diff --git a/src/HuaJiBot.NET.Plugin.Calendar/ClubAffairsReminder.cs b/src/HuaJiBot.NET.Plugin.Calendar/ClubAffairsReminder.cs
index f98f407..081587b 100644
--- a/src/HuaJiBot.NET.Plugin.Calendar/ClubAffairsReminder.cs
+++ b/src/HuaJiBot.NET.Plugin.Calendar/ClubAffairsReminder.cs
@@ -157,7 +157,13 @@ internal bool SendWeeklySummary(DateTimeOffset now)
continue;
_ = Service.TrySendGroupMessageAsync(
group.GroupId,
- BuildWeeklySummaryMessage(now, weekEnd, groupEvents)
+ new Post(
+ $"📢 **社团事务一周预告**({now:MM月dd日}~{weekEnd:MM月dd日})\n\n{Items(groupEvents, true)}",
+ BuildWeeklySummaryMessage(now, weekEnd, groupEvents)
+ )
+ {
+ Tags = ["日程", "周预告"],
+ }
);
Service.Log($"[社团事务] 已向群组 {group.GroupId} 发送每周汇总");
}
@@ -187,13 +193,33 @@ internal bool SendDailyReminder(DateTimeOffset now)
continue;
_ = Service.TrySendGroupMessageAsync(
group.GroupId,
- BuildDailyReminderMessage(tomorrow, groupEvents)
+ new Post(
+ $"⏰ **明天截止**({tomorrow:MM月dd日})\n\n{Items(groupEvents, false)}",
+ BuildDailyReminderMessage(tomorrow, groupEvents)
+ )
+ {
+ Tags = ["日程", "临期"],
+ }
);
Service.Log($"[社团事务] 已向群组 {group.GroupId} 发送每日提醒");
}
return true;
}
+ private string Items(
+ List<(CalendarExtensions.Period period, CalendarEvent e)> events,
+ bool withDate
+ ) =>
+ string.Join(
+ "\n",
+ from x in events
+ let responsible = ExtractResponsible(x.e)
+ select "- "
+ + (withDate ? $"{x.period.StartTime:MM月dd日} " : "")
+ + $"**{Post.Escape(x.e.Summary ?? "未命名事务")}**"
+ + (string.IsNullOrEmpty(responsible) ? "" : $"(负责人:{Post.Escape(responsible)})")
+ );
+
private string BuildWeeklySummaryMessage(
DateTimeOffset weekStart,
DateTimeOffset weekEnd,
diff --git a/src/HuaJiBot.NET.Plugin.Calendar/ReminderTask.cs b/src/HuaJiBot.NET.Plugin.Calendar/ReminderTask.cs
index b3ad93a..694c60c 100644
--- a/src/HuaJiBot.NET.Plugin.Calendar/ReminderTask.cs
+++ b/src/HuaJiBot.NET.Plugin.Calendar/ReminderTask.cs
@@ -43,10 +43,10 @@ public void Start()
private DateTimeOffset _scheduledTimeEnd = Utils.NetworkTime.Now; //截止到该时间点的日程已经在Task队列中列入计划了
- private void SendToMatchedGroups(CalendarEvent e, string text)
+ private void SendToMatchedGroups(CalendarEvent e, Post post)
{
foreach (var group in Config.ReminderGroups.Where(g => g.Matches(e)))
- _ = Service.TrySendGroupMessageAsync(group.GroupId, text);
+ _ = Service.TrySendGroupMessageAsync(group.GroupId, post);
}
[MethodImpl(MethodImplOptions.Synchronized)] //防止多线程同时更新时间节点
@@ -74,12 +74,18 @@ private void InvokeCheck()
ScheduleReminder(
period.StartTime.AddMinutes(-RemindBeforeStartMinutes) - now,
e,
- $"""
- 日程提醒({period.StartTime:MM-dd HH:mm}):
- {e.Summary} {e.Location}
- {e.Description}
- 将于 {RemindBeforeStartMinutes} 分钟后开始
- """
+ StartPost(
+ period.StartTime,
+ e.End is null ? null : period.EndTime,
+ e,
+ RemindBeforeStartMinutes,
+ $"""
+ 日程提醒({period.StartTime:MM-dd HH:mm}):
+ {e.Summary} {e.Location}
+ {e.Description}
+ 将于 {RemindBeforeStartMinutes} 分钟后开始
+ """
+ )
);
}
@@ -92,11 +98,15 @@ private void InvokeCheck()
ScheduleReminder(
period.EndTime.AddMinutes(-RemindBeforeEndMinutes) - now,
e,
- $"""
- 日程提醒({period.EndTime:MM-dd HH:mm}):
- {e.Summary} {e.Location}
- 预计于 {RemindBeforeEndMinutes} 分钟后结束
- """
+ EndPost(
+ e,
+ RemindBeforeEndMinutes,
+ $"""
+ 日程提醒({period.EndTime:MM-dd HH:mm}):
+ {e.Summary} {e.Location}
+ 预计于 {RemindBeforeEndMinutes} 分钟后结束
+ """
+ )
);
}
}
@@ -106,7 +116,37 @@ private void InvokeCheck()
}
}
- private void ScheduleReminder(TimeSpan waiting, CalendarEvent e, string text)
+ internal static Post StartPost(
+ DateTimeOffset start,
+ DateTimeOffset? end,
+ CalendarEvent e,
+ int minutes,
+ string fallback
+ )
+ {
+ var when = end is { } x ? $"🕘 {start:MM-dd HH:mm}–{x:HH:mm}" : $"🕘 {start:MM-dd HH:mm}";
+ if (!string.IsNullOrWhiteSpace(e.Location))
+ when += $" · 📍 {Post.Escape(e.Location)}";
+ var markdown = $"⏰ **{Post.Escape(e.Summary ?? "")}** {minutes} 分钟后开始\n{when}";
+ if (!string.IsNullOrWhiteSpace(e.Description))
+ markdown +=
+ "\n\n"
+ + string.Join(
+ "\n",
+ from line in e.Description.ReplaceLineEndings("\n").Trim().Split('\n')
+ select "> " + Post.Escape(line)
+ );
+ return new(markdown, fallback) { Tags = ["日程"] };
+ }
+
+ internal static Post EndPost(CalendarEvent e, int minutes, string fallback) =>
+ new($"🔚 **{Post.Escape(e.Summary ?? "")}** 预计 {minutes} 分钟后结束", fallback)
+ {
+ Tags = ["日程"],
+ Silent = true,
+ };
+
+ private void ScheduleReminder(TimeSpan waiting, CalendarEvent e, Post post)
{
if (waiting < TimeSpan.Zero)
return;
@@ -114,7 +154,7 @@ private void ScheduleReminder(TimeSpan waiting, CalendarEvent e, string text)
.ContinueWith(_ =>
{
Service.Log($"[日程] 发送提醒:{e.Summary}");
- SendToMatchedGroups(e, text);
+ SendToMatchedGroups(e, post);
});
Service.Log($"[日程] 计划发送提醒:{e.Summary} ({waiting.TotalMinutes:F1}分钟后发送)");
}
diff --git a/src/HuaJiBot.NET.Plugin.MessageBridge/HuaJiBot.NET.Plugin.MessageBridge.csproj b/src/HuaJiBot.NET.Plugin.MessageBridge/HuaJiBot.NET.Plugin.MessageBridge.csproj
index de057c7..826340b 100644
--- a/src/HuaJiBot.NET.Plugin.MessageBridge/HuaJiBot.NET.Plugin.MessageBridge.csproj
+++ b/src/HuaJiBot.NET.Plugin.MessageBridge/HuaJiBot.NET.Plugin.MessageBridge.csproj
@@ -11,4 +11,8 @@
+
+
+
+
diff --git a/src/HuaJiBot.NET.Plugin.MessageBridge/MinecraftPosts.cs b/src/HuaJiBot.NET.Plugin.MessageBridge/MinecraftPosts.cs
new file mode 100644
index 0000000..9d96ddd
--- /dev/null
+++ b/src/HuaJiBot.NET.Plugin.MessageBridge/MinecraftPosts.cs
@@ -0,0 +1,28 @@
+using HuaJiBot.NET.Bot;
+
+namespace HuaJiBot.NET.Plugin.MessageBridge;
+
+internal static class MinecraftPosts
+{
+ internal static Post Chat(string player, string message, string fallback) =>
+ new($"**{Post.Escape(player)}**: {Post.Escape(message)}", fallback);
+
+ internal static Post Join(string player, string fallback) =>
+ new($"➕ {Post.Escape(player)} 加入了服务器", fallback) { Silent = true };
+
+ internal static Post Quit(string player, string fallback) =>
+ new($"➖ {Post.Escape(player)} 离开了服务器", fallback) { Silent = true };
+
+ internal static Post Death(string message, string fallback) =>
+ new($"💀 {Post.Escape(message)}", fallback) { Silent = true };
+
+ internal static Post Advancement(string player, string name, string? description, string fallback) =>
+ new(
+ $"🏆 **{Post.Escape(player)}** 完成了进度 **{Post.Escape(name)}**"
+ + (string.IsNullOrWhiteSpace(description) ? "" : $"\n*{Post.Escape(description)}*"),
+ fallback
+ )
+ {
+ Silent = true,
+ };
+}
diff --git a/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs b/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs
index 6b2edbc..e114974 100644
--- a/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs
+++ b/src/HuaJiBot.NET.Plugin.MessageBridge/PluginMain.cs
@@ -213,21 +213,21 @@ PluginConfig.ClientInfo clientInfo
_ = SendGroupMessageAsync(
clientInfo,
ClientEventType.Chat,
- $"[{senderName}] <{name}> {msg}"
+ MinecraftPosts.Chat(name, msg, $"[{senderName}] <{name}> {msg}")
);
break;
case PlayerJoinPacket { Data.PlayerName: var name }:
_onlinePlayers[name] = await SendGroupMessageAsync(
clientInfo,
ClientEventType.JoinLeft,
- $"[{senderName}] {name} 加入了服务器"
+ MinecraftPosts.Join(name, $"[{senderName}] {name} 加入了服务器")
);
break;
case PlayerQuitPacket { Data.PlayerName: var name }:
_ = SendGroupMessageAsync(
clientInfo,
ClientEventType.JoinLeft,
- $"[{senderName}] {name} 离开了服务器"
+ MinecraftPosts.Quit(name, $"[{senderName}] {name} 离开了服务器")
);
if (_onlinePlayers.TryRemove(name, out var allMsg))
{
@@ -242,7 +242,7 @@ PluginConfig.ClientInfo clientInfo
_ = SendGroupMessageAsync(
clientInfo,
ClientEventType.PlayerDeath,
- $"[{senderName}] {msg}"
+ MinecraftPosts.Death(msg, $"[{senderName}] {msg}")
);
break;
case PlayerAchievementPacket
@@ -258,7 +258,12 @@ PluginConfig.ClientInfo clientInfo
_ = SendGroupMessageAsync(
clientInfo,
ClientEventType.PlayerAchievement,
- $"[{senderName}] {name} 完成了进度 {achievementName} ({string.Join(",", criteria)}){Environment.NewLine}{description}"
+ MinecraftPosts.Advancement(
+ name,
+ achievementName,
+ description,
+ $"[{senderName}] {name} 完成了进度 {achievementName} ({string.Join(",", criteria)}){Environment.NewLine}{description}"
+ )
);
break;
case GetPlayerListRequestPacket: //do not reply
@@ -279,7 +284,7 @@ PluginConfig.ClientInfo clientInfo
private async Task<(string groupId, string[] msgId)[]> SendGroupMessageAsync(
PluginConfig.ClientInfo clientInfo,
ClientEventType eventType,
- string message
+ Post message
)
{
List<(string, string[])> msgIds = [];
diff --git a/src/HuaJiBot.NET.Plugin.RepairTeam/PluginMain.cs b/src/HuaJiBot.NET.Plugin.RepairTeam/PluginMain.cs
index 688a6aa..af0bcfa 100644
--- a/src/HuaJiBot.NET.Plugin.RepairTeam/PluginMain.cs
+++ b/src/HuaJiBot.NET.Plugin.RepairTeam/PluginMain.cs
@@ -1,4 +1,5 @@
using System.Text;
+using HuaJiBot.NET.Bot;
using HuaJiBot.NET.Commands;
using HuaJiBot.NET.Events;
using HuaJiBot.NET.Interfaces;
@@ -110,6 +111,48 @@ internal static string FormatEvent(LogEventEntity e)
return s.ToString().TrimEnd();
}
+ private static string? StateTag(string? action) =>
+ action switch
+ {
+ "create" => "新报修",
+ "accept" => "已接单",
+ "cancel" => "已取消",
+ "drop" => "已放弃",
+ "commit" or "alterCommit" => "待审核",
+ "reject" => "已退回",
+ "close" => "已结单",
+ "update" => "已更新",
+ _ => null,
+ };
+
+ internal static Post EventPost(LogEventEntity e)
+ {
+ var lines = new List { $"🛠 **#{e.EventId} {Post.Escape(ActionText(e.Action))}**" };
+ var device = string.Join(
+ " · ",
+ new[] { e.Model, e.Problem }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => Post.Escape(x!))
+ );
+ if (device.Length > 0)
+ lines.Add(device);
+ if (!string.IsNullOrWhiteSpace(e.Description))
+ lines.Add(
+ "\n"
+ + string.Join(
+ "\n",
+ from line in e.Description.ReplaceLineEndings("\n").Trim().Split('\n')
+ select "> " + Post.Escape(line)
+ )
+ );
+ var time = e.GmtCreate.ToOffset(NetworkTime.LocalTimeZoneOffset).ToString("MM-dd HH:mm");
+ var member = string.IsNullOrWhiteSpace(e.MemberAlias) ? e.MemberId : e.MemberAlias;
+ lines.Add("\n" + (string.IsNullOrWhiteSpace(member) ? time : $"{Post.Escape(member)} · {time}"));
+ return new(string.Join("\n", lines), FormatEvent(e))
+ {
+ Tags = StateTag(e.Action) is { } tag ? ["维修", tag] : ["维修"],
+ Links = [new("去处理", TicketDigest.PortalUrl)],
+ };
+ }
+
private void OnMessageReceived(object? sender, string msg)
{
Service.Log(msg);
@@ -127,9 +170,9 @@ private void OnMessageReceived(object? sender, string msg)
Service.LogError("[RepairTeam] 无法解析维修事件", ex);
return;
}
- var str = FormatEvent(e);
+ var post = EventPost(e);
foreach (var group in Config.PushInfoGroup)
- _ = Service.TrySendGroupMessageAsync(group, str);
+ _ = Service.TrySendGroupMessageAsync(group, post);
}
}
diff --git a/src/HuaJiBot.NET.Plugin.RepairTeam/TicketDigest.cs b/src/HuaJiBot.NET.Plugin.RepairTeam/TicketDigest.cs
index 2f418dd..2acb111 100644
--- a/src/HuaJiBot.NET.Plugin.RepairTeam/TicketDigest.cs
+++ b/src/HuaJiBot.NET.Plugin.RepairTeam/TicketDigest.cs
@@ -1,4 +1,5 @@
using System.Text;
+using HuaJiBot.NET.Bot;
namespace HuaJiBot.NET.Plugin.RepairTeam;
@@ -25,6 +26,32 @@ private static double ThresholdDays(string status, PluginConfig config) =>
public static List Stalled(IEnumerable tickets, DateTimeOffset now, PluginConfig config) =>
tickets.Where(t => (now - t.LastActivity).TotalDays > ThresholdDays(t.Status, config)).ToList();
+ public static Post ToPost(IReadOnlyCollection tickets, DateTimeOffset now, string fallback)
+ {
+ var sb = new StringBuilder($"🛠 **{tickets.Count} 张工单卡住了**");
+ foreach (var (status, stageTitle) in Stages)
+ {
+ var stage = tickets.Where(t => t.Status == status).OrderBy(t => t.LastActivity).ToList();
+ if (stage.Count == 0)
+ continue;
+ sb.Append($"\n\n**{stageTitle}**");
+ foreach (var t in stage)
+ sb.Append($"\n• #{t.Id} ").AppendJoin(" · ", Fields(t, now).Select(Post.Escape));
+ }
+ return new(sb.ToString(), fallback)
+ {
+ Tags = ["维修", "提醒"],
+ Links = [new("去处理", PortalUrl)],
+ };
+ }
+
+ private static IEnumerable Fields(Ticket t, DateTimeOffset now)
+ {
+ var days = (int)(now - t.LastActivity).TotalDays;
+ var fields = new[] { t.Model, t.Problem, t.Member, days < 1 ? "不到 1 天" : $"已 {days} 天" };
+ return fields.Where(f => !string.IsNullOrWhiteSpace(f))!;
+ }
+
public static string Format(string title, IReadOnlyCollection tickets, DateTimeOffset now)
{
var sb = new StringBuilder(title).Append('\n');
@@ -35,11 +62,7 @@ public static string Format(string title, IReadOnlyCollection tickets, D
continue;
sb.Append(stageTitle).Append(":\n");
foreach (var t in stage)
- {
- var days = (int)(now - t.LastActivity).TotalDays;
- var fields = new[] { t.Model, t.Problem, t.Member, days < 1 ? "不到 1 天" : $"已 {days} 天" };
- sb.Append($" #{t.Id} ").AppendJoin(" · ", fields.Where(f => !string.IsNullOrWhiteSpace(f))).Append('\n');
- }
+ sb.Append($" #{t.Id} ").AppendJoin(" · ", Fields(t, now)).Append('\n');
}
return sb.Append($"去处理:{PortalUrl}").ToString();
}
diff --git a/src/HuaJiBot.NET.Plugin.RepairTeam/TicketReminder.cs b/src/HuaJiBot.NET.Plugin.RepairTeam/TicketReminder.cs
index a7e5519..f27a0b4 100644
--- a/src/HuaJiBot.NET.Plugin.RepairTeam/TicketReminder.cs
+++ b/src/HuaJiBot.NET.Plugin.RepairTeam/TicketReminder.cs
@@ -69,9 +69,13 @@ public async Task SendDigestAsync(DateTimeOffset now)
var stalled = TicketDigest.Stalled(tickets, now, _config);
if (stalled.Count == 0)
return true;
- var text = TicketDigest.Format($"【维修工单提醒】{stalled.Count} 张工单卡住了", stalled, now);
+ var post = TicketDigest.ToPost(
+ stalled,
+ now,
+ TicketDigest.Format($"【维修工单提醒】{stalled.Count} 张工单卡住了", stalled, now)
+ );
foreach (var group in _config.RemindGroups)
- await _service.TrySendGroupMessageAsync(group, text);
+ await _service.TrySendGroupMessageAsync(group, post);
return true;
}
diff --git a/src/HuaJiBot.NET.UnitTest/ClubAffairsReminderTest.cs b/src/HuaJiBot.NET.UnitTest/ClubAffairsReminderTest.cs
index 059f483..245f4f5 100644
--- a/src/HuaJiBot.NET.UnitTest/ClubAffairsReminderTest.cs
+++ b/src/HuaJiBot.NET.UnitTest/ClubAffairsReminderTest.cs
@@ -42,6 +42,21 @@ private static string SentTo(RecordingAdapter adapter, string group) =>
adapter.Sends.Where(x => x.Target == group).SelectMany(x => x.Messages).OfType().Select(x => x.Text)
);
+ [Test]
+ public void WeeklySummary_PostsTaggedListOnTelegram()
+ {
+ var (adapter, reminder) = Setup();
+
+ reminder.SendWeeklySummary(Now);
+
+ var post = adapter.Posts.First(x => x.Markdown.Contains("招新宣讲") && !x.Markdown.Contains("例会筹备"));
+ Assert.Multiple(() =>
+ {
+ Assert.That(post.Markdown, Is.EqualTo("📢 **社团事务一周预告**(09月21日~09月28日)\n\n- 09月22日 **招新宣讲**"));
+ Assert.That(post.Tags, Is.EqualTo(new[] { "日程", "周预告" }));
+ });
+ }
+
[Test]
public void WeeklySummary_SendsEachGroupOnlyItsOwnEvents()
{
diff --git a/src/HuaJiBot.NET.UnitTest/Doubles.cs b/src/HuaJiBot.NET.UnitTest/Doubles.cs
index bea5c5a..4ced28a 100644
--- a/src/HuaJiBot.NET.UnitTest/Doubles.cs
+++ b/src/HuaJiBot.NET.UnitTest/Doubles.cs
@@ -4,16 +4,22 @@ namespace HuaJiBot.NET.UnitTest;
internal sealed class RecordingAdapter : TestAdapter
{
+ /// What a non-Telegram adapter sends: posts replaced by their fallback.
public readonly List<(string Target, SendingMessageBase[] Messages)> Sends = [];
+ public readonly List Posts = [];
- public override Task SendGroupMessageAsync(
+ public override async Task SendGroupMessageAsync(
string? robotId,
string targetGroup,
params SendingMessageBase[] messages
)
{
- Sends.Add((targetGroup, messages));
- return Task.FromResult(["42"]);
+ lock (Posts)
+ Posts.AddRange(messages.OfType());
+ var sent = await messages.ExpandPostsAsync();
+ lock (Sends)
+ Sends.Add((targetGroup, sent));
+ return ["42"];
}
}
diff --git a/src/HuaJiBot.NET.UnitTest/PluginPostTest.cs b/src/HuaJiBot.NET.UnitTest/PluginPostTest.cs
new file mode 100644
index 0000000..20215ee
--- /dev/null
+++ b/src/HuaJiBot.NET.UnitTest/PluginPostTest.cs
@@ -0,0 +1,136 @@
+using HuaJiBot.NET.Bot;
+using HuaJiBot.NET.Plugin.Calendar;
+using HuaJiBot.NET.Plugin.MessageBridge;
+using HuaJiBot.NET.Plugin.RepairTeam;
+using Ical.Net.CalendarComponents;
+using Newtonsoft.Json;
+using RepairPlugin = HuaJiBot.NET.Plugin.RepairTeam.PluginMain;
+
+namespace HuaJiBot.NET.UnitTest;
+
+internal class PluginPostTest
+{
+ private static readonly DateTimeOffset Start = new(2026, 9, 25, 20, 30, 0, TimeSpan.FromHours(8));
+
+ [Test]
+ public async Task CalendarStart_ShowsTitleTimePlaceAndQuotedDescription()
+ {
+ var e = new CalendarEvent { Summary = "NWDC_x", Location = "实验室", Description = "带上电脑\n别迟到" };
+
+ var post = ReminderTask.StartPost(Start, Start.AddHours(2), e, 60, "old");
+ var fallback = await post.Fallback();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ post.Markdown,
+ Is.EqualTo("⏰ **NWDC\\_x** 60 分钟后开始\n🕘 09-25 20:30–22:30 · 📍 实验室\n\n> 带上电脑\n> 别迟到")
+ );
+ Assert.That(post.Tags, Is.EqualTo(new[] { "日程" }));
+ Assert.That(post.Silent, Is.False);
+ Assert.That(fallback, Is.EqualTo(new SendingMessageBase[] { new TextMessage("old") }));
+ });
+ }
+
+ [Test]
+ public void CalendarEnd_IsSilent()
+ {
+ var post = ReminderTask.EndPost(new CalendarEvent { Summary = "例会" }, 5, "old");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(post.Markdown, Is.EqualTo("🔚 **例会** 预计 5 分钟后结束"));
+ Assert.That(post.Silent, Is.True);
+ });
+ }
+
+ [Test]
+ public async Task RepairEvent_ShowsTicketAndLinksPortal()
+ {
+ var e = JsonConvert.DeserializeObject(
+ """
+ {"event_id":900001,"action":"create","member_id":"","member_alias":"张三",
+ "gmt_create":"2026-09-21T03:45:53Z","model":"ThinkPad","problem":"无法开机","description":"进水了"}
+ """
+ )!;
+
+ var post = RepairPlugin.EventPost(e);
+ var fallback = await post.Fallback();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ post.Markdown,
+ Is.EqualTo("🛠 **#900001 新报修**\nThinkPad · 无法开机\n\n> 进水了\n\n张三 · 09-21 11:45")
+ );
+ Assert.That(post.Tags, Is.EqualTo(new[] { "维修", "新报修" }));
+ Assert.That(post.Links, Is.EqualTo(new[] { new LinkMessage("去处理", TicketDigest.PortalUrl) }));
+ Assert.That(fallback, Is.EqualTo(new SendingMessageBase[] { new TextMessage(RepairPlugin.FormatEvent(e)) }));
+ });
+ }
+
+ [Test]
+ public void TicketDigest_ListsStagesAsBulletLists()
+ {
+ var now = new DateTimeOffset(2026, 9, 23, 12, 0, 0, TimeSpan.FromHours(8));
+ Ticket[] tickets =
+ [
+ new(12, "open", "MacBook", "屏幕闪", null, now.AddDays(-5), now.AddDays(-3)),
+ new(13, "committed", null, "蓝屏", "李四", now.AddDays(-5), now.AddHours(-30)),
+ ];
+
+ var post = TicketDigest.ToPost(tickets, now, "old");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ post.Markdown,
+ Is.EqualTo(
+ "🛠 **2 张工单卡住了**\n\n**待接单**\n• #12 MacBook · 屏幕闪 · 已 3 天\n\n**待审核**\n• #13 蓝屏 · 李四 · 已 1 天"
+ )
+ );
+ Assert.That(post.Tags, Is.EqualTo(new[] { "维修", "提醒" }));
+ Assert.That(post.Links.Single().Url, Is.EqualTo(TicketDigest.PortalUrl));
+ });
+ }
+
+ [Test]
+ public void MinecraftChat_BoldsPlayerAndIsNotSilent()
+ {
+ var post = MinecraftPosts.Chat("Steve_1", "hi *all*", "old");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(post.Markdown, Is.EqualTo("**Steve\\_1**: hi \\*all\\*"));
+ Assert.That(post.Silent, Is.False);
+ Assert.That(post.Tags, Is.Empty);
+ });
+ }
+
+ [TestCase("join", "➕ Steve 加入了服务器")]
+ [TestCase("quit", "➖ Steve 离开了服务器")]
+ public void MinecraftJoinQuit_AreSilent(string kind, string markdown)
+ {
+ var post = kind == "join" ? MinecraftPosts.Join("Steve", "old") : MinecraftPosts.Quit("Steve", "old");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(post.Markdown, Is.EqualTo(markdown));
+ Assert.That(post.Silent, Is.True);
+ });
+ }
+
+ [Test]
+ public void MinecraftDeathAndAdvancement_AreSilent()
+ {
+ var death = MinecraftPosts.Death("Steve was slain", "old");
+ var advancement = MinecraftPosts.Advancement("Steve", "Stone Age", "Mine stone", "old");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(death.Markdown, Is.EqualTo("💀 Steve was slain"));
+ Assert.That(advancement.Markdown, Is.EqualTo("🏆 **Steve** 完成了进度 **Stone Age**\n*Mine stone*"));
+ Assert.That(death.Silent && advancement.Silent, Is.True);
+ });
+ }
+}
diff --git a/src/HuaJiBot.NET/Bot/BotService.cs b/src/HuaJiBot.NET/Bot/BotService.cs
index 1dfca29..86aa43a 100644
--- a/src/HuaJiBot.NET/Bot/BotService.cs
+++ b/src/HuaJiBot.NET/Bot/BotService.cs
@@ -31,6 +31,9 @@ public sealed record LinkMessage(string Text, string Url) : SendingMessageBase;
public sealed record Post(string Markdown, Func> Fallback)
: SendingMessageBase
{
+ public Post(string markdown, params SendingMessageBase[] fallback)
+ : this(markdown, () => Task.FromResult(fallback)) { }
+
public Func>? Image { get; init; }
public string[] Tags { get; init; } = [];
public LinkMessage[] Links { get; init; } = [];