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
32 changes: 29 additions & 3 deletions src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/Broadcast.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -9,15 +10,13 @@ internal static class Broadcast
internal static async Task SendAsync(
IPluginService service,
IEnumerable<string> targets,
Func<Task<SendingMessageBase[]>> 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)
Expand All @@ -30,3 +29,30 @@ Func<Task<SendingMessageBase[]>> 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<TempFile.AutoDeleteFile> _files = [];

internal static Func<Task<T>> Once<T>(Func<Task<T>> build)
{
Lazy<Task<T>> value = new(build);
return () => value.Value;
}

internal Func<Task<string>> Card(Func<Task<TempFile.AutoDeleteFile>> 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();
}
}
79 changes: 79 additions & 0 deletions src/HuaJiBot.NET.Plugin.GitHubBridge/EventDispatch/GitHubPosts.cs
Original file line number Diff line number Diff line change
@@ -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<Task<string>> card,
Func<Task<SendingMessageBase[]>> 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<Task<string>> card,
Func<Task<SendingMessageBase[]>> 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<Task<string>> card,
Func<Task<SendingMessageBase[]>> 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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<SendingMessageBase[]>(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)
Expand All @@ -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<SendingMessageBase[]>(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<TempFile.AutoDeleteFile> BuildCommentCardAsync(IssueCommentEventBody body) =>
private static Task<TempFile.AutoDeleteFile> 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<TempFile.AutoDeleteFile> BuildIssueCardAsync(IssuesEventBody body) =>
private static Task<TempFile.AutoDeleteFile> 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"
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SendingMessageBase[]>(async () =>
[
new ImageMessage(await card()),
new LinkMessage("View changes", await plugin.OrRawAsync(body.Compare)),
]
)
)
);
}

private static async Task<TempFile.AutoDeleteFile> BuildPushCardAsync(PushEventBody body)
Expand Down
27 changes: 9 additions & 18 deletions src/HuaJiBot.NET.UnitTest/BroadcastTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SendingMessageBase[]>([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));
}
}
Loading
Loading