From 78a52c8372a58f2d5276a66bb5c7ecfe49310dc0 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 23 Sep 2026 17:52:50 +0800 Subject: [PATCH] feat(github-bridge): send GitHub events as Telegram posts --- .../EventDispatch/Broadcast.cs | 32 ++++- .../EventDispatch/GitHubPosts.cs | 79 +++++++++++++ .../EventDispatch/IssuesEventDispatcher.cs | 86 +++++++------- .../EventDispatch/PushEventDispatcher.cs | 40 +++---- src/HuaJiBot.NET.UnitTest/BroadcastTest.cs | 27 ++--- src/HuaJiBot.NET.UnitTest/GitHubPostTest.cs | 111 ++++++++++++++++++ 6 files changed, 291 insertions(+), 84 deletions(-) create mode 100644 src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/GitHubPosts.cs create mode 100644 src/HuaJiBot.NET.UnitTest/GitHubPostTest.cs diff --git a/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/Broadcast.cs b/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/Broadcast.cs index d084f01..931e2af 100644 --- a/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/Broadcast.cs +++ b/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/Broadcast.cs @@ -1,6 +1,7 @@ using HuaJiBot.NET.Bot; using HuaJiBot.NET.Interfaces; using HuaJiBot.NET.Plugin.GitHubBridge.Types.Generic; +using HuaJiBot.NET.Utils; namespace HuaJiBot.NET.Plugin.GitHubBridge.EventDispatch; @@ -9,15 +10,13 @@ internal static class Broadcast internal static async Task SendAsync( IPluginService service, IEnumerable targets, - Func> build + params SendingMessageBase[] messages ) { - SendingMessageBase[]? messages = null; foreach (var target in targets) { try { - messages ??= await build(); await service.SendGroupMessageAsync(null, target, messages); } catch (Exception e) @@ -30,3 +29,30 @@ Func> build internal static bool IsBot(Sender sender) => sender.Type == "Bot" || sender.Login.EndsWith("[bot]"); } + +// Each card renders at most once per broadcast, however many targets and platforms use it. +internal sealed class Cards : IDisposable +{ + private readonly List _files = []; + + internal static Func> Once(Func> build) + { + Lazy> value = new(build); + return () => value.Value; + } + + internal Func> Card(Func> build) => + Once(async () => + { + var file = await build(); + lock (_files) + _files.Add(file); + return (string)file; + }); + + public void Dispose() + { + foreach (var file in _files) + file.Dispose(); + } +} diff --git a/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/GitHubPosts.cs b/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/GitHubPosts.cs new file mode 100644 index 0000000..b14fad9 --- /dev/null +++ b/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/GitHubPosts.cs @@ -0,0 +1,79 @@ +using HuaJiBot.NET.Bot; +using HuaJiBot.NET.Plugin.GitHubBridge.Types.IssueCommentEventBody; +using HuaJiBot.NET.Plugin.GitHubBridge.Types.IssuesEventBody; +using HuaJiBot.NET.Plugin.GitHubBridge.Types.PushEventBody; + +namespace HuaJiBot.NET.Plugin.GitHubBridge.EventDispatch; + +internal static class GitHubPosts +{ + internal const int ExcerptLimit = 280; + + internal static Post Issue( + IssuesEventBody body, + Func> card, + Func> fallback + ) => + new( + $"{Post.Escape(body.Sender.Login)} {Verb(body.Action)} #{body.Issue.Number}", + fallback + ) + { + Image = card, + Tags = ["issue", body.Repository.Name], + Links = [new($"打开 #{body.Issue.Number}", body.Issue.HtmlUrl.ToString())], + }; + + internal static Post Comment( + IssueCommentEventBody body, + Func> card, + Func> fallback + ) => + new($"{Post.Escape(body.Sender.Login)} 评论了 #{body.Issue.Number}", fallback) + { + Image = card, + Tags = ["comment", body.Repository.Name], + Links = [new("查看评论", body.Comment.HtmlUrl.ToString())], + }; + + internal static Post Push( + PushEventBody body, + Func> card, + Func> fallback + ) => + new( + $"{Post.Escape(body.Sender.Login)} 向 {Post.Escape(body.Ref.Split('/').Last())} 推送了 {body.Commits.Length} 个提交", + fallback + ) + { + Image = card, + Tags = ["push", body.Repository.Name], + Links = [new("查看改动", body.Compare.ToString())], + }; + + internal static string? Excerpt(string? body) + { + if (body is null) + return null; + var text = body.ReplaceLineEndings("\n").Trim(); + var lines = text.Split('\n'); + if (lines.Length <= 6 && text.Length <= ExcerptLimit) + return text; + var head = string.Join('\n', lines.Take(3)); + if (lines.Length > 3 && head.Length < ExcerptLimit) + return head + '…'; + var end = ExcerptLimit - 1; + if (char.IsLowSurrogate(head[end])) + end--; + return head[..end] + '…'; + } + + private static string Verb(string action) => + action switch + { + "opened" => "打开了", + "closed" => "关闭了", + "reopened" => "重新打开了", + _ => action, + }; +} diff --git a/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/IssuesEventDispatcher.cs b/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/IssuesEventDispatcher.cs index 57513ed..afedde1 100644 --- a/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/IssuesEventDispatcher.cs +++ b/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/IssuesEventDispatcher.cs @@ -26,23 +26,22 @@ IssueCommentEventBody body plugin.Info($"IssueCommentEvent {repositoryFullName} {body.Action}"); if (!ShouldBroadcast(body)) return; - TempFile.AutoDeleteFile? tempImage = null; - try - { - await Broadcast.SendAsync( - plugin.Service, - plugin.GetBroadcastTargets(body.Repository), - async () => - [ - new ImageMessage(tempImage = await BuildCommentCardAsync(body)), - new LinkMessage("View comment", await plugin.OrRawAsync(body.Issue.HtmlUrl)), - ] - ); - } - finally - { - tempImage?.Dispose(); - } + using Cards cards = new(); + var post = GitHubPosts.Comment( + body, + cards.Card(() => BuildCommentCardAsync(body, GitHubPosts.Excerpt(body.Comment.Body))), + Cards.Once(async () => + [ + new ImageMessage(await cards.Card(() => BuildCommentCardAsync(body, body.Comment.Body))()), + new LinkMessage("View comment", await plugin.OrRawAsync(body.Issue.HtmlUrl)), + ] + ) + ); + await Broadcast.SendAsync( + plugin.Service, + plugin.GetBroadcastTargets(body.Repository), + post + ); } public static async Task DispatchIssuesEventAsync(this PluginMain plugin, IssuesEventBody body) @@ -51,45 +50,50 @@ public static async Task DispatchIssuesEventAsync(this PluginMain plugin, Issues plugin.Info($"IssuesEvent {repositoryFullName} {body.Action}"); if (!ShouldBroadcast(body)) return; - TempFile.AutoDeleteFile? tempImage = null; - try - { - await Broadcast.SendAsync( - plugin.Service, - plugin.GetBroadcastTargets(body.Repository), - async () => - [ - new ImageMessage(tempImage = await BuildIssueCardAsync(body)), - new LinkMessage( - $"Open #{body.Issue.Number}", - await plugin.OrRawAsync(body.Issue.HtmlUrl) - ), - ] - ); - } - finally - { - tempImage?.Dispose(); - } + using Cards cards = new(); + var post = GitHubPosts.Issue( + body, + cards.Card(() => BuildIssueCardAsync(body, GitHubPosts.Excerpt(body.Issue.Body))), + Cards.Once(async () => + [ + new ImageMessage(await cards.Card(() => BuildIssueCardAsync(body, body.Issue.Body))()), + new LinkMessage( + $"Open #{body.Issue.Number}", + await plugin.OrRawAsync(body.Issue.HtmlUrl) + ), + ] + ) + ); + await Broadcast.SendAsync( + plugin.Service, + plugin.GetBroadcastTargets(body.Repository), + post + ); } - private static Task BuildCommentCardAsync(IssueCommentEventBody body) => + private static Task BuildCommentCardAsync( + IssueCommentEventBody body, + string? content + ) => BuildCardAsync( body.Repository, body.Issue, body.Sender, body.Issue.State, - body.Comment.Body, + content, $"@{body.Sender.Login} {body.Action} comment." ); - private static Task BuildIssueCardAsync(IssuesEventBody body) => + private static Task BuildIssueCardAsync( + IssuesEventBody body, + string? content + ) => BuildCardAsync( body.Repository, body.Issue, body.Sender, body.Action is "opened" or "reopened" ? "open" : body.Action, - body.Issue.Body, + content, $"@{body.Sender.Login} {body.Action} issue" ); diff --git a/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/PushEventDispatcher.cs b/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/PushEventDispatcher.cs index 3ecdbbb..c6a68be 100644 --- a/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/PushEventDispatcher.cs +++ b/src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/PushEventDispatcher.cs @@ -27,28 +27,24 @@ public static async Task DispatchPushEventAsync(this PluginMain plugin, PushEven } var repositoryFullName = body.Repository.FullName; plugin.Info($"PushEvent {repositoryFullName} {body.Ref}"); - { - if (!ShouldBroadcast(body)) - return; - - TempFile.AutoDeleteFile? tempImage = null; - try - { - await Broadcast.SendAsync( - plugin.Service, - plugin.GetBroadcastTargets(body.Repository), - async () => - [ - new ImageMessage(tempImage = await BuildPushCardAsync(body)), - new LinkMessage("View changes", await plugin.OrRawAsync(body.Compare)), - ] - ); - } - finally - { - tempImage?.Dispose(); - } - } + if (!ShouldBroadcast(body)) + return; + using Cards cards = new(); + var card = cards.Card(() => BuildPushCardAsync(body)); + await Broadcast.SendAsync( + plugin.Service, + plugin.GetBroadcastTargets(body.Repository), + GitHubPosts.Push( + body, + card, + Cards.Once(async () => + [ + new ImageMessage(await card()), + new LinkMessage("View changes", await plugin.OrRawAsync(body.Compare)), + ] + ) + ) + ); } private static async Task BuildPushCardAsync(PushEventBody body) diff --git a/src/HuaJiBot.NET.UnitTest/BroadcastTest.cs b/src/HuaJiBot.NET.UnitTest/BroadcastTest.cs index 6008503..93769a4 100644 --- a/src/HuaJiBot.NET.UnitTest/BroadcastTest.cs +++ b/src/HuaJiBot.NET.UnitTest/BroadcastTest.cs @@ -6,36 +6,27 @@ namespace HuaJiBot.NET.UnitTest; internal class BroadcastTest { [Test] - public async Task SendAsync_WithMultipleTargets_BuildsMessagesOnce() + public async Task SendAsync_SendsSameMessagesToEachTarget() { RecordingAdapter adapter = new(); - var built = 0; - await Broadcast.SendAsync( - adapter, - ["g1", "g2"], - () => - { - built++; - return Task.FromResult([new TextMessage("card")]); - } - ); + await Broadcast.SendAsync(adapter, ["g1", "g2"], new TextMessage("card")); Assert.Multiple(() => { - Assert.That(built, Is.EqualTo(1)); Assert.That(adapter.Sends.Select(x => x.Target), Is.EqualTo(new[] { "g1", "g2" })); Assert.That(adapter.Sends.Select(x => x.Messages[0]), Is.All.EqualTo(new TextMessage("card"))); }); } [Test] - public async Task SendAsync_WithoutTargets_NeverBuildsMessages() + public async Task Cards_RenderEachCardOnce() { - await Broadcast.SendAsync( - new RecordingAdapter(), - [], - () => throw new InvalidOperationException("messages must not be built") - ); + var renders = 0; + var card = Cards.Once(() => Task.FromResult(++renders)); + + await Task.WhenAll(card(), card(), card()); + + Assert.That(renders, Is.EqualTo(1)); } } diff --git a/src/HuaJiBot.NET.UnitTest/GitHubPostTest.cs b/src/HuaJiBot.NET.UnitTest/GitHubPostTest.cs new file mode 100644 index 0000000..1b2f482 --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/GitHubPostTest.cs @@ -0,0 +1,111 @@ +using HuaJiBot.NET.Bot; +using HuaJiBot.NET.Plugin.GitHubBridge.EventDispatch; +using HuaJiBot.NET.Plugin.GitHubBridge.Types.Generic; +using HuaJiBot.NET.Plugin.GitHubBridge.Types.IssueCommentEventBody; +using HuaJiBot.NET.Plugin.GitHubBridge.Types.IssuesEventBody; +using HuaJiBot.NET.Plugin.GitHubBridge.Types.PushEventBody; + +namespace HuaJiBot.NET.UnitTest; + +internal class GitHubPostTest +{ + private static readonly Repository Repo = new() { Name = "HuaJiBot.NET", DefaultBranch = "main" }; + private static readonly Sender User = new() { Login = "m1ng_sama" }; + private static readonly Func> Card = () => Task.FromResult("card.png"); + private static readonly Func> Old = () => + Task.FromResult([new TextMessage("old")]); + + private static Issue Issue52 => + new() { Number = 52, HtmlUrl = new("https://github.com/nbtca/HuaJiBot.NET/issues/52") }; + + [TestCase("opened", "打开了")] + [TestCase("closed", "关闭了")] + [TestCase("reopened", "重新打开了")] + public void Issue_DescribesActionAndLinksIssue(string action, string verb) + { + var post = GitHubPosts.Issue( + new IssuesEventBody { Action = action, Sender = User, Repository = Repo, Issue = Issue52 }, + Card, + Old + ); + + Assert.Multiple(() => + { + Assert.That(post.Markdown, Is.EqualTo($"m1ng\\_sama {verb} #52")); + Assert.That(post.Tags, Is.EqualTo(new[] { "issue", "HuaJiBot.NET" })); + Assert.That( + post.Links, + Is.EqualTo(new[] { new LinkMessage("打开 #52", "https://github.com/nbtca/HuaJiBot.NET/issues/52") }) + ); + Assert.That(post.Image, Is.SameAs(Card)); + Assert.That(post.Fallback, Is.SameAs(Old)); + }); + } + + [Test] + public void Comment_LinksTheComment() + { + var post = GitHubPosts.Comment( + new IssueCommentEventBody + { + Action = "created", + Sender = User, + Repository = Repo, + Issue = Issue52, + Comment = new() { HtmlUrl = new("https://github.com/nbtca/HuaJiBot.NET/issues/52#issuecomment-1") }, + }, + Card, + Old + ); + + Assert.Multiple(() => + { + Assert.That(post.Markdown, Is.EqualTo("m1ng\\_sama 评论了 #52")); + Assert.That(post.Tags, Is.EqualTo(new[] { "comment", "HuaJiBot.NET" })); + Assert.That(post.Links.Single().Url, Does.EndWith("#issuecomment-1")); + }); + } + + [Test] + public void Push_CountsCommitsOnBranch() + { + var post = GitHubPosts.Push( + new PushEventBody + { + Ref = "refs/heads/main", + Sender = User, + Repository = Repo, + Commits = [new(), new(), new()], + Compare = new("https://github.com/nbtca/HuaJiBot.NET/compare/a...b"), + }, + Card, + Old + ); + + Assert.Multiple(() => + { + Assert.That(post.Markdown, Is.EqualTo("m1ng\\_sama 向 main 推送了 3 个提交")); + Assert.That(post.Tags, Is.EqualTo(new[] { "push", "HuaJiBot.NET" })); + Assert.That(post.Links.Single(), Is.EqualTo(new LinkMessage("查看改动", "https://github.com/nbtca/HuaJiBot.NET/compare/a...b"))); + }); + } + + [Test] + public void Excerpt_KeepsShortBody() => + Assert.That(GitHubPosts.Excerpt("one\ntwo"), Is.EqualTo("one\ntwo")); + + [Test] + public void Excerpt_CutsManyLinesToThree() => + Assert.That(GitHubPosts.Excerpt("1\n2\n3\n4\n5\n6\n7"), Is.EqualTo("1\n2\n3…")); + + [Test] + public void Excerpt_CutsLongParagraph() + { + var excerpt = GitHubPosts.Excerpt(new string('a', 500))!; + Assert.Multiple(() => + { + Assert.That(excerpt, Has.Length.EqualTo(GitHubPosts.ExcerptLimit)); + Assert.That(excerpt, Does.EndWith("…")); + }); + } +}