Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/HuaJiBot.NET.Adapter.OneBot/OneBotAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public override async Task<string[]> SendGroupMessageAsync(
params SendingMessageBase[] messages
)
{
messages = await messages.ExpandPostsAsync();
var result = await Client.Api.SendGroupMessageAsync(
targetGroup,
messages
Expand Down
1 change: 1 addition & 0 deletions src/HuaJiBot.NET.Adapter.Satori/SatoriAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public override async Task<string[]> SendGroupMessageAsync(
params SendingMessageBase[] messages
)
{
messages = await messages.ExpandPostsAsync();
var robots = robotId is null ? AllRobots : [robotId];
var msgIds = new List<string>();
foreach (var robot in robots)
Expand Down
70 changes: 64 additions & 6 deletions src/HuaJiBot.NET.Adapter.Telegram/TelegramAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ params SendingMessageBase[] messages
string? imagePathToSend = null;
ReplyParameters? replyParameters = null;
List<InlineKeyboardButton> buttons = [];
Post? post = null;

foreach (var message in messages)
{
Expand All @@ -113,10 +114,7 @@ params SendingMessageBase[] messages
case TextMessage { Text: var text }:
if (textBuilder.Length > 0)
textBuilder.Append(' ');
// Escape HTML special characters
textBuilder.Append(
text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;")
);
textBuilder.Append(TelegramHtml.Escape(text));
break;

case AtMessage { Target: var target }
Expand All @@ -135,6 +133,10 @@ when ToReplyParameters(msgId) is { } reply:
buttons.Add(InlineKeyboardButton.WithUrl(text, url));
break;

case Post p:
post = p;
break;

case ImageMessage { ImagePath: var path }:
// If we have an image, we'll send it with the text as caption
imagePathToSend = path;
Expand All @@ -147,6 +149,9 @@ when ToReplyParameters(msgId) is { } reply:
}
}

if (post is not null)
return [(await SendPostAsync(chatId, topicId, post, replyParameters)).MessageId.ToString()];

// Send the combined message
Message? sentMessage = null;
var combinedText = textBuilder.ToString();
Expand Down Expand Up @@ -194,6 +199,59 @@ when ToReplyParameters(msgId) is { } reply:
return messageIds.ToArray();
}

private async Task<Message> SendPostAsync(
ChatId chatId,
int? topicId,
Post post,
ReplyParameters? replyParameters
)
{
var replyMarkup = post.Links.Length == 0
? null
: new InlineKeyboardMarkup(
post.Links.Take(3).Select(x => InlineKeyboardButton.WithUrl(x.Text, x.Url))
);
try
{
return await SendAsync(ParseMode.Html, TelegramHtml.Compose);
}
catch (ApiRequestException ex) when (ex.Message.Contains("can't parse entities"))
{
LogError("Telegram rejected the post HTML, resending as plain text", ex);
return await SendAsync(ParseMode.None, TelegramHtml.ComposePlain);
}

async Task<Message> SendAsync(ParseMode parseMode, Func<Post, int, string> compose)
{
if (post.Image is { } image)
{
await using var photo = await OpenPhotoAsync(await image());
return await _botClient.SendPhoto(
chatId,
InputFile.FromStream(photo),
caption: compose(post, TelegramHtml.CaptionLimit),
parseMode: parseMode,
replyParameters: replyParameters,
replyMarkup: replyMarkup,
messageThreadId: topicId,
disableNotification: post.Silent,
cancellationToken: _cancellationTokenSource.Token
);
}
return await _botClient.SendMessage(
chatId,
compose(post, TelegramHtml.TextLimit),
parseMode: parseMode,
replyParameters: replyParameters,
replyMarkup: replyMarkup,
linkPreviewOptions: new LinkPreviewOptions { IsDisabled = true },
messageThreadId: topicId,
disableNotification: post.Silent,
cancellationToken: _cancellationTokenSource.Token
);
}
}

// Telegram re-encodes photos as JPEG, which turns the card's transparent corners white.
internal static async Task<Stream> OpenPhotoAsync(string path)
{
Expand Down Expand Up @@ -265,7 +323,7 @@ public override void RecallMessage(string? robotId, string targetGroup, string m
try
{
await _botClient.DeleteMessage(
new(targetGroup),
GroupTopic.Parse(targetGroup).GroupId,
messageId,
_cancellationTokenSource.Token
);
Expand All @@ -292,7 +350,7 @@ protected override void SetGroupNameCore(string? robotId, string targetGroup, st
try
{
await _botClient.SetChatTitle(
new(targetGroup),
GroupTopic.Parse(targetGroup).GroupId,
groupName,
_cancellationTokenSource.Token
);
Expand Down
90 changes: 90 additions & 0 deletions src/HuaJiBot.NET.Adapter.Telegram/TelegramHtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Text.RegularExpressions;
using HuaJiBot.NET.Bot;
using Markdig;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;

namespace HuaJiBot.NET.Adapter.Telegram;

internal static partial class TelegramHtml
{
internal const int CaptionLimit = 1024;
internal const int TextLimit = 4096;

internal static string Compose(Post post, int limit) =>
Fit(Head(post), post.Markdown, limit, Render);

internal static string ComposePlain(Post post, int limit) =>
Fit(Head(post), post.Markdown, limit, x => Markdown.ToPlainText(x).Trim());

internal static string Tag(string tag) => "#" + NonTagChars().Replace(tag, "_");

internal static string Render(string markdown) =>
string.Join("\n\n", Markdown.Parse(markdown).Select(Block).Where(x => x.Length > 0));

private static string Head(Post post) =>
post.Tags.Length == 0 ? "" : string.Join(' ', post.Tags.Select(Tag)) + "\n\n";

private static string Fit(string head, string markdown, int limit, Func<string, string> render)
{
var text = head + render(markdown);
for (var max = markdown.Length; text.Length > limit && max > 1;)
{
max -= text.Length - limit;
text = head + render(Truncate(markdown, max));
}
return text;
}

private static string Truncate(string text, int max)
{
var end = Math.Max(max - 1, 0);
if (end > 0 && char.IsLowSurrogate(text[end]))
end--;
return text[..end] + '…';
}

private static string Block(Block block) =>
block switch
{
ParagraphBlock p => Inlines(p.Inline),
HeadingBlock h => $"<b>{Inlines(h.Inline)}</b>",
ListBlock list => string.Join(
"\n",
from ListItemBlock item in list
select "• " + string.Join("\n", item.Select(Block))
),
QuoteBlock quote => $"<blockquote>{string.Join("\n", quote.Select(Block))}</blockquote>",
LeafBlock { Lines: var lines } leaf when leaf is CodeBlock =>
$"<pre>{Escape(lines.ToString())}</pre>",
LeafBlock { Lines: var lines } => Escape(lines.ToString()),
ContainerBlock container => string.Join("\n", container.Select(Block)),
_ => "",
};

private static string Inlines(ContainerInline? container) =>
container is null ? "" : string.Concat(container.Select(Inline));

private static string Inline(Markdig.Syntax.Inlines.Inline inline) =>
inline switch
{
LiteralInline literal => Escape(literal.Content.ToString()),
EmphasisInline { DelimiterCount: 2 } e => $"<b>{Inlines(e)}</b>",
EmphasisInline e => $"<i>{Inlines(e)}</i>",
CodeInline code => $"<code>{Escape(code.Content)}</code>",
LinkInline { IsImage: true } link => Inlines(link),
LinkInline link => $"<a href=\"{Escape(link.Url)}\">{Inlines(link)}</a>",
AutolinkInline link => $"<a href=\"{Escape(link.Url)}\">{Escape(link.Url)}</a>",
LineBreakInline => "\n",
HtmlInline html => Escape(html.Tag),
HtmlEntityInline entity => Escape(entity.Transcoded.ToString()),
ContainerInline container => Inlines(container),
_ => "",
};

internal static string Escape(string? text) =>
(text ?? "").Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");

[GeneratedRegex(@"[^\p{L}\p{N}_]")]
private static partial Regex NonTagChars();
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ await Broadcast.SendAsync(
async () =>
[
new ImageMessage(tempImage = await BuildCommentCardAsync(body)),
new LinkMessage("View comment", await plugin.OrRawAsync(body.Comment.HtmlUrl)),
new LinkMessage("View comment", await plugin.OrRawAsync(body.Issue.HtmlUrl)),
]
);
}
Expand Down
6 changes: 6 additions & 0 deletions src/HuaJiBot.NET.UnitTest/TelegramAdapterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ internal sealed class FakeBotApi : HttpMessageHandler
public string? RichMessageBody { get; private set; }
public List<string> Bodies { get; } = [];
public bool RejectRichMessage { get; init; }
public bool RejectHtml { get; init; }

protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
Expand All @@ -103,6 +104,11 @@ CancellationToken cancellationToken
RichMessageBody = content;
var (status, body) = method switch
{
_ when RejectHtml && content.Contains("\"parse_mode\":\"Html\"") => (
HttpStatusCode.BadRequest,
"""{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}"""
),
"deleteMessage" => (HttpStatusCode.OK, """{"ok":true,"result":true}"""),
"sendRichMessage" when RejectRichMessage => (
HttpStatusCode.BadRequest,
"""{"ok":false,"error_code":400,"description":"Bad Request: can't parse"}"""
Expand Down
Loading
Loading