commit 453a8e779342ab127b3307f63645ae3e997f9320 Author: JustMe Date: Tue Sep 29 20:50:01 2026 +0800 feat: package MyAgent as Shirobot plugin 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 + + + + + +