From 453a8e779342ab127b3307f63645ae3e997f9320 Mon Sep 17 00:00:00 2001 From: JustMe Date: Tue, 29 Sep 2026 20:50:01 +0800 Subject: [PATCH] feat: package MyAgent as Shirobot plugin --- .gitignore | 10 + .gitmodules | 6 + AgentManagementServer.cs | 450 ++++++++++++++++++ AgentPlugin.cs | 171 +++++++ MyAgent | 1 + Shirobot | 1 + Shirobot.Plugin.MyAgent.csproj | 22 + ShirobotGroupPlatform.cs | 142 ++++++ global.json | 6 + scripts/package-plugin.ps1 | 40 ++ .../ManagementServerTests.cs | 44 ++ .../Shirobot.Plugin.MyAgent.Tests.csproj | 20 + 12 files changed, 913 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 AgentManagementServer.cs create mode 100644 AgentPlugin.cs create mode 160000 MyAgent create mode 160000 Shirobot create mode 100644 Shirobot.Plugin.MyAgent.csproj create mode 100644 ShirobotGroupPlatform.cs create mode 100644 global.json create mode 100644 scripts/package-plugin.ps1 create mode 100644 tests/Shirobot.Plugin.MyAgent.Tests/ManagementServerTests.cs create mode 100644 tests/Shirobot.Plugin.MyAgent.Tests/Shirobot.Plugin.MyAgent.Tests.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c35cbea --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +**/bin/ +**/obj/ +**/.idea/ +**/node_modules/ +**/dist/ +**/data/ +**/logs/ +artifacts/ +.env +.env.* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6749b52 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "MyAgent"] + path = MyAgent + url = https://git.oeo.one/justme/MyAgent.git +[submodule "Shirobot"] + path = Shirobot + url = https://git.oeo.one/justme/ShiroBot.git diff --git a/AgentManagementServer.cs b/AgentManagementServer.cs new file mode 100644 index 0000000..ec78b79 --- /dev/null +++ b/AgentManagementServer.cs @@ -0,0 +1,450 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MyAgent; +using MyAgent.Agents; +using MyAgent.Core; +using MyAgent.Storage; +using MyAgent.Tools; + +namespace Shirobot.Plugin.MyAgent; + +public static class AgentManagementServer +{ + public static WebApplication Build(ShiroBot.SDK.Plugin.IBotContext context, string listenUrl) + { + if (!Uri.TryCreate(listenUrl, UriKind.Absolute, out var listenUri) + || listenUri.Scheme != Uri.UriSchemeHttp || !listenUri.IsLoopback) + throw new ArgumentException("ManagementListenUrl must be a loopback HTTP URL.", nameof(listenUrl)); + var webRoot = Path.Combine(Path.GetDirectoryName(typeof(AgentPlugin).Assembly.Location)!, "wwwroot"); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = context.PluginDirectory, WebRootPath = webRoot }); + builder.WebHost.UseUrls(listenUrl); + builder.Services.Configure(options => options.DataDir = Path.Combine(context.PluginDirectory, "data")); + builder.Services.AddAgentCore(); + builder.Services.AddSingleton(new ShirobotGroupPlatform(context)); + builder.Services.AddCors(o => o.AddDefaultPolicy(p => p + .WithOrigins("http://127.0.0.1:5173", "http://localhost:5173", "http://127.0.0.1:5174", "http://localhost:5174") + .AllowAnyHeader().AllowAnyMethod())); +var app = builder.Build(); +app.UseCors(); +app.UseDefaultFiles(); +app.UseStaticFiles(); + +var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) +{ + WriteIndented = true +}; + +var sseJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + +app.MapGet("/api/health", async (LocalConfigStore localStore, CancellationToken ct) => +{ + var local = await localStore.ReadAsync(ct); + return Results.Json(new + { + ok = true, + modelConfigured = !string.IsNullOrWhiteSpace(local.OpenAi.ApiKey), + exaConfigured = !string.IsNullOrWhiteSpace(local.Exa.ApiKey), + now = DateTimeOffset.Now + }, jsonOptions); +}); + +app.MapGet("/api/config", async (IOptions options, LocalConfigStore localStore, CancellationToken ct) => +{ + var cfg = options.Value; + var local = await localStore.ReadAsync(ct); + return Results.Json(new + { + dataDir = cfg.DataDir, + configPath = localStore.GetConfigPath(), + openAi = new + { + baseUrl = local.OpenAi.BaseUrl, + model = local.OpenAi.Model, + routerModel = local.OpenAi.RouterModel, + toolModel = local.OpenAi.ToolModel, + memoryModel = local.OpenAi.MemoryModel, + replyModel = local.OpenAi.ReplyModel, + embeddingModel = local.OpenAi.EmbeddingModel, + apiKey = "", + apiKeyConfigured = !string.IsNullOrWhiteSpace(local.OpenAi.ApiKey) + }, + exa = new + { + enabled = local.Exa.Enabled, + maxResults = local.Exa.MaxResults, + mode = local.Exa.Mode, + apiKey = "", + apiKeyConfigured = !string.IsNullOrWhiteSpace(local.Exa.ApiKey) + }, + search = new + { + webSearchProvider = local.Search.WebSearchProvider, + tavilyApiKey = "", + tavilyApiKeyConfigured = !string.IsNullOrWhiteSpace(local.Search.TavilyApiKey) + }, + media = new + { + imageSearchProvider = local.Media.ImageSearchProvider, + braveApiKey = "", + braveApiKeyConfigured = !string.IsNullOrWhiteSpace(local.Media.BraveApiKey) + }, + bot = new + { + botUserId = local.Bot.BotUserId, + botDisplayName = local.Bot.BotDisplayName, + botAliases = local.Bot.BotAliases, + replyOnlyWhenMentioned = local.Bot.ReplyOnlyWhenMentioned, + defaultGroupId = local.Bot.DefaultGroupId, + allowedGroupIds = local.Bot.AllowedGroupIds + }, + fusion = cfg.Fusion, + context = cfg.Context, + memory = cfg.Memory, + runtime = cfg.Runtime, + webCache = cfg.WebCache, + note = "敏感字段不会通过 API 返回;密码框留空表示保留已配置值。" + }, jsonOptions); +}); + +app.MapPut("/api/config", async (LocalConfigStore localStore, RuntimeConfig config, CancellationToken ct) => +{ + var saved = await localStore.WriteAsync(config, ct); + return Results.Json(new + { + ok = true, + configPath = localStore.GetConfigPath(), + openAi = new + { + baseUrl = saved.OpenAi.BaseUrl, + model = saved.OpenAi.Model, + routerModel = saved.OpenAi.RouterModel, + toolModel = saved.OpenAi.ToolModel, + memoryModel = saved.OpenAi.MemoryModel, + replyModel = saved.OpenAi.ReplyModel, + apiKey = "", + apiKeyConfigured = !string.IsNullOrWhiteSpace(saved.OpenAi.ApiKey) + }, + exa = new + { + enabled = saved.Exa.Enabled, + maxResults = saved.Exa.MaxResults, + mode = saved.Exa.Mode, + apiKey = "", + apiKeyConfigured = !string.IsNullOrWhiteSpace(saved.Exa.ApiKey) + }, + search = new + { + webSearchProvider = saved.Search.WebSearchProvider, + tavilyApiKey = "", + tavilyApiKeyConfigured = !string.IsNullOrWhiteSpace(saved.Search.TavilyApiKey) + }, + media = new + { + imageSearchProvider = saved.Media.ImageSearchProvider, + braveApiKey = "", + braveApiKeyConfigured = !string.IsNullOrWhiteSpace(saved.Media.BraveApiKey) + }, + bot = new + { + botUserId = saved.Bot.BotUserId, + botDisplayName = saved.Bot.BotDisplayName, + botAliases = saved.Bot.BotAliases, + replyOnlyWhenMentioned = saved.Bot.ReplyOnlyWhenMentioned, + defaultGroupId = saved.Bot.DefaultGroupId, + allowedGroupIds = saved.Bot.AllowedGroupIds + } + }, jsonOptions); +}); + +app.MapGet("/api/config/ui-metadata", (ConfigUiMetadata metadata) => Results.Json(metadata, jsonOptions)); + +app.MapGet("/api/openai/models", async (OpenAiModelService modelService, string? baseUrl, string? apiKey, CancellationToken ct) => +{ + var result = await modelService.ListAsync(baseUrl, apiKey, ct); + return Results.Json(result, jsonOptions); +}); + +app.MapPost("/api/openai/models", async (OpenAiModelService modelService, OpenAiModelsRequest request, CancellationToken ct) => +{ + var result = await modelService.ListAsync(request.BaseUrl, request.ApiKey, ct); + return Results.Json(result, jsonOptions); +}); + +app.MapGet("/api/group-chat/profile", async (IGroupPlatform platform, LocalConfigStore configStore, long? groupId, CancellationToken ct) => +{ + var gid = groupId ?? (await configStore.ReadAsync(ct)).Bot.DefaultGroupId; + if (gid <= 0) return Results.BadRequest("groupId is required"); + var profile = await platform.GetProfileAsync(gid, ct); + return Results.Json(profile, jsonOptions); +}); + +app.MapGet("/api/group-chat/recent", async (GroupChatHub hub, IGroupPlatform platform, RecentLogStore recentLog, LocalConfigStore configStore, GroupHistorySyncService historySync, ConversationRegistry conversations, long? groupId, int? limit, CancellationToken ct) => +{ + _ = await historySync.SyncOnceAsync(ct); + var cfg = await configStore.ReadAsync(ct); + var gid = groupId ?? cfg.Bot.DefaultGroupId; + if (gid <= 0) return Results.BadRequest("groupId is required"); + var groupKey = gid.ToString(); + var take = Math.Clamp(limit ?? 50, 1, 100); + var history = await platform.GetHistoryAsync(gid, take, ct); + var localMessages = await recentLog.ReadRecentAsync(groupKey, take * 3, ct); + var convs = await conversations.ListAsync(groupKey, ct); + var turns = convs.SelectMany(c => c.Turns.Select(t => new { Conversation = c, Turn = t })).ToList(); + var localEvents = localMessages + .Select(m => + { + if (m.UserId != "assistant:shiro") + return GroupChatEvent.User(gid, m.UserId, m.UserId, m.Text, m.CreatedAt, m.Attachments.ToArray(), m.MessageId); + + var matched = turns + .Where(x => !string.IsNullOrWhiteSpace(x.Turn.AssistantText) && NormalizeEventText(x.Turn.AssistantText) == NormalizeEventText(m.Text)) + .OrderBy(x => Math.Abs((x.Turn.CreatedAt - m.CreatedAt).TotalSeconds)) + .FirstOrDefault(); + var ev = GroupChatEvent.BotMessage( + gid, + cfg.Bot.BotDisplayName, + m.Text, + m.CreatedAt, + matched?.Turn.ElapsedMs, + null, + m.Attachments.ToArray(), + taskId: matched?.Turn.TaskId ?? "", + threadId: matched?.Conversation.ThreadId ?? ""); + if (matched is not null) ev.Trace = matched.Turn.Trace; + return ev; + }) + .ToList(); + + var merged = MergeRecentEvents(history, localEvents, take); + if (merged.Count > 0) return Results.Json(merged, jsonOptions); + return Results.Json(hub.Recent(gid), jsonOptions); +}); + +app.MapGet("/api/group-chat/events", async (HttpResponse response, GroupChatHub hub, long? groupId, CancellationToken ct) => +{ + response.Headers.ContentType = "text/event-stream; charset=utf-8"; + response.Headers.CacheControl = "no-cache"; + await foreach (var ev in hub.SubscribeAsync(ct)) + { + if (groupId is not null && ev.GroupId != 0 && ev.GroupId != groupId.Value) continue; + await response.WriteAsync($"event: {ev.Type}\ndata: {JsonSerializer.Serialize(ev, sseJsonOptions)}\n\n", ct); + await response.Body.FlushAsync(ct); + } +}); + +app.MapGet("/api/workflow/metadata", (WorkflowUiMetadata metadata) => Results.Json(metadata, jsonOptions)); + +app.MapGet("/api/tools/catalog", (ToolCatalog catalog) => Results.Json(catalog.List(), jsonOptions)); + +app.MapGet("/api/media/sources", (MediaService media) => Results.Json(media.GetSources(), jsonOptions)); + +app.MapPost("/api/media/search", async (MediaService media, MediaSearchRequest request, CancellationToken ct) => +{ + var result = await media.SearchImagesAsync(request, ct); + return Results.Json(result, jsonOptions); +}); + +app.MapPost("/api/media/download", async (MediaService media, MediaDownloadRequest request, CancellationToken ct) => +{ + var result = await media.DownloadAsync(request, ct); + return string.IsNullOrWhiteSpace(result.Error) ? Results.Json(result, jsonOptions) : Results.BadRequest(result); +}); + +app.MapGet("/api/media/{mediaId}", async (MediaService media, string mediaId, CancellationToken ct) => +{ + var file = await media.TryGetFileAsync(mediaId, ct); + return file is null ? Results.NotFound() : Results.File(file.Value.Path, file.Value.Mime); +}); + +app.MapGet("/api/files/{groupId}/{fileId}", (string groupId, string fileId) => +{ + var dir = Path.Combine(AppContext.BaseDirectory, "data", "files", groupId); + if (!Directory.Exists(dir)) return Results.NotFound(); + var file = Directory.EnumerateFiles(dir, fileId + ".*").FirstOrDefault(); + if (file is null) return Results.NotFound(); + return Results.File(file, "application/octet-stream", Path.GetFileName(file)); +}); + +app.MapGet("/api/groups/{groupId}/context", async (GroupContextStore store, string groupId, CancellationToken ct) => +{ + var md = await store.ReadOrCreateAsync(groupId, ct); + return Results.Text(md, "text/markdown; charset=utf-8"); +}); + +app.MapPut("/api/groups/{groupId}/context", async (GroupContextStore store, string groupId, HttpRequest request, CancellationToken ct) => +{ + using var reader = new StreamReader(request.Body); + var md = await reader.ReadToEndAsync(ct); + await store.WriteAsync(groupId, md, ct); + return Results.Json(new { ok = true }); +}); + +app.MapGet("/api/groups/{groupId}/members", async (GroupContextStore store, string groupId, CancellationToken ct) => +{ + var members = await store.ListMembersAsync(groupId, ct); + return Results.Json(members, jsonOptions); +}); + +app.MapGet("/api/groups/{groupId}/members/{userId}/memory", async (GroupContextStore store, string groupId, string userId, CancellationToken ct) => +{ + var md = await store.ReadOrCreateMemberAsync(groupId, userId, ct); + return Results.Text(md, "text/markdown; charset=utf-8"); +}); + +app.MapPut("/api/groups/{groupId}/members/{userId}/memory", async (GroupContextStore store, string groupId, string userId, HttpRequest request, CancellationToken ct) => +{ + using var reader = new StreamReader(request.Body); + var md = await reader.ReadToEndAsync(ct); + await store.WriteMemberAsync(groupId, userId, md, ct); + return Results.Json(new { ok = true }); +}); + +app.MapPost("/api/groups/{groupId}/members/{userId}/memory/reset", async (GroupContextStore store, string groupId, string userId, CancellationToken ct) => +{ + var md = await store.ResetMemberAsync(groupId, userId, ct); + return Results.Json(new { ok = true, markdown = md, message = "群友记忆已清理并重置,旧文件已备份" }, jsonOptions); +}); + +app.MapDelete("/api/groups/{groupId}/members/{userId}/memory", async (GroupContextStore store, string groupId, string userId, CancellationToken ct) => +{ + var deleted = await store.DeleteMemberAsync(groupId, userId, backup: true, ct); + return Results.Json(new { ok = true, deleted, message = deleted ? "群友记忆已删除,旧文件已备份" : "群友记忆不存在" }, jsonOptions); +}); + +app.MapPost("/api/groups/{groupId}/context/reset", async (GroupContextStore store, string groupId, CancellationToken ct) => +{ + var md = await store.ResetAsync(groupId, ct); + return Results.Json(new { ok = true, markdown = md, message = "context.md 已清理并重置为默认模板,旧文件已备份" }, jsonOptions); +}); + +app.MapPost("/api/groups/{groupId}/context/clear-all", async (GroupContextStore store, RecentLogStore recentLog, ConversationRegistry conversations, SessionManager sessions, GroupChatHub hub, string groupId, CancellationToken ct) => +{ + var result = await store.ClearAllContextAsync(groupId, ct); + await conversations.ClearGroupAsync(groupId, ct); + await sessions.ClearGroupAsync(groupId, ct); + var recentDeleted = await recentLog.ClearAsync(groupId, ct); + if (recentDeleted) result.Deleted = result.Deleted.Concat(["recent.jsonl"]).ToList(); + if (long.TryParse(groupId, out var gid)) hub.ClearGroup(gid); + return Results.Json(new { ok = true, result.GroupId, markdown = result.Markdown, result.Deleted, message = result.Message + ";线程上下文、待融合消息、最近消息和页面事件缓存已清空" }, jsonOptions); +}); + +app.MapPost("/api/groups/{groupId}/context/refresh", async (MemoryWorker memory, string groupId, CancellationToken ct) => +{ + var result = await memory.RefreshAsync(groupId, ct); + return Results.Json(result, jsonOptions); +}); + +app.MapGet("/api/groups/{groupId}/recent", async (RecentLogStore store, string groupId, int? limit, CancellationToken ct) => +{ + var items = await store.ReadRecentAsync(groupId, limit ?? 50, ct); + return Results.Json(items, jsonOptions); +}); + +app.MapGet("/api/groups/{groupId}/context-parts", async (ContextBuilder contextBuilder, string groupId, string? userId, CancellationToken ct) => +{ + var parts = await contextBuilder.BuildPartsAsync(groupId, string.IsNullOrWhiteSpace(userId) ? "demo-user" : userId, ct); + return Results.Json(parts, jsonOptions); +}); + +app.MapGet("/api/groups/{groupId}/heavy-memory", async (HeavyMemoryStore store, string groupId, int? limit, CancellationToken ct) => +{ + var items = (await store.ReadAllAsync(groupId, ct)) + .OrderByDescending(x => x.EndAt) + .Take(Math.Clamp(limit ?? 50, 1, 200)) + .Select(ToHeavyMemoryDto) + .ToList(); + return Results.Json(new + { + ok = true, + path = store.GetHeavyMemoryPath(groupId), + backupDir = store.GetHeavyMemoryBackupDir(groupId), + lightRecallCachePath = store.GetLightRecallCachePath(groupId), + items + }, jsonOptions); +}); + +app.MapGet("/api/groups/{groupId}/heavy-memory/search", async (HeavyMemoryStore store, string groupId, string? q, int? limit, CancellationToken ct) => +{ + var items = string.IsNullOrWhiteSpace(q) + ? [] + : (await store.SearchAsync(groupId, q, Math.Clamp(limit ?? 8, 1, 20), ct)).Select(ToHeavyMemoryDto).ToList(); + return Results.Json(new { ok = true, query = q ?? "", items }, jsonOptions); +}); + +app.MapGet("/api/groups/{groupId}/light-recall-cache", async (HeavyMemoryStore store, string groupId, CancellationToken ct) => +{ + var md = await store.ReadLightRecallCacheAsync(groupId, ct); + return Results.Text(string.IsNullOrWhiteSpace(md) ? "" : md, "text/markdown; charset=utf-8"); +}); + +static object ToHeavyMemoryDto(HeavyMemoryEntry x) => new +{ + x.Id, + x.GroupId, + x.Topic, + x.Summary, + x.StartAt, + x.EndAt, + x.Participants, + x.Keywords, + x.SourceMessageIds, + x.Importance, + x.CreatedAt, + RawDialoguePreview = string.IsNullOrWhiteSpace(x.RawDialogue) ? "" : x.RawDialogue.Length <= 1200 ? x.RawDialogue : x.RawDialogue[..1200] + "\n...(原始对话过长,已在前端预览截断)" +}; + +app.MapGet("/api/conversations", async (ConversationRegistry conversations, AgentTopologyBuilder topology, string? groupId, CancellationToken ct) => +{ + var items = await conversations.ListAsync(groupId, ct); + foreach (var item in items) + { + item.LastTopology = topology.Build(item.LastTrace, item.LastTurnStatus); + foreach (var turn in item.Turns) + { + var turnStatus = item.LastTaskId == turn.TaskId ? item.LastTurnStatus : null; + turn.Topology = topology.Build(turn.Trace, turnStatus); + } + } + return Results.Json(items, jsonOptions); +}); +app.MapGet("/api/tasks", (TaskRegistry tasks) => Results.Json(tasks.List(), jsonOptions)); +app.MapGet("/api/tasks/{taskId}", (TaskRegistry tasks, string taskId) => + tasks.TryGet(taskId, out var task) ? Results.Json(task, jsonOptions) : Results.NotFound()); + +app.MapGet("/api/groups/{groupId}/runs/{runId}", async (AgentRunStore runs, string groupId, string runId, CancellationToken ct) => +{ + var state = await runs.TryReadAsync(groupId, runId, ct); + return state is null ? Results.NotFound() : Results.Json(state, jsonOptions); +}); + +app.MapFallbackToFile("index.html"); + +return app; + +static IReadOnlyList MergeRecentEvents(IReadOnlyList remote, IReadOnlyList local, int limit) +{ + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var merged = new List(); + foreach (var ev in remote.Concat(local).OrderBy(x => x.At)) + { + var key = !string.IsNullOrWhiteSpace(ev.MessageId) + ? "msg:" + ev.MessageId + : $"{ev.Type}:{ev.SenderId}:{ev.At.ToUnixTimeSeconds()}:{NormalizeEventText(ev.Text)}"; + if (!seen.Add(key)) continue; + merged.Add(ev); + } + return merged.TakeLast(Math.Max(1, limit)).ToList(); +} + +static string NormalizeEventText(string text) +{ + text = text.Replace("\r\n", "\n").Trim(); + return text.Length <= 80 ? text : text[..80]; +} + + } +} diff --git a/AgentPlugin.cs b/AgentPlugin.cs new file mode 100644 index 0000000..2c9d45a --- /dev/null +++ b/AgentPlugin.cs @@ -0,0 +1,171 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ShiroBot.SDK.Abstractions; +using ShiroBot.SDK.Config; +using ShiroBot.SDK.Core; +using ShiroBot.SDK.Models; +using ShiroBot.SDK.Plugin; +using MyAgent.Core; +using MyAgent.Storage; +using MyAgent.Tools; + +[assembly: ShiroBotApiCompatibility("0.9", "0.9")] + +namespace Shirobot.Plugin.MyAgent; + +[BotPlugin("Shirobot.Plugin.MyAgent", Name = "Shirobot.Plugin.MyAgent", Version = "0.1.0", Description = "QQ group AI agent")] +public sealed class AgentPlugin : PluginBase +{ + private IServiceProvider? _services; + private WebApplication? _management; + private bool _managementStarted; + private CancellationTokenSource? _lifetime; + private readonly ConcurrentDictionary _running = new(); + + protected override void ConfigureRoutes() + { + GroupCommands.MapAll(OnMessageAsync); + Events.MapWhen(e => e.Platform == "qq" && e.Channel.Type == ChannelType.Group, OnDeletedAsync); + } + + protected override async Task LoadAsync() + { + var settings = Context.Config.Load(); + _lifetime = new CancellationTokenSource(); + try + { + _management = AgentManagementServer.Build(Context, settings.ManagementListenUrl); + _services = _management.Services; + await _services.GetRequiredService().StartAsync(_lifetime.Token); + await _services.GetRequiredService().StartAsync(_lifetime.Token); + try + { + await _management.StartAsync(_lifetime.Token); + _managementStarted = true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + BotLog.Warning($"Agent management API did not start: {ex.Message}"); + } + } + catch + { + if (_services is null) + { + _lifetime.Dispose(); + _lifetime = null; + if (_management is not null) await _management.DisposeAsync(); + _management = null; + } + else + { + await OnUnloadAsync(); + } + throw; + } + BotLog.Info("Shirobot.Plugin.MyAgent loaded"); + } + + private Task OnMessageAsync(MessageEvent message) + { + if (message.Platform != "qq" || message.Channel.Type != ChannelType.Group || message.Sender.Id == message.SelfId) + return Task.CompletedTask; + if (!long.TryParse(message.Channel.Id, out var groupId) || !long.TryParse(message.Sender.Id, out var userId)) + return Task.CompletedTask; + var services = _services; + var lifetime = _lifetime; + var context = Context; + if (services is null || lifetime is null || lifetime.IsCancellationRequested) return Task.CompletedTask; + + var task = Task.Run(async () => + { + try + { + using var scope = context.UsePlatform(message.Platform); + var configStore = services.GetRequiredService(); + var cfg = await configStore.ReadAsync(lifetime.Token); + if (cfg.Bot.AllowedGroupIds.Count > 0 && !cfg.Bot.AllowedGroupIds.Contains(groupId)) return; + var changed = false; + if (cfg.Bot.BotUserId <= 0 && long.TryParse(message.SelfId, out var selfId)) + { + cfg.Bot.BotUserId = selfId; + changed = true; + } + if (cfg.Bot.DefaultGroupId <= 0) + { + cfg.Bot.DefaultGroupId = groupId; + changed = true; + } + if (changed) await configStore.WriteAsync(cfg, lifetime.Token); + var mentions = message.Segments.OfType().Select(m => m.UserId).ToList(); + var mentioned = !string.IsNullOrWhiteSpace(message.SelfId) && mentions.Contains(message.SelfId); + mentioned |= message.GetPlainText().Contains(cfg.Bot.BotDisplayName, StringComparison.OrdinalIgnoreCase); + mentioned |= cfg.Bot.BotAliases.Any(alias => message.GetPlainText().Contains(alias, StringComparison.OrdinalIgnoreCase)); + var request = new GroupChatUserMessage + { + GroupId = groupId, + UserId = userId, + DisplayName = message.Member?.DisplayName ?? message.Sender.Name ?? message.Sender.Id, + Text = message.GetPlainText(), + IsMentioned = mentioned, + MentionedUserIds = mentions, + Attachments = await ((ShirobotGroupPlatform)services.GetRequiredService()).ToAttachmentsAsync( + message.Segments, groupId, services.GetRequiredService(), lifetime.Token), + MessageId = message.MessageId, + ReplyToMessageId = message.GetQuote()?.MessageId + }; + await foreach (var _ in services.GetRequiredService().RunGroupAsync( + request, + new AgentWorkflowOptions { PublishToHub = true, SendToPlatform = true, EmitUserEvent = true, EmitStatusEvents = true }, + lifetime.Token)) { } + } + catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { } + catch (Exception ex) { BotLog.Error($"Agent message failed: {ex}"); } + }, lifetime.Token); + _running.TryAdd(task, 0); + _ = task.ContinueWith(completed => _running.TryRemove(completed, out _), TaskScheduler.Default); + return Task.CompletedTask; + } + + private async Task OnDeletedAsync(MessageDeletedEvent e) + { + if (_services is null || _lifetime is null || _lifetime.IsCancellationRequested) return; + if (!long.TryParse(e.Channel.Id, out var groupId)) return; + await _services.GetRequiredService().HandleRecallAsync(new GroupChatRecallMessage + { + GroupId = groupId, + UserId = long.TryParse(e.SenderId, out var senderId) ? senderId : 0, + RecallMessageId = e.MessageId + }, _lifetime.Token); + } + + protected override async Task OnUnloadAsync() + { + var lifetime = _lifetime; + var services = _services; + if (lifetime is null || services is null) return; + lifetime.Cancel(); + try { await Task.WhenAll(_running.Keys).WaitAsync(TimeSpan.FromSeconds(3)); } + catch (OperationCanceledException) { } + catch (TimeoutException) { BotLog.Warning("Agent tasks are still stopping"); } + if (_management is not null) + { + if (_managementStarted) await _management.StopAsync(CancellationToken.None); + await services.GetRequiredService().StopAsync(CancellationToken.None); + await services.GetRequiredService().StopAsync(CancellationToken.None); + await _management.DisposeAsync(); + _management = null; + _managementStarted = false; + } + lifetime.Dispose(); + _services = null; + _lifetime = null; + } +} + +public sealed class AgentPluginSettings +{ + [ConfigField("管理 API 监听地址")] + public string ManagementListenUrl { get; set; } = "http://127.0.0.1:5087"; +} diff --git a/MyAgent b/MyAgent new file mode 160000 index 0000000..e491b1a --- /dev/null +++ b/MyAgent @@ -0,0 +1 @@ +Subproject commit e491b1a35b7e8bf7d7946f3a5830132682e94181 diff --git a/Shirobot b/Shirobot new file mode 160000 index 0000000..95379f5 --- /dev/null +++ b/Shirobot @@ -0,0 +1 @@ +Subproject commit 95379f5cfeb3e884dd8973b3b27a844f024c5500 diff --git a/Shirobot.Plugin.MyAgent.csproj b/Shirobot.Plugin.MyAgent.csproj new file mode 100644 index 0000000..65ec4d6 --- /dev/null +++ b/Shirobot.Plugin.MyAgent.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + Shirobot.Plugin.MyAgent + Shirobot.Plugin.MyAgent + true + + + + + + + + + + + + + + diff --git a/ShirobotGroupPlatform.cs b/ShirobotGroupPlatform.cs new file mode 100644 index 0000000..912d692 --- /dev/null +++ b/ShirobotGroupPlatform.cs @@ -0,0 +1,142 @@ +using ShiroBot.SDK.Models; +using ShiroBot.SDK.Plugin; +using ShiroBot.Model.QQ; +using MyAgent; +using MyAgent.Core; +using MyAgent.Tools; +using Channel = ShiroBot.SDK.Models.Channel; + +namespace Shirobot.Plugin.MyAgent; + +internal sealed class ShirobotGroupPlatform(IBotContext context) : IGroupPlatform +{ + private static readonly HttpClient DownloadClient = new() { Timeout = TimeSpan.FromSeconds(20) }; + public async Task SendAsync( + long groupId, string text, IReadOnlyList attachments, + string? replyToMessageId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + using var scope = context.UsePlatform("qq"); + var segments = new List(); + if (!string.IsNullOrWhiteSpace(replyToMessageId)) segments.Add(new QuoteSegment(replyToMessageId)); + if (!string.IsNullOrWhiteSpace(text)) segments.Add(new TextSegment(text)); + foreach (var attachment in attachments) + { + var uri = !string.IsNullOrWhiteSpace(attachment.LocalPath) && File.Exists(attachment.LocalPath) + ? "base64://" + Convert.ToBase64String(await File.ReadAllBytesAsync(attachment.LocalPath, cancellationToken)) + : attachment.SourceUrl; + if (!string.IsNullOrWhiteSpace(uri)) segments.Add(new ImageSegment(uri) { Summary = "[图片]" }); + } + if (segments.Count == 0) return null; + var sent = await context.Message.SendMessageAsync(Channel.Group(groupId.ToString()), segments); + return new MessageSendResult { Ok = true, MessageId = sent.MessageId, Timestamp = sent.Timestamp }; + } + + public async Task> GetHistoryAsync(long groupId, int limit, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + using var scope = context.UsePlatform("qq"); + var messages = await context.Message.GetHistoryMessagesAsync(Channel.Group(groupId.ToString()), limit: Math.Clamp(limit, 1, 100)); + return messages.Where(message => message.Sender.Id != message.SelfId) + .Select(message => GroupChatEvent.User(groupId, message.Sender.Id, message.Member?.Nick ?? message.Sender.Name ?? message.Sender.Id, + message.GetPlainText(), message.Timestamp, ToAttachments(message.Segments), message.MessageId)) + .ToArray(); + } + + public async Task GetProfileAsync(long groupId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + using var scope = context.UsePlatform("qq"); + var channel = await context.Channel.GetChannelAsync(groupId.ToString()); + var members = await context.Channel.GetMembersAsync(groupId.ToString()); + return new GroupProfileResult + { + GroupId = groupId, + GroupName = channel?.Name ?? groupId.ToString(), + AvatarUrl = $"https://p.qlogo.cn/gh/{groupId}/{groupId}/100", + MemberCount = members.Count, + Members = members.Select(member => new GroupMemberInfo + { + UserId = long.TryParse(member.User.Id, out var id) ? id : 0, + Name = member.Nick ?? member.User.Name ?? member.User.Id, + Role = member.Role.ToString().ToLowerInvariant(), + AvatarUrl = $"https://q1.qlogo.cn/g?b=qq&nk={member.User.Id}&s=100" + }).ToList() + }; + } + + internal static ChatAttachment[] ToAttachments(IReadOnlyList segments) => segments + .OfType() + .Select(segment => new ChatAttachment + { + Type = segment switch { ImageSegment => "image", AudioSegment => "audio", VideoSegment => "video", FileSegment => "file", _ => "resource" }, + Url = segment.Uri, + SourceUrl = segment.Uri, + Title = segment.FileName ?? "", + Size = segment is FileSegment file ? file.FileSize ?? 0 : 0 + }).ToArray(); + + internal async Task> ToAttachmentsAsync( + IReadOnlyList segments, long groupId, DocumentTextExtractor documents, + CancellationToken cancellationToken) + { + using var scope = context.UsePlatform("qq"); + var attachments = new List(); + foreach (var segment in segments.OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + var url = segment.Uri; + if (string.IsNullOrWhiteSpace(url) && !string.IsNullOrWhiteSpace(segment.ResourceId)) + { + if (segment is FileSegment) + url = await (context.GetAdapterExtension()?.GetGroupFileDownloadUrlAsync(groupId, segment.ResourceId) + ?? Task.FromResult("")); + else + url = await context.Message.GetResourceUrlAsync(segment.ResourceId); + } + var attachment = new ChatAttachment + { + Type = segment switch { ImageSegment => "image", AudioSegment => "audio", VideoSegment => "video", FileSegment => "file", _ => "resource" }, + Url = url, + SourceUrl = url, + Title = segment.FileName ?? "", + Size = segment is FileSegment file ? file.FileSize ?? 0 : 0 + }; + if (segment is FileSegment && Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + { + try + { + var cache = Path.Combine(context.PluginDirectory, "data", "attachments"); + Directory.CreateDirectory(cache); + var suffix = Path.GetExtension(attachment.Title); + var path = Path.Combine(cache, Guid.NewGuid().ToString("n") + suffix); + using var response = await DownloadClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength is > 15_000_000) throw new InvalidDataException("Attachment exceeds 15 MB"); + await using (var input = await response.Content.ReadAsStreamAsync(cancellationToken)) + await using (var output = File.Create(path)) + { + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0) + { + total += read; + if (total > 15_000_000) throw new InvalidDataException("Attachment exceeds 15 MB"); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + } + attachment.LocalPath = path; + attachment.TextContent = await documents.TryExtractAsync(path, attachment.Title, + response.Content.Headers.ContentType?.MediaType ?? "", cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + ShiroBot.SDK.Abstractions.BotLog.Warning($"Could not read group attachment: {ex.Message}"); + } + } + attachments.Add(attachment); + } + return attachments; + } +} diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/scripts/package-plugin.ps1 b/scripts/package-plugin.ps1 new file mode 100644 index 0000000..f1bb52d --- /dev/null +++ b/scripts/package-plugin.ps1 @@ -0,0 +1,40 @@ +$ErrorActionPreference = 'Stop' + +$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$artifactRoot = [IO.Path]::GetFullPath((Join-Path $projectRoot 'artifacts')) +$staging = [IO.Path]::GetFullPath((Join-Path $artifactRoot 'staging')) +$output = [IO.Path]::GetFullPath((Join-Path $artifactRoot 'plugin')) + +foreach ($path in @($artifactRoot, $staging, $output)) { + if (-not $path.StartsWith($projectRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use path outside project: $path" + } +} + +Push-Location (Join-Path $projectRoot 'MyAgent\MyAgent.Web') +try { + npm ci + if ($LASTEXITCODE -ne 0) { throw 'npm ci failed' } + npm run build + if ($LASTEXITCODE -ne 0) { throw 'frontend build failed' } +} +finally { Pop-Location } + +if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force } +if (Test-Path -LiteralPath $output) { Remove-Item -LiteralPath $output -Recurse -Force } +New-Item -ItemType Directory -Path $staging -Force | Out-Null +New-Item -ItemType Directory -Path $output -Force | Out-Null + +dotnet publish (Join-Path $projectRoot 'Shirobot.Plugin.MyAgent.csproj') -c Release -o $staging +if ($LASTEXITCODE -ne 0) { throw 'plugin publish failed' } + +$files = @('MyAgent.dll', 'Shirobot.Plugin.MyAgent.dll', 'Shirobot.Plugin.MyAgent.deps.json', 'HtmlAgilityPack.dll') +$files += Get-ChildItem -LiteralPath $staging -Filter 'UglyToad.PdfPig*.dll' -File | Select-Object -ExpandProperty Name +foreach ($file in $files) { + $from = Join-Path $staging $file + if (-not (Test-Path -LiteralPath $from)) { throw "Missing publish file: $file" } + Copy-Item -LiteralPath $from -Destination (Join-Path $output $file) +} +Copy-Item -LiteralPath (Join-Path $staging 'wwwroot') -Destination (Join-Path $output 'wwwroot') -Recurse + +Write-Host "Plugin package ready: $output" diff --git a/tests/Shirobot.Plugin.MyAgent.Tests/ManagementServerTests.cs b/tests/Shirobot.Plugin.MyAgent.Tests/ManagementServerTests.cs new file mode 100644 index 0000000..2cc7bc7 --- /dev/null +++ b/tests/Shirobot.Plugin.MyAgent.Tests/ManagementServerTests.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using ShiroBot.SDK.Plugin; +using Shirobot.Plugin.MyAgent; +using Xunit; + +namespace Shirobot.Plugin.MyAgent.Tests; + +public sealed class ManagementServerTests +{ + [Fact] + public async Task StartsManagementApiWithoutIndependentMessageEndpoint() + { + var root = Path.Combine(Path.GetTempPath(), "myagent-test-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(root); + var context = DispatchProxy.Create(); + ((BotContextProxy)(object)context).PluginDirectory = root; + try + { + await using var app = AgentManagementServer.Build(context, "http://127.0.0.1:0"); + await app.StartAsync(); + var address = app.Urls.Single(); + using var client = new HttpClient { BaseAddress = new Uri(address) }; + using var health = await client.GetAsync("/api/health"); + Assert.True(health.IsSuccessStatusCode); + using var oldIngress = await client.PostAsync("/api/group-chat/send", new StringContent("{}")); + Assert.False(oldIngress.IsSuccessStatusCode); + await app.StopAsync(); + } + finally + { + var full = Path.GetFullPath(root); + if (full.StartsWith(Path.GetFullPath(Path.GetTempPath()), StringComparison.OrdinalIgnoreCase)) + Directory.Delete(full, recursive: true); + } + } + + public class BotContextProxy : DispatchProxy + { + public string PluginDirectory { get; set; } = ""; + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => + targetMethod?.Name == "get_PluginDirectory" ? PluginDirectory : throw new NotSupportedException(targetMethod?.Name); + } +} diff --git a/tests/Shirobot.Plugin.MyAgent.Tests/Shirobot.Plugin.MyAgent.Tests.csproj b/tests/Shirobot.Plugin.MyAgent.Tests/Shirobot.Plugin.MyAgent.Tests.csproj new file mode 100644 index 0000000..6ad1de8 --- /dev/null +++ b/tests/Shirobot.Plugin.MyAgent.Tests/Shirobot.Plugin.MyAgent.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + +