feat: package MyAgent as Shirobot plugin

This commit is contained in:
justme committed 2026-09-29 20:50:01 +08:00
commit 453a8e7793
12 files changed
+913

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
**/bin/
**/obj/
**/.idea/
**/node_modules/
**/dist/
**/data/
**/logs/
artifacts/
.env
.env.*
+6
View File
@@ -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
+450
View File
@@ -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<ShirobotOptions>(options => options.DataDir = Path.Combine(context.PluginDirectory, "data"));
builder.Services.AddAgentCore();
builder.Services.AddSingleton<IGroupPlatform>(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<ShirobotOptions> 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<GroupChatEvent> MergeRecentEvents(IReadOnlyList<GroupChatEvent> remote, IReadOnlyList<GroupChatEvent> local, int limit)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var merged = new List<GroupChatEvent>();
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];
}
}
}
+171
View File
@@ -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<Task, byte> _running = new();
protected override void ConfigureRoutes()
{
GroupCommands.MapAll(OnMessageAsync);
Events.MapWhen<MessageDeletedEvent>(e => e.Platform == "qq" && e.Channel.Type == ChannelType.Group, OnDeletedAsync);
}
protected override async Task LoadAsync()
{
var settings = Context.Config.Load<AgentPluginSettings>();
_lifetime = new CancellationTokenSource();
try
{
_management = AgentManagementServer.Build(Context, settings.ManagementListenUrl);
_services = _management.Services;
await _services.GetRequiredService<global::MyAgent.Agents.MemoryWorker>().StartAsync(_lifetime.Token);
await _services.GetRequiredService<GroupHistorySyncService>().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<LocalConfigStore>();
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<MentionSegment>().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<IGroupPlatform>()).ToAttachmentsAsync(
message.Segments, groupId, services.GetRequiredService<DocumentTextExtractor>(), lifetime.Token),
MessageId = message.MessageId,
ReplyToMessageId = message.GetQuote()?.MessageId
};
await foreach (var _ in services.GetRequiredService<AgentWorkflowEngine>().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<GroupChatBridge>().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<GroupHistorySyncService>().StopAsync(CancellationToken.None);
await services.GetRequiredService<global::MyAgent.Agents.MemoryWorker>().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";
}
Submodule
+1
Submodule MyAgent added at e491b1a35b.
Submodule
+1
Submodule Shirobot added at 95379f5cfe.
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Shirobot.Plugin.MyAgent</RootNamespace>
<AssemblyName>Shirobot.Plugin.MyAgent</AssemblyName>
<ShiroBotPluginPackagingEnabled>true</ShiroBotPluginPackagingEnabled>
</PropertyGroup>
<ItemGroup>
<Compile Remove="tests\**\*.cs;MyAgent\**\*.cs;Shirobot\**\*.cs" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="MyAgent\MyAgent\MyAgent.csproj" />
<ProjectReference Include="Shirobot\SDK\ShiroBot.SDK.csproj" />
<ProjectReference Include="Shirobot\Models\QQ\ShiroBot.Model.QQ.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="MyAgent\MyAgent.Web\dist\**\*" Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+142
View File
@@ -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<MessageSendResult?> SendAsync(
long groupId, string text, IReadOnlyList<ChatAttachment> attachments,
string? replyToMessageId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using var scope = context.UsePlatform("qq");
var segments = new List<MessageSegment>();
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<IReadOnlyList<GroupChatEvent>> 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<GroupProfileResult> 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<MessageSegment> segments) => segments
.OfType<ResourceSegment>()
.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<List<ChatAttachment>> ToAttachmentsAsync(
IReadOnlyList<MessageSegment> segments, long groupId, DocumentTextExtractor documents,
CancellationToken cancellationToken)
{
using var scope = context.UsePlatform("qq");
var attachments = new List<ChatAttachment>();
foreach (var segment in segments.OfType<ResourceSegment>())
{
cancellationToken.ThrowIfCancellationRequested();
var url = segment.Uri;
if (string.IsNullOrWhiteSpace(url) && !string.IsNullOrWhiteSpace(segment.ResourceId))
{
if (segment is FileSegment)
url = await (context.GetAdapterExtension<IQFileApi>()?.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;
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
+40
View File
@@ -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"
@@ -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<IBotContext, BotContextProxy>();
((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);
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shirobot.Plugin.MyAgent.csproj" />
</ItemGroup>
</Project>