From 8165fd2a0e5dcfbb09aa3eb9ca183369f5b37572 Mon Sep 17 00:00:00 2001 From: Yuna Celisse <29951517@qq.com> Date: Fri, 25 Sep 2026 07:12:54 +0800 Subject: [PATCH 1/2] feat(push-panel): add a local page for editing push rules The page binds to loopback and writes the live plugin config, so the next push uses the saved routes. Tokens and model keys stay out of the page. --- HuaJiBot.NET.slnx | 1 + .../EntryPoint.cs | 3 + .../HuaJiBot.NET.Plugin.PushPanel.csproj | 23 + .../PanelServer.cs | 186 +++++++ .../PluginMain.cs | 66 +++ .../PushRules.cs | 468 ++++++++++++++++ src/HuaJiBot.NET.Plugin.PushPanel/panel.html | 507 ++++++++++++++++++ .../HuaJiBot.NET.UnitTest.csproj | 1 + src/HuaJiBot.NET.UnitTest/PushPanelTest.cs | 211 ++++++++ src/HuaJiBot.NET/Config/ConfigWrapper.cs | 21 +- 10 files changed, 1486 insertions(+), 1 deletion(-) create mode 100644 src/HuaJiBot.NET.Plugin.PushPanel/EntryPoint.cs create mode 100644 src/HuaJiBot.NET.Plugin.PushPanel/HuaJiBot.NET.Plugin.PushPanel.csproj create mode 100644 src/HuaJiBot.NET.Plugin.PushPanel/PanelServer.cs create mode 100644 src/HuaJiBot.NET.Plugin.PushPanel/PluginMain.cs create mode 100644 src/HuaJiBot.NET.Plugin.PushPanel/PushRules.cs create mode 100644 src/HuaJiBot.NET.Plugin.PushPanel/panel.html create mode 100644 src/HuaJiBot.NET.UnitTest/PushPanelTest.cs diff --git a/HuaJiBot.NET.slnx b/HuaJiBot.NET.slnx index 6f9e4f8..1f45f95 100644 --- a/HuaJiBot.NET.slnx +++ b/HuaJiBot.NET.slnx @@ -19,6 +19,7 @@ + diff --git a/src/HuaJiBot.NET.Plugin.PushPanel/EntryPoint.cs b/src/HuaJiBot.NET.Plugin.PushPanel/EntryPoint.cs new file mode 100644 index 0000000..dcfda85 --- /dev/null +++ b/src/HuaJiBot.NET.Plugin.PushPanel/EntryPoint.cs @@ -0,0 +1,3 @@ +using HuaJiBot.NET.Plugin.PushPanel; + +[assembly: HuaJiBot.NET.PluginEntryPoint("PushPanel", "推送规则面板")] diff --git a/src/HuaJiBot.NET.Plugin.PushPanel/HuaJiBot.NET.Plugin.PushPanel.csproj b/src/HuaJiBot.NET.Plugin.PushPanel/HuaJiBot.NET.Plugin.PushPanel.csproj new file mode 100644 index 0000000..16284a8 --- /dev/null +++ b/src/HuaJiBot.NET.Plugin.PushPanel/HuaJiBot.NET.Plugin.PushPanel.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + diff --git a/src/HuaJiBot.NET.Plugin.PushPanel/PanelServer.cs b/src/HuaJiBot.NET.Plugin.PushPanel/PanelServer.cs new file mode 100644 index 0000000..4d64293 --- /dev/null +++ b/src/HuaJiBot.NET.Plugin.PushPanel/PanelServer.cs @@ -0,0 +1,186 @@ +using System.Net; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using HuaJiBot.NET.Config; +using Newtonsoft.Json; + +namespace HuaJiBot.NET.Plugin.PushPanel; + +internal sealed class PanelServer : IDisposable +{ + private const int MaxBodyChars = 256 * 1024; + private readonly HttpListener _listener = new(); + private readonly string _token; + private readonly ConfigWrapper _config; + private readonly string _page; + private readonly CancellationTokenSource _cts = new(); + + public PanelServer(string host, int port, string token, ConfigWrapper config) + { + _token = token; + _config = config; + _page = ReadPage(); + _listener.Prefixes.Add($"http://{host}:{port}/"); + } + + public void Start() + { + _listener.Start(); + _ = Task.Run(ListenAsync); + } + + private async Task ListenAsync() + { + while (!_cts.IsCancellationRequested) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync(); + } + catch (ObjectDisposedException) when (_cts.IsCancellationRequested) + { + return; + } + catch (HttpListenerException) when (_cts.IsCancellationRequested) + { + return; + } + _ = Task.Run(() => HandleAsync(context)); + } + } + + private async Task HandleAsync(HttpListenerContext context) + { + try + { + var request = context.Request; + var path = request.Url?.AbsolutePath ?? "/"; + if (request.HttpMethod == "GET" && path is "/" or "/index.html") + { + await WriteText(context.Response, 200, "text/html; charset=utf-8", _page); + return; + } + if (path != "/api/rules") + { + await WriteJson(context.Response, 404, new { error = "没有这个地址" }); + return; + } + if (!Authorized(request)) + { + await WriteJson(context.Response, 401, new { error = "口令不对" }); + return; + } + if (request.HttpMethod == "GET") + { + await WriteJson(context.Response, 200, PushRules.Read(_config)); + return; + } + if (request.HttpMethod == "PUT") + { + var body = await ReadBody(request); + if (body.Length > MaxBodyChars) + { + await WriteJson(context.Response, 413, new { error = "请求过大" }); + return; + } + PushRulesDocument? document; + try + { + document = JsonConvert.DeserializeObject(body, PushRules.Json); + } + catch (JsonException) + { + await WriteJson(context.Response, 400, new { error = "请求不是规则 JSON" }); + return; + } + if (document is null) + { + await WriteJson(context.Response, 400, new { error = "请求是空的" }); + return; + } + var error = PushRules.Apply(_config, document); + if (error is not null) + { + await WriteJson(context.Response, 400, new { error }); + return; + } + await WriteJson(context.Response, 200, PushRules.Read(_config)); + return; + } + await WriteJson(context.Response, 405, new { error = "只接受读取和保存" }); + } + catch (Exception) + { + try + { + await WriteJson(context.Response, 500, new { error = "面板内部出错" }); + } + catch (Exception) + { + context.Response.Abort(); + } + } + } + + private bool Authorized(HttpListenerRequest request) + { + var header = request.Headers["Authorization"]; + const string prefix = "Bearer "; + if (header is null || !header.StartsWith(prefix, StringComparison.Ordinal)) + return false; + var presented = Encoding.UTF8.GetBytes(header[prefix.Length..]); + var expected = Encoding.UTF8.GetBytes(_token); + return presented.Length == expected.Length + && CryptographicOperations.FixedTimeEquals(presented, expected); + } + + private static async Task ReadBody(HttpListenerRequest request) + { + if (request.ContentLength64 > MaxBodyChars) + return new string(' ', MaxBodyChars + 1); + using var reader = new StreamReader(request.InputStream, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } + + private static async Task WriteJson(HttpListenerResponse response, int status, object body) + { + var json = JsonConvert.SerializeObject(body, PushRules.Json); + await WriteText(response, status, "application/json; charset=utf-8", json); + } + + private static async Task WriteText( + HttpListenerResponse response, + int status, + string contentType, + string text + ) + { + var bytes = Encoding.UTF8.GetBytes(text); + response.StatusCode = status; + response.ContentType = contentType; + response.Headers["Cache-Control"] = "no-store"; + response.Headers["X-Content-Type-Options"] = "nosniff"; + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes); + response.Close(); + } + + private static string ReadPage() + { + var name = typeof(PanelServer).Assembly.GetManifestResourceNames().Single(resource => + resource.EndsWith("panel.html", StringComparison.Ordinal) + ); + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(name)!; + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Close(); + _cts.Dispose(); + } +} diff --git a/src/HuaJiBot.NET.Plugin.PushPanel/PluginMain.cs b/src/HuaJiBot.NET.Plugin.PushPanel/PluginMain.cs new file mode 100644 index 0000000..39621fe --- /dev/null +++ b/src/HuaJiBot.NET.Plugin.PushPanel/PluginMain.cs @@ -0,0 +1,66 @@ +using System.Net; +using HuaJiBot.NET.Config; + +namespace HuaJiBot.NET.Plugin.PushPanel; + +public class PluginConfig : ConfigBase +{ + /// 面板口令。为空时不监听。 + public string Token { get; set; } = ""; + + /// 监听地址。默认只绑本机。 + public string Host { get; set; } = "127.0.0.1"; + + public int Port { get; set; } = 8787; + + /// 允许 Host 不是本机地址。口令仍然必须带上。 + public bool AllowRemote { get; set; } +} + +public partial class PluginMain : PluginBase, IPluginWithConfig +{ + public PluginConfig Config { get; } = new(); + private PanelServer? _server; + + protected override void Initialize() + { + if (string.IsNullOrWhiteSpace(Config.Token)) + { + Service.Log("[PushPanel] 未配置 Token,面板不监听"); + return; + } + if (Config.Port is < 1 or > 65535) + { + Service.Warn("[PushPanel] Port 不在 1–65535,面板不监听"); + return; + } + if (!Config.AllowRemote && !IsLoopback(Config.Host)) + { + Service.Warn("[PushPanel] Host 不是本机地址。要对外监听请把 AllowRemote 设为 true"); + return; + } + try + { + _server = new PanelServer(Config.Host, Config.Port, Config.Token, Service.Config); + _server.Start(); + Service.Log($"[PushPanel] http://{Config.Host}:{Config.Port}/"); + } + catch (Exception ex) + { + Service.LogError("[PushPanel] 启动失败", ex); + _server = null; + } + } + + protected override void Unload() + { + _server?.Dispose(); + _server = null; + } + + internal static bool IsLoopback(string host) => + host.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || host == "127.0.0.1" + || host == "::1" + || (IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address)); +} diff --git a/src/HuaJiBot.NET.Plugin.PushPanel/PushRules.cs b/src/HuaJiBot.NET.Plugin.PushPanel/PushRules.cs new file mode 100644 index 0000000..3cafd6d --- /dev/null +++ b/src/HuaJiBot.NET.Plugin.PushPanel/PushRules.cs @@ -0,0 +1,468 @@ +using System.Globalization; +using HuaJiBot.NET.Config; +using HuaJiBot.NET.Plugin.Calendar; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using BridgeConfig = HuaJiBot.NET.Plugin.MessageBridge.PluginConfig; +using CalendarConfig = HuaJiBot.NET.Plugin.Calendar.PluginConfig; +using GithubConfig = HuaJiBot.NET.Plugin.GitHubBridge.PluginConfig; +using RepairConfig = HuaJiBot.NET.Plugin.RepairTeam.PluginConfig; +using SummaryConfig = HuaJiBot.NET.Plugin.DailySummary.Config.PluginConfig; + +namespace HuaJiBot.NET.Plugin.PushPanel; + +internal static class PushRules +{ + public const string Github = "GitHubBridge"; + public const string Calendar = "Calendar"; + public const string Repair = "RepairTeam"; + public const string Bridge = "MessageBridge"; + public const string Summary = "每日聊天总结"; + + internal static readonly JsonSerializerSettings Json = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Include, + }; + + public static PushRulesDocument Read(ConfigWrapper config) => + new() + { + Github = config.TryGetLive(Github, out var github) ? ReadGithub((GithubConfig)github) : null, + Calendar = config.TryGetLive(Calendar, out var calendar) + ? ReadCalendar((CalendarConfig)calendar) + : null, + Repair = config.TryGetLive(Repair, out var repair) ? ReadRepair((RepairConfig)repair) : null, + Bridge = config.TryGetLive(Bridge, out var bridge) + ? ReadBridge((BridgeConfig)bridge) + : null, + Summary = config.TryGetLive(Summary, out var summary) + ? ReadSummary((SummaryConfig)summary) + : null, + }; + + /// null when the change was saved. + public static string? Apply(ConfigWrapper config, PushRulesDocument document) + { + var error = Validate(document); + if (error is not null) + return error; + return config.Change(() => + { + GithubConfig? github = null; + CalendarConfig? calendar = null; + RepairConfig? repair = null; + BridgeConfig? bridge = null; + SummaryConfig? summary = null; + if (document.Github is not null && !TryLive(config, Github, out github)) + return "GitHubBridge 未加载"; + if (document.Calendar is not null && !TryLive(config, Calendar, out calendar)) + return "Calendar 未加载"; + if (document.Repair is not null && !TryLive(config, Repair, out repair)) + return "RepairTeam 未加载"; + if (document.Bridge is { } requestedBridge) + { + if (!TryLive(config, Bridge, out bridge) || bridge is null) + return "MessageBridge 未加载"; + var bridgeError = CheckBridge(bridge, requestedBridge); + if (bridgeError is not null) + return bridgeError; + } + if (document.Summary is not null && !TryLive(config, Summary, out summary)) + return "每日聊天总结 未加载"; + + if (document.Github is { } githubRules && github is not null) + WriteGithub(github, githubRules); + if (document.Calendar is { } calendarRules && calendar is not null) + WriteCalendar(calendar, calendarRules); + if (document.Repair is { } repairRules && repair is not null) + WriteRepair(repair, repairRules); + if (document.Bridge is { } bridgeRules && bridge is not null) + WriteBridge(bridge, bridgeRules); + if (document.Summary is { } summaryRules && summary is not null) + WriteSummary(summary, summaryRules); + return null; + }); + } + + internal static string? Validate(PushRulesDocument document) + { + if (document.Github is { } github) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var (name, targets) in github.Groups) + { + if (!IsGroupName(name)) + return $"GitHub 分组名无效:{name}"; + if (!names.Add(name)) + return $"GitHub 分组重复:{name}"; + var targetError = CheckTargets(targets, $"GitHub 分组 {name}"); + if (targetError is not null) + return targetError; + } + var repos = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (repo, group) in github.Routes) + { + if (!IsRepo(repo)) + return $"仓库名无效:{repo}"; + if (!repos.Add(repo)) + return $"仓库重复:{repo}"; + if (!names.Contains(group)) + return $"仓库 {repo} 指向不存在的分组 {group}"; + } + } + if (document.Calendar is { } calendar) + { + var ids = new HashSet(StringComparer.Ordinal); + foreach (var group in calendar.Groups) + { + if (!IsTarget(group.GroupId)) + return $"日程群号无效:{group.GroupId}"; + if (!ids.Add(group.GroupId)) + return $"日程群号重复:{group.GroupId}"; + if (!Enum.TryParse(group.Mode, out _)) + return $"日程过滤模式无效:{group.Mode}"; + if (group.Keywords.Any(string.IsNullOrWhiteSpace)) + return "日程关键词不能为空"; + } + if (calendar.WeeklyHour is < 0 or > 23 || calendar.DailyHour is < 0 or > 23) + return "日程提醒小时必须在 0–23"; + if (calendar.WeeklyDay is < 0 or > 6) + return "每周汇总的星期必须在 0–6"; + } + if (document.Repair is { } repair) + { + if (repair.PushRawGroup.Length > 0 && !IsTarget(repair.PushRawGroup)) + return $"维修原始推送群号无效:{repair.PushRawGroup}"; + var info = CheckTargets(repair.PushInfoGroups, "维修格式化推送"); + if (info is not null) + return info; + var remind = CheckTargets(repair.RemindGroups, "维修每日摘要"); + if (remind is not null) + return remind; + if (repair.RemindHour is < 0 or > 23) + return "维修摘要小时必须在 0–23"; + if (repair.OpenDays < 0 || repair.AcceptedDays < 0 || repair.CommittedDays < 0) + return "维修卡住天数不能为负"; + if ( + !string.IsNullOrWhiteSpace(repair.RemindSince) + && !DateTimeOffset.TryParse( + repair.RemindSince, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out _ + ) + ) + return "RemindSince 不是时间"; + } + if (document.Bridge is { } bridge) + { + foreach (var client in bridge.Clients) + { + var ids = new HashSet(StringComparer.Ordinal); + foreach (var group in client.Groups) + { + if (!IsTarget(group.GroupId)) + return $"消息桥群号无效:{group.GroupId}"; + if (!ids.Add(group.GroupId)) + return $"消息桥群号重复:{group.GroupId}"; + foreach (var ev in group.DisabledEvents) + { + if (!Enum.TryParse(ev, out _)) + return $"消息桥事件无效:{ev}"; + } + } + } + } + if (document.Summary is { } summary) + { + var groups = CheckTargets(summary.GroupIds, "每日总结"); + if (groups is not null) + return groups; + if (summary.Hour is < 0 or > 23 || summary.Minute is < 0 or > 59) + return "每日总结时刻无效"; + if (summary.MinMessageCount < 0) + return "最少消息数不能为负"; + } + return null; + } + + private static GithubRules ReadGithub(GithubConfig config) => + new() + { + Groups = config.BroadcastGroup.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToArray(), + StringComparer.Ordinal + ), + Routes = config.BroadcastMap.ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.Ordinal + ), + }; + + private static void WriteGithub(GithubConfig config, GithubRules rules) + { + config.BroadcastGroup.Clear(); + foreach (var (name, targets) in rules.Groups) + config.BroadcastGroup[name] = targets.ToArray(); + config.BroadcastMap.Clear(); + foreach (var (repo, group) in rules.Routes) + config.BroadcastMap[repo] = group; + } + + private static CalendarRules ReadCalendar(CalendarConfig config) => + new() + { + Groups = config + .ReminderGroups.Select(group => new CalendarGroupRule + { + GroupId = group.GroupId, + Mode = group.Mode.ToString(), + Keywords = group.Keywords.ToArray(), + }) + .ToArray(), + WeeklyEnabled = config.ClubAffairsReminder.EnableWeeklySummary, + DailyEnabled = config.ClubAffairsReminder.EnableDailyReminder, + WeeklyHour = config.ClubAffairsReminder.WeeklySummaryHour, + DailyHour = config.ClubAffairsReminder.DailyReminderHour, + WeeklyDay = (int)config.ClubAffairsReminder.WeeklySummaryDayOfWeek, + }; + + private static void WriteCalendar(CalendarConfig config, CalendarRules rules) + { + config.ReminderGroups = rules + .Groups.Select(group => new CalendarConfig.ReminderFilterConfig + { + GroupId = group.GroupId.Trim(), + Mode = Enum.Parse(group.Mode), + Keywords = group.Keywords.Select(keyword => keyword.Trim()).ToArray(), + }) + .ToArray(); + config.ClubAffairsReminder.EnableWeeklySummary = rules.WeeklyEnabled; + config.ClubAffairsReminder.EnableDailyReminder = rules.DailyEnabled; + config.ClubAffairsReminder.WeeklySummaryHour = rules.WeeklyHour; + config.ClubAffairsReminder.DailyReminderHour = rules.DailyHour; + config.ClubAffairsReminder.WeeklySummaryDayOfWeek = (DayOfWeek)rules.WeeklyDay; + } + + private static RepairRules ReadRepair(RepairConfig config) => + new() + { + PushRawGroup = config.PushRawGroup ?? "", + PushInfoGroups = config.PushInfoGroup.ToArray(), + RemindGroups = config.RemindGroups.ToArray(), + RemindHour = config.RemindHour, + RemindSince = config.RemindSince?.ToString("O"), + OpenDays = config.OpenDays, + AcceptedDays = config.AcceptedDays, + CommittedDays = config.CommittedDays, + }; + + private static void WriteRepair(RepairConfig config, RepairRules rules) + { + config.PushRawGroup = rules.PushRawGroup.Trim(); + config.PushInfoGroup = rules.PushInfoGroups.Select(id => id.Trim()).ToArray(); + config.RemindGroups = rules.RemindGroups.Select(id => id.Trim()).ToArray(); + config.RemindHour = rules.RemindHour; + config.RemindSince = string.IsNullOrWhiteSpace(rules.RemindSince) + ? null + : DateTimeOffset.Parse(rules.RemindSince, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + config.OpenDays = rules.OpenDays; + config.AcceptedDays = rules.AcceptedDays; + config.CommittedDays = rules.CommittedDays; + } + + private static BridgeRules ReadBridge(BridgeConfig config) => + new() + { + Clients = config + .Clients.Select(client => new BridgeClientRule + { + Address = client.Address, + Groups = client + .Groups.Select(group => new BridgeGroupRule + { + GroupId = group.GroupId, + Enabled = group.Enabled, + ForwardToClient = group.ForwardToClient, + ForwardFromClient = group.ForwardFromClient, + DisabledEvents = group + .ForwardFromClientDisabledEvent.Select(item => item.ToString()) + .Order(StringComparer.Ordinal) + .ToArray(), + }) + .ToArray(), + }) + .ToArray(), + }; + + private static bool TryLive(ConfigWrapper config, string key, out T? live) + where T : ConfigBase + { + if (config.TryGetLive(key, out var found) && found is T typed) + { + live = typed; + return true; + } + live = null; + return false; + } + + private static string? CheckBridge(BridgeConfig config, BridgeRules rules) + { + if (rules.Clients.Length != config.Clients.Length) + return "消息桥客户端数量不能在面板里增减"; + for (var i = 0; i < config.Clients.Length; i++) + { + if (!string.Equals(config.Clients[i].Address, rules.Clients[i].Address, StringComparison.Ordinal)) + return "消息桥地址不能在面板里修改"; + } + return null; + } + + private static void WriteBridge(BridgeConfig config, BridgeRules rules) + { + for (var i = 0; i < config.Clients.Length; i++) + { + config.Clients[i].Groups = rules + .Clients[i] + .Groups.Select(group => new BridgeConfig.GroupConfig + { + GroupId = group.GroupId.Trim(), + Enabled = group.Enabled, + ForwardToClient = group.ForwardToClient, + ForwardFromClient = group.ForwardFromClient, + ForwardFromClientDisabledEvent = group + .DisabledEvents.Select(ev => + Enum.Parse(ev) + ) + .ToHashSet(), + }) + .ToArray(); + } + } + + private static SummaryRules ReadSummary(SummaryConfig config) => + new() + { + GroupIds = config.GroupIds.ToArray(), + Hour = config.SummaryHour, + Minute = config.SummaryMinute, + MinMessageCount = config.MinMessageCount, + }; + + private static void WriteSummary(SummaryConfig config, SummaryRules rules) + { + config.GroupIds.Clear(); + config.GroupIds.AddRange(rules.GroupIds.Select(id => id.Trim())); + config.SummaryHour = rules.Hour; + config.SummaryMinute = rules.Minute; + config.MinMessageCount = rules.MinMessageCount; + } + + private static string? CheckTargets(IEnumerable targets, string label) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var target in targets) + { + if (!IsTarget(target)) + return $"{label}的目标无效:{target}"; + if (!seen.Add(target.Trim())) + return $"{label}的目标重复:{target}"; + } + return null; + } + + private static bool IsGroupName(string name) => + name.Length is > 0 and <= 32 && name.All(ch => char.IsAsciiLetterOrDigit(ch) || ch is '_' or '-'); + + private static bool IsRepo(string repo) + { + var parts = repo.Split('/'); + return parts.Length == 2 + && parts[0].Length > 0 + && parts[1].Length > 0 + && parts.All(part => part.All(ch => char.IsAsciiLetterOrDigit(ch) || ch is '.' or '_' or '-')); + } + + internal static bool IsTarget(string value) + { + var text = value.Trim(); + return text.Length is > 0 and <= 80 + && text.All(ch => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_' or ':' || ch == '.'); + } +} + +internal sealed class PushRulesDocument +{ + public GithubRules? Github { get; set; } + public CalendarRules? Calendar { get; set; } + public RepairRules? Repair { get; set; } + public BridgeRules? Bridge { get; set; } + public SummaryRules? Summary { get; set; } +} + +internal sealed class GithubRules +{ + public Dictionary Groups { get; set; } = new(); + public Dictionary Routes { get; set; } = new(); +} + +internal sealed class CalendarRules +{ + public CalendarGroupRule[] Groups { get; set; } = []; + public bool WeeklyEnabled { get; set; } = true; + public bool DailyEnabled { get; set; } = true; + public int WeeklyHour { get; set; } = 9; + public int DailyHour { get; set; } = 9; + public int WeeklyDay { get; set; } = 1; +} + +internal sealed class CalendarGroupRule +{ + public string GroupId { get; set; } = ""; + public string Mode { get; set; } = "BlackList"; + public string[] Keywords { get; set; } = []; +} + +internal sealed class RepairRules +{ + public string PushRawGroup { get; set; } = ""; + public string[] PushInfoGroups { get; set; } = []; + public string[] RemindGroups { get; set; } = []; + public int RemindHour { get; set; } = 12; + public string? RemindSince { get; set; } + public double OpenDays { get; set; } = 1; + public double AcceptedDays { get; set; } = 3; + public double CommittedDays { get; set; } = 2; +} + +internal sealed class BridgeRules +{ + public BridgeClientRule[] Clients { get; set; } = []; +} + +internal sealed class BridgeClientRule +{ + public string Address { get; set; } = ""; + public BridgeGroupRule[] Groups { get; set; } = []; +} + +internal sealed class BridgeGroupRule +{ + public string GroupId { get; set; } = ""; + public bool Enabled { get; set; } = true; + public bool ForwardToClient { get; set; } = true; + public bool ForwardFromClient { get; set; } = true; + public string[] DisabledEvents { get; set; } = []; +} + +internal sealed class SummaryRules +{ + public string[] GroupIds { get; set; } = []; + public int Hour { get; set; } + public int Minute { get; set; } + public int MinMessageCount { get; set; } = 10; +} diff --git a/src/HuaJiBot.NET.Plugin.PushPanel/panel.html b/src/HuaJiBot.NET.Plugin.PushPanel/panel.html new file mode 100644 index 0000000..de29278 --- /dev/null +++ b/src/HuaJiBot.NET.Plugin.PushPanel/panel.html @@ -0,0 +1,507 @@ + + + + + + HuaJiBot 推送规则 + + + +
+
+

推送规则

+

保存后写入正在运行的配置,下一次推送按新规则走。口令、密钥和模型配置不会出现在这里。

+
+
+ + + +
+
+
+
+

登录

+

口令在这台机器人的 config.json 里,字段是 Plugins.PushPanel.Token。

+ +

+
+
+
+
+

GitHub

+

只推默认分支。机器人账号,以及一次提交里只改点文件的 push,不会发。私有仓库必须先写在下面。没映射的公开仓库会自动归到 default。

+ +

分组

+ + + +
名称目标,逗号分隔
+ +

仓库

+ + + +
owner/name分组
+ +
+
+

日程

+

非全天事件开始前 60 分钟、结束前 5 分钟提醒。黑名单且关键词为空表示全部事件。社团事务走同一套群过滤。

+
+ + + + + +
+ + + +
群或话题模式关键词,逗号分隔
+ +
+
+

维修

+

卡住才进每日摘要:待接单、已接单、待审核各自超过下面的天数。没有卡住的工单就不发。原始群留空表示不推原始 JSON。

+
+ + + + + + +
+ + +
+
+

Minecraft 消息桥

+

地址和口令不在这里改。可以改每个群转不转发。

+
+
+
+

每日聊天总结

+

时刻用 Asia/Shanghai,总结前一个自然日。群列表为空则不发。

+
+ + + +
+ +
+
+ + + diff --git a/src/HuaJiBot.NET.UnitTest/HuaJiBot.NET.UnitTest.csproj b/src/HuaJiBot.NET.UnitTest/HuaJiBot.NET.UnitTest.csproj index 43acc90..c25559e 100644 --- a/src/HuaJiBot.NET.UnitTest/HuaJiBot.NET.UnitTest.csproj +++ b/src/HuaJiBot.NET.UnitTest/HuaJiBot.NET.UnitTest.csproj @@ -36,6 +36,7 @@ + diff --git a/src/HuaJiBot.NET.UnitTest/PushPanelTest.cs b/src/HuaJiBot.NET.UnitTest/PushPanelTest.cs new file mode 100644 index 0000000..759b381 --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/PushPanelTest.cs @@ -0,0 +1,211 @@ +using System.Net; +using System.Text; +using HuaJiBot.NET.Config; +using HuaJiBot.NET.Plugin.PushPanel; +using BridgeConfig = HuaJiBot.NET.Plugin.MessageBridge.PluginConfig; +using Newtonsoft.Json.Linq; +using CalendarConfig = HuaJiBot.NET.Plugin.Calendar.PluginConfig; +using GithubConfig = HuaJiBot.NET.Plugin.GitHubBridge.PluginConfig; +using RepairConfig = HuaJiBot.NET.Plugin.RepairTeam.PluginConfig; +using SummaryConfig = HuaJiBot.NET.Plugin.DailySummary.Config.PluginConfig; + +namespace HuaJiBot.NET.UnitTest; + +[NonParallelizable] +internal class PushPanelTest +{ + [Test] + public void Apply_UpdatesRoutesAndKeepsUnrelatedSecrets() + { + using var scope = new ConfigScope(); + var github = new GithubConfig + { + BroadcastGroup = new() { ["default"] = ["726156781"] }, + BroadcastMap = new() { ["nbtca/Home"] = "default" }, + }; + var bridge = new BridgeConfig + { + Clients = + [ + new() + { + Address = "wss://mq.nbtca.space/mc", + Token = "bridge-secret", + Groups = [new() { GroupId = "221064637" }], + }, + ], + }; + scope.Wrapper.Populate(PushRules.Github, github); + scope.Wrapper.Populate(PushRules.Bridge, bridge); + scope.Root.Plugins["AIChat"] = JObject.Parse("""{"ApiKey":"model-secret"}"""); + + var document = PushRules.Read(scope.Wrapper); + document.Github!.Routes["nbtca/Roadmap"] = "default"; + document.Github.Groups["default"] = ["726156781", "466691612"]; + document.Bridge!.Clients[0].Groups[0].ForwardToClient = false; + + Assert.That(PushRules.Apply(scope.Wrapper, document), Is.Null); + + var saved = JObject.Parse(File.ReadAllText("config.json")); + Assert.Multiple(() => + { + Assert.That(github.BroadcastMap["nbtca/Roadmap"], Is.EqualTo("default")); + Assert.That(github.BroadcastGroup["default"], Is.EqualTo(new[] { "726156781", "466691612" })); + Assert.That(bridge.Clients[0].Token, Is.EqualTo("bridge-secret")); + Assert.That(bridge.Clients[0].Groups[0].ForwardToClient, Is.False); + Assert.That(saved["Plugins"]!["AIChat"]!["ApiKey"]!.Value(), Is.EqualTo("model-secret")); + Assert.That(saved["Plugins"]!["MessageBridge"]!["Clients"]![0]!["Token"]!.Value(), Is.EqualTo("bridge-secret")); + Assert.That(JsonOf(PushRules.Read(scope.Wrapper)), Does.Not.Contain("bridge-secret")); + Assert.That(JsonOf(PushRules.Read(scope.Wrapper)), Does.Not.Contain("model-secret")); + }); + } + + [Test] + public void Apply_RejectsUnknownGroupWithoutWriting() + { + using var scope = new ConfigScope(); + var github = new GithubConfig + { + BroadcastGroup = new() { ["default"] = ["1"] }, + BroadcastMap = new() { ["nbtca/Home"] = "default" }, + }; + scope.Wrapper.Populate(PushRules.Github, github); + var document = PushRules.Read(scope.Wrapper); + document.Github!.Routes["nbtca/Home"] = "missing"; + + Assert.That(PushRules.Apply(scope.Wrapper, document), Does.Contain("不存在的分组")); + Assert.Multiple(() => + { + Assert.That(github.BroadcastMap["nbtca/Home"], Is.EqualTo("default")); + Assert.That(File.Exists("config.json"), Is.False); + }); + } + + [Test] + public void Apply_RejectsBridgeAddressChange() + { + using var scope = new ConfigScope(); + var bridge = new BridgeConfig + { + Clients = [new() { Address = "wss://mq.nbtca.space/mc", Token = "keep" }], + }; + scope.Wrapper.Populate(PushRules.Bridge, bridge); + var document = PushRules.Read(scope.Wrapper); + document.Bridge!.Clients[0].Address = "wss://example.invalid/mc"; + + Assert.That(PushRules.Apply(scope.Wrapper, document), Does.Contain("地址不能")); + Assert.That(bridge.Clients[0].Address, Is.EqualTo("wss://mq.nbtca.space/mc")); + Assert.That(bridge.Clients[0].Token, Is.EqualTo("keep")); + } + + [Test] + public void Read_IncludesTheOtherPushRules() + { + using var scope = new ConfigScope(); + scope.Wrapper.Populate( + PushRules.Calendar, + new CalendarConfig + { + ReminderGroups = + [ + new() + { + GroupId = "466691612", + Mode = CalendarConfig.ReminderFilterConfig.FilterMode.BlackList, + Keywords = [], + }, + ], + } + ); + scope.Wrapper.Populate( + PushRules.Repair, + new RepairConfig { PushInfoGroup = ["875853470"], RemindHour = 12, OpenDays = 1 } + ); + scope.Wrapper.Populate(PushRules.Summary, new SummaryConfig { GroupIds = ["466691612"], SummaryHour = 0 }); + + var document = PushRules.Read(scope.Wrapper); + + Assert.Multiple(() => + { + Assert.That(document.Calendar!.Groups[0].GroupId, Is.EqualTo("466691612")); + Assert.That(document.Calendar.Groups[0].Mode, Is.EqualTo("BlackList")); + Assert.That(document.Repair!.PushInfoGroups, Is.EqualTo(new[] { "875853470" })); + Assert.That(document.Summary!.GroupIds, Is.EqualTo(new[] { "466691612" })); + Assert.That(document.Github, Is.Null); + }); + } + + [Test] + public async Task Panel_RequiresTokenAndSaves() + { + using var scope = new ConfigScope(); + var github = new GithubConfig + { + BroadcastGroup = new() { ["default"] = ["1"] }, + BroadcastMap = new() { ["nbtca/Home"] = "default" }, + }; + scope.Wrapper.Populate(PushRules.Github, github); + var port = FreePort(); + using var server = new PanelServer("127.0.0.1", port, "panel-secret", scope.Wrapper); + server.Start(); + using var http = new HttpClient(); + + var page = await http.GetStringAsync($"http://127.0.0.1:{port}/"); + var denied = await http.GetAsync($"http://127.0.0.1:{port}/api/rules"); + http.DefaultRequestHeaders.Authorization = new("Bearer", "panel-secret"); + var body = new StringContent( + """ + {"github":{"groups":{"default":["1","2"]},"routes":{"nbtca/Home":"default","nbtca/Roadmap":"default"}}} + """, + Encoding.UTF8, + "application/json" + ); + var saved = await http.PutAsync($"http://127.0.0.1:{port}/api/rules", body); + var text = await saved.Content.ReadAsStringAsync(); + + Assert.Multiple(() => + { + Assert.That(page, Does.Contain("推送规则")); + Assert.That(page, Does.Not.Contain("panel-secret")); + Assert.That(denied.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + Assert.That(saved.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(text, Does.Contain("nbtca/Roadmap")); + Assert.That(text, Does.Not.Contain("panel-secret")); + Assert.That(github.BroadcastMap["nbtca/Roadmap"], Is.EqualTo("default")); + Assert.That(github.BroadcastGroup["default"], Is.EqualTo(new[] { "1", "2" })); + }); + } + + private static int FreePort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static string JsonOf(PushRulesDocument document) => + Newtonsoft.Json.JsonConvert.SerializeObject(document, PushRules.Json); + + private sealed class ConfigScope : IDisposable + { + private readonly string _previous = Environment.CurrentDirectory; + public HuaJiBot.NET.Config.Config Root { get; } = new(); + public ConfigWrapper Wrapper { get; } + + public ConfigScope() + { + Wrapper = new ConfigWrapper(Root); + var dir = Directory.CreateTempSubdirectory("push-panel-").FullName; + Environment.CurrentDirectory = dir; + } + + public void Dispose() + { + var dir = Environment.CurrentDirectory; + Environment.CurrentDirectory = _previous; + Directory.Delete(dir, true); + } + } +} diff --git a/src/HuaJiBot.NET/Config/ConfigWrapper.cs b/src/HuaJiBot.NET/Config/ConfigWrapper.cs index e24ffd5..63bbe8d 100644 --- a/src/HuaJiBot.NET/Config/ConfigWrapper.cs +++ b/src/HuaJiBot.NET/Config/ConfigWrapper.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace HuaJiBot.NET.Config; @@ -7,6 +8,24 @@ public class ConfigWrapper(Config config) { private readonly Dictionary _plugins = new(); + public bool TryGetLive(string key, [NotNullWhen(true)] out ConfigBase? live) => + _plugins.TryGetValue(key, out live); + + /// + /// Runs the edit while the config lock is held. Saves only when the edit returns null. + /// + public string? Change(Func edit) + { + lock (Config.SaveLock) + { + var error = edit(); + if (error is not null) + return error; + Save(); + return null; + } + } + public void Populate(string key, ConfigBase existValue) //填充配置 { if (config.Plugins.TryGetValue(key, out var value)) //如果配置中有这个插件的配置 From 7599beb94b7478e97faecd718d88aeae6988c277 Mon Sep 17 00:00:00 2001 From: Yuna Celisse <29951517@qq.com> Date: Fri, 25 Sep 2026 07:19:59 +0800 Subject: [PATCH 2/2] fix(push-panel): check the token inside the save handler Routing no longer decides whether the config is written. The save handler refuses the request until the token matches. --- .../PanelServer.cs | 120 ++++++++++-------- 1 file changed, 70 insertions(+), 50 deletions(-) diff --git a/src/HuaJiBot.NET.Plugin.PushPanel/PanelServer.cs b/src/HuaJiBot.NET.Plugin.PushPanel/PanelServer.cs index 4d64293..320d426 100644 --- a/src/HuaJiBot.NET.Plugin.PushPanel/PanelServer.cs +++ b/src/HuaJiBot.NET.Plugin.PushPanel/PanelServer.cs @@ -56,60 +56,13 @@ private async Task HandleAsync(HttpListenerContext context) try { var request = context.Request; - var path = request.Url?.AbsolutePath ?? "/"; - if (request.HttpMethod == "GET" && path is "/" or "/index.html") - { - await WriteText(context.Response, 200, "text/html; charset=utf-8", _page); - return; - } - if (path != "/api/rules") + var handler = SelectHandler(request.Url?.AbsolutePath ?? "/", request.HttpMethod); + if (handler is null) { await WriteJson(context.Response, 404, new { error = "没有这个地址" }); return; } - if (!Authorized(request)) - { - await WriteJson(context.Response, 401, new { error = "口令不对" }); - return; - } - if (request.HttpMethod == "GET") - { - await WriteJson(context.Response, 200, PushRules.Read(_config)); - return; - } - if (request.HttpMethod == "PUT") - { - var body = await ReadBody(request); - if (body.Length > MaxBodyChars) - { - await WriteJson(context.Response, 413, new { error = "请求过大" }); - return; - } - PushRulesDocument? document; - try - { - document = JsonConvert.DeserializeObject(body, PushRules.Json); - } - catch (JsonException) - { - await WriteJson(context.Response, 400, new { error = "请求不是规则 JSON" }); - return; - } - if (document is null) - { - await WriteJson(context.Response, 400, new { error = "请求是空的" }); - return; - } - var error = PushRules.Apply(_config, document); - if (error is not null) - { - await WriteJson(context.Response, 400, new { error }); - return; - } - await WriteJson(context.Response, 200, PushRules.Read(_config)); - return; - } - await WriteJson(context.Response, 405, new { error = "只接受读取和保存" }); + await handler(context); } catch (Exception) { @@ -124,6 +77,73 @@ private async Task HandleAsync(HttpListenerContext context) } } + private Func? SelectHandler(string path, string method) + { + if (method == "GET" && path is "/" or "/index.html") + return ServePage; + if (path != "/api/rules") + return null; + return method switch + { + "GET" => ServeRules, + "PUT" => SaveRules, + _ => MethodNotAllowed, + }; + } + + private Task ServePage(HttpListenerContext context) => + WriteText(context.Response, 200, "text/html; charset=utf-8", _page); + + private async Task ServeRules(HttpListenerContext context) + { + if (!Authorized(context.Request)) + { + await WriteJson(context.Response, 401, new { error = "口令不对" }); + return; + } + await WriteJson(context.Response, 200, PushRules.Read(_config)); + } + + private async Task SaveRules(HttpListenerContext context) + { + if (!Authorized(context.Request)) + { + await WriteJson(context.Response, 401, new { error = "口令不对" }); + return; + } + var body = await ReadBody(context.Request); + if (body.Length > MaxBodyChars) + { + await WriteJson(context.Response, 413, new { error = "请求过大" }); + return; + } + PushRulesDocument? document; + try + { + document = JsonConvert.DeserializeObject(body, PushRules.Json); + } + catch (JsonException) + { + await WriteJson(context.Response, 400, new { error = "请求不是规则 JSON" }); + return; + } + if (document is null) + { + await WriteJson(context.Response, 400, new { error = "请求是空的" }); + return; + } + var error = PushRules.Apply(_config, document); + if (error is not null) + { + await WriteJson(context.Response, 400, new { error }); + return; + } + await WriteJson(context.Response, 200, PushRules.Read(_config)); + } + + private Task MethodNotAllowed(HttpListenerContext context) => + WriteJson(context.Response, 405, new { error = "只接受读取和保存" }); + private bool Authorized(HttpListenerRequest request) { var header = request.Headers["Authorization"];