diff --git a/src/HuaJiBot.NET.Adapter.OneBot/OneBotAdapter.cs b/src/HuaJiBot.NET.Adapter.OneBot/OneBotAdapter.cs index a2ea67c..019750c 100644 --- a/src/HuaJiBot.NET.Adapter.OneBot/OneBotAdapter.cs +++ b/src/HuaJiBot.NET.Adapter.OneBot/OneBotAdapter.cs @@ -27,6 +27,7 @@ public override async Task SendGroupMessageAsync( params SendingMessageBase[] messages ) { + messages = await messages.ExpandPostsAsync(); var result = await Client.Api.SendGroupMessageAsync( targetGroup, messages diff --git a/src/HuaJiBot.NET.Adapter.Satori/SatoriAdapter.cs b/src/HuaJiBot.NET.Adapter.Satori/SatoriAdapter.cs index 6652335..077f656 100644 --- a/src/HuaJiBot.NET.Adapter.Satori/SatoriAdapter.cs +++ b/src/HuaJiBot.NET.Adapter.Satori/SatoriAdapter.cs @@ -53,6 +53,7 @@ public override async Task SendGroupMessageAsync( params SendingMessageBase[] messages ) { + messages = await messages.ExpandPostsAsync(); var robots = robotId is null ? AllRobots : [robotId]; var msgIds = new List(); foreach (var robot in robots) diff --git a/src/HuaJiBot.NET.Adapter.Telegram/TelegramAdapter.cs b/src/HuaJiBot.NET.Adapter.Telegram/TelegramAdapter.cs index b4be0ee..7930eb8 100644 --- a/src/HuaJiBot.NET.Adapter.Telegram/TelegramAdapter.cs +++ b/src/HuaJiBot.NET.Adapter.Telegram/TelegramAdapter.cs @@ -105,6 +105,7 @@ params SendingMessageBase[] messages string? imagePathToSend = null; ReplyParameters? replyParameters = null; List buttons = []; + Post? post = null; foreach (var message in messages) { @@ -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("&", "&").Replace("<", "<").Replace(">", ">") - ); + textBuilder.Append(TelegramHtml.Escape(text)); break; case AtMessage { Target: var target } @@ -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; @@ -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(); @@ -194,6 +199,59 @@ when ToReplyParameters(msgId) is { } reply: return messageIds.ToArray(); } + private async Task 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 SendAsync(ParseMode parseMode, Func 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 OpenPhotoAsync(string path) { @@ -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 ); @@ -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 ); diff --git a/src/HuaJiBot.NET.Adapter.Telegram/TelegramHtml.cs b/src/HuaJiBot.NET.Adapter.Telegram/TelegramHtml.cs new file mode 100644 index 0000000..feedcd7 --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Telegram/TelegramHtml.cs @@ -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 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 => $"{Inlines(h.Inline)}", + ListBlock list => string.Join( + "\n", + from ListItemBlock item in list + select "• " + string.Join("\n", item.Select(Block)) + ), + QuoteBlock quote => $"
{string.Join("\n", quote.Select(Block))}
", + LeafBlock { Lines: var lines } leaf when leaf is CodeBlock => + $"
{Escape(lines.ToString())}
", + 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 => $"{Inlines(e)}", + EmphasisInline e => $"{Inlines(e)}", + CodeInline code => $"{Escape(code.Content)}", + LinkInline { IsImage: true } link => Inlines(link), + LinkInline link => $"{Inlines(link)}", + AutolinkInline link => $"{Escape(link.Url)}", + 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("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); + + [GeneratedRegex(@"[^\p{L}\p{N}_]")] + private static partial Regex NonTagChars(); +} diff --git a/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/IssuesEventDispatcher.cs b/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/IssuesEventDispatcher.cs index 4621b30..57513ed 100644 --- a/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/IssuesEventDispatcher.cs +++ b/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/IssuesEventDispatcher.cs @@ -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)), ] ); } diff --git a/src/HuaJiBot.NET.UnitTest/TelegramAdapterTest.cs b/src/HuaJiBot.NET.UnitTest/TelegramAdapterTest.cs index 59f0546..1c3f1a0 100644 --- a/src/HuaJiBot.NET.UnitTest/TelegramAdapterTest.cs +++ b/src/HuaJiBot.NET.UnitTest/TelegramAdapterTest.cs @@ -89,6 +89,7 @@ internal sealed class FakeBotApi : HttpMessageHandler public string? RichMessageBody { get; private set; } public List Bodies { get; } = []; public bool RejectRichMessage { get; init; } + public bool RejectHtml { get; init; } protected override async Task SendAsync( HttpRequestMessage request, @@ -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"}""" diff --git a/src/HuaJiBot.NET.UnitTest/TelegramPostTest.cs b/src/HuaJiBot.NET.UnitTest/TelegramPostTest.cs new file mode 100644 index 0000000..e24b5b3 --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/TelegramPostTest.cs @@ -0,0 +1,168 @@ +using HuaJiBot.NET.Adapter.Telegram; +using HuaJiBot.NET.Bot; +using HuaJiBot.NET.Logger; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace HuaJiBot.NET.UnitTest; + +internal class TelegramHtmlTest +{ + [TestCase("a < b & c", "a < b & c")] + [TestCase("**bold** *it* `xbold it x<y")] + [TestCase("[#22](https://example.com/?a=1&b=2)", "#22")] + [TestCase("## Title", "Title")] + [TestCase("- one\n- two", "• one\n• two")] + [TestCase("> quoted\n> more", "
quoted\nmore
")] + [TestCase("first\n\nsecond", "first\n\nsecond")] + [TestCase("above\n\n---\n\nbelow", "above\n\nbelow")] + [TestCase("raw", "<b>raw</b>")] + public void Render_ProducesTelegramHtml(string markdown, string html) => + Assert.That(TelegramHtml.Render(markdown), Is.EqualTo(html)); + + [TestCase("HuaJiBot.NET", "#HuaJiBot_NET")] + [TestCase("日程", "#日程")] + [TestCase("mcje-plugins", "#mcje_plugins")] + public void Tag_KeepsLettersDigitsAndUnderscore(string tag, string expected) => + Assert.That(TelegramHtml.Tag(tag), Is.EqualTo(expected)); + + [Test] + public void Compose_TruncatesLongBody() + { + var html = TelegramHtml.Compose(new Post(new string('a', 2000), Nothing), 1024); + Assert.Multiple(() => + { + Assert.That(html, Has.Length.LessThanOrEqualTo(1024)); + Assert.That(html, Does.EndWith("…")); + }); + } + + [Test] + public void Compose_DoesNotSplitSurrogatePair() + { + var html = TelegramHtml.Compose(new Post(new string('a', 1022) + "😊😊", Nothing), 1024); + Assert.That(html, Is.EqualTo(new string('a', 1022) + "…")); + } + + internal static Task Nothing() => Task.FromResult([]); +} + +internal class TelegramPostTest +{ + private static TelegramAdapter Adapter(TelegramRichMessageFallbackTest.FakeBotApi api) => + new("123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", new(api)) { Logger = new ConsoleLogger() }; + + [Test] + public async Task SendGroupMessageAsync_WithTextPost_SendsHtmlMessage() + { + TelegramRichMessageFallbackTest.FakeBotApi api = new(); + + await Adapter(api) + .SendGroupMessageAsync( + null, + "-100:7", + new Post("**Steve** joined", TelegramHtmlTest.Nothing) + { + Tags = ["mc", "HuaJiBot.NET"], + Links = [new LinkMessage("查看", "https://example.com")], + Silent = true, + } + ); + + var body = api.Bodies.Single(); + Assert.Multiple(() => + { + Assert.That(api.Methods, Is.EqualTo(new[] { "sendMessage" })); + Assert.That(body, Does.Contain("\"text\":\"#mc #HuaJiBot_NET\\n\\n\\u003Cb\\u003ESteve\\u003C/b\\u003E joined\"")); + Assert.That(body, Does.Contain("\"parse_mode\":\"Html\"")); + Assert.That(body, Does.Contain("\"disable_notification\":true")); + Assert.That(body, Does.Contain("\"is_disabled\":true")); + Assert.That(body, Does.Contain("\"message_thread_id\":7")); + Assert.That(body, Does.Contain("\"url\":\"https://example.com\"")); + }); + } + + [Test] + public async Task SendGroupMessageAsync_WithImagePost_SendsPhotoWithCaption() + { + TelegramRichMessageFallbackTest.FakeBotApi api = new(); + var path = Path.GetTempFileName(); + using (var image = new Image(1, 1)) + await image.SaveAsPngAsync(path); + + await Adapter(api) + .SendGroupMessageAsync( + null, + "-100", + new Post("pushed", TelegramHtmlTest.Nothing) { Image = () => Task.FromResult(path), Tags = ["push"] } + ); + File.Delete(path); + + Assert.Multiple(() => + { + Assert.That(api.Methods, Is.EqualTo(new[] { "sendPhoto" })); + Assert.That(api.Bodies.Single(), Does.Contain("#push\n\npushed")); + }); + } + + [Test] + public async Task SendGroupMessageAsync_WhenHtmlIsRejected_ResendsPlainText() + { + TelegramRichMessageFallbackTest.FakeBotApi api = new() { RejectHtml = true }; + + var ids = await Adapter(api) + .SendGroupMessageAsync( + null, + "-100", + new Post("**Steve** joined", TelegramHtmlTest.Nothing) { Tags = ["mc"] } + ); + + Assert.Multiple(() => + { + Assert.That(api.Methods, Is.EqualTo(new[] { "sendMessage", "sendMessage" })); + Assert.That(api.Bodies[1], Does.Not.Contain("parse_mode")); + Assert.That(api.Bodies[1], Does.Contain("\"text\":\"#mc\\n\\nSteve joined\"")); + Assert.That(ids, Is.EqualTo(new[] { "99" })); + }); + } + + [Test] + public async Task RecallMessage_InTopic_UsesGroupId() + { + TelegramRichMessageFallbackTest.FakeBotApi api = new(); + + Adapter(api).RecallMessage(null, "-100:7", "5"); + for (var i = 0; i < 50 && api.Bodies.Count == 0; i++) + await Task.Delay(20); + + Assert.That(api.Bodies.Single(), Does.Contain("\"chat_id\":-100")); + } +} + +internal class PostFallbackTest +{ + [Test] + public void Escape_KeepsMarkdownLiteral() => + Assert.That( + TelegramHtml.Render(Post.Escape("my_name *x* [a](b) 中文 1.")), + Is.EqualTo("my_name *x* [a](b) 中文 1.") + ); + + [Test] + public async Task ExpandPostsAsync_ReplacesPostWithFallback() + { + SendingMessageBase[] messages = + [ + new ReplyMessage("1"), + new Post( + "**new**", + () => Task.FromResult([new TextMessage("old"), new ImageMessage("a.png")]) + ), + ]; + + Assert.That( + await messages.ExpandPostsAsync(), + Is.EqualTo(new SendingMessageBase[] { new ReplyMessage("1"), new TextMessage("old"), new ImageMessage("a.png") }) + ); + } +} diff --git a/src/HuaJiBot.NET/Bot/BotService.cs b/src/HuaJiBot.NET/Bot/BotService.cs index 17c3242..1dfca29 100644 --- a/src/HuaJiBot.NET/Bot/BotService.cs +++ b/src/HuaJiBot.NET/Bot/BotService.cs @@ -27,6 +27,40 @@ public sealed record AtMessage(string Target) : SendingMessageBase; public sealed record LinkMessage(string Text, string Url) : SendingMessageBase; +/// A Telegram post. Other adapters send instead. +public sealed record Post(string Markdown, Func> Fallback) + : SendingMessageBase +{ + public Func>? Image { get; init; } + public string[] Tags { get; init; } = []; + public LinkMessage[] Links { get; init; } = []; + public bool Silent { get; init; } + + public static string Escape(string text) => + string.Concat( + from c in text + select char.IsAsciiLetterOrDigit(c) || !char.IsAscii(c) || char.IsWhiteSpace(c) + ? c.ToString() + : "\\" + c + ); +} + +public static class SendingMessageExtensions +{ + public static async Task ExpandPostsAsync( + this SendingMessageBase[] messages + ) + { + List result = []; + foreach (var message in messages) + if (message is Post post) + result.AddRange(await post.Fallback()); + else + result.Add(message); + return [.. result]; + } +} + public sealed record ReplyMessage(string MessageId) : SendingMessageBase; public sealed record RichContent(string Markdown, string? ReplyToMessageId = null)