feat: package MyAgent as Shirobot plugin
This commit is contained in:
commit
453a8e7793
12 files changed
+913
No files matched your search
+10
@@ -0,0 +1,10 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/.idea/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/data/
|
||||
**/logs/
|
||||
artifacts/
|
||||
.env
|
||||
.env.*
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user