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..320d426 --- /dev/null +++ b/src/HuaJiBot.NET.Plugin.PushPanel/PanelServer.cs @@ -0,0 +1,206 @@ +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 handler = SelectHandler(request.Url?.AbsolutePath ?? "/", request.HttpMethod); + if (handler is null) + { + await WriteJson(context.Response, 404, new { error = "没有这个地址" }); + return; + } + await handler(context); + } + catch (Exception) + { + try + { + await WriteJson(context.Response, 500, new { error = "面板内部出错" }); + } + catch (Exception) + { + context.Response.Abort(); + } + } + } + + 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"]; + 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)) //如果配置中有这个插件的配置