mioku-plugin-agent 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/session.ts CHANGED
@@ -3,44 +3,44 @@ import type { AgentDatabase, AgentSessionRow } from "../db";
3
3
  export class SessionManager {
4
4
  constructor(private db: AgentDatabase) {}
5
5
 
6
- generation(userId: number): number {
6
+ generation(userId: string): number {
7
7
  return this.db.getUserGeneration(userId);
8
8
  }
9
9
 
10
- sessionId(userId: number): string {
10
+ sessionId(userId: string): string {
11
11
  return `agent:${userId}:g${this.generation(userId)}`;
12
12
  }
13
13
 
14
- get(userId: number): AgentSessionRow {
14
+ get(userId: string): AgentSessionRow {
15
15
  return this.db.getOrCreateSession(this.sessionId(userId), userId);
16
16
  }
17
17
 
18
- newSession(userId: number): AgentSessionRow {
18
+ newSession(userId: string): AgentSessionRow {
19
19
  this.db.bumpUserGenerationTo(userId, this.db.maxGeneration(userId) + 1);
20
20
  return this.get(userId);
21
21
  }
22
22
 
23
- resume(userId: number, generation: number): AgentSessionRow | undefined {
23
+ resume(userId: string, generation: number): AgentSessionRow | undefined {
24
24
  const target = this.db.getSessionByGeneration(userId, generation);
25
25
  if (!target) return undefined;
26
26
  this.db.bumpUserGenerationTo(userId, generation);
27
27
  return this.db.getOrCreateSession(target.sessionId, userId);
28
28
  }
29
29
 
30
- resumableSessions(userId: number, excludeSessionId: string): AgentSessionRow[] {
30
+ resumableSessions(userId: string, excludeSessionId: string): AgentSessionRow[] {
31
31
  return this.db
32
32
  .listSessions(userId, { archived: false })
33
33
  .filter((session) => session.sessionId !== excludeSessionId);
34
34
  }
35
35
 
36
- archive(userId: number, generation: number): AgentSessionRow | undefined {
36
+ archive(userId: string, generation: number): AgentSessionRow | undefined {
37
37
  const target = this.db.getSessionByGeneration(userId, generation);
38
38
  if (!target) return undefined;
39
39
  this.db.setSessionMeta(target.sessionId, { archived: true });
40
40
  return this.db.getSessionByGeneration(userId, generation);
41
41
  }
42
42
 
43
- reset(userId: number): void {
43
+ reset(userId: string): void {
44
44
  this.db.resetSession(this.sessionId(userId));
45
45
  }
46
46
 
@@ -48,11 +48,11 @@ export class SessionManager {
48
48
  return this.db.getMessagesAfter(session.sessionId, session.summaryUpTo);
49
49
  }
50
50
 
51
- append(userId: number, role: "user" | "assistant", content: string): number {
51
+ append(userId: string, role: "user" | "assistant", content: string): number {
52
52
  return this.db.appendMessage(this.sessionId(userId), role, content);
53
53
  }
54
54
 
55
- setEmotion(userId: number, emotion: string): void {
55
+ setEmotion(userId: string, emotion: string): void {
56
56
  this.db.setSessionMeta(this.sessionId(userId), { emotion });
57
57
  }
58
58
  }
package/db.ts CHANGED
@@ -18,6 +18,14 @@ function rowString(row: SqlRow, key: string, fallback = ""): string {
18
18
  return typeof value === "string" ? value : fallback;
19
19
  }
20
20
 
21
+ /** id 列可能是 TEXT(openid)也可能是 INTEGER 亲和存下的数字,统一转字符串 */
22
+ function rowId(row: SqlRow | null | undefined, key: string, fallback = ""): string {
23
+ const value = row?.[key];
24
+ if (value == null) return fallback;
25
+ const text = String(value);
26
+ return text.length > 0 ? text : fallback;
27
+ }
28
+
21
29
  export type SessionPlanStatus = "pending" | "in_progress" | "completed";
22
30
 
23
31
  export interface SessionPlanItem {
@@ -27,7 +35,7 @@ export interface SessionPlanItem {
27
35
 
28
36
  export interface AgentSessionRow {
29
37
  sessionId: string;
30
- userId: number;
38
+ userId: string;
31
39
  generation: number;
32
40
  emotion: string;
33
41
  summary: string;
@@ -51,7 +59,7 @@ export interface AgentMessageRow {
51
59
  export interface AgentRunRow {
52
60
  id: number;
53
61
  sessionId: string;
54
- userId: number;
62
+ userId: string;
55
63
  model: string;
56
64
  status: "ok" | "error";
57
65
  iterations: number;
@@ -93,7 +101,7 @@ export class AgentDatabase {
93
101
  this.db.run(`
94
102
  CREATE TABLE IF NOT EXISTS sessions (
95
103
  session_id TEXT PRIMARY KEY,
96
- user_id INTEGER NOT NULL,
104
+ user_id TEXT NOT NULL,
97
105
  generation INTEGER NOT NULL DEFAULT 0,
98
106
  emotion TEXT NOT NULL DEFAULT '',
99
107
  summary TEXT NOT NULL DEFAULT '',
@@ -106,7 +114,7 @@ export class AgentDatabase {
106
114
  updated_at INTEGER NOT NULL
107
115
  );
108
116
  CREATE TABLE IF NOT EXISTS user_state (
109
- user_id INTEGER PRIMARY KEY,
117
+ user_id TEXT PRIMARY KEY,
110
118
  generation INTEGER NOT NULL DEFAULT 0,
111
119
  updated_at INTEGER NOT NULL
112
120
  );
@@ -121,7 +129,7 @@ export class AgentDatabase {
121
129
  CREATE TABLE IF NOT EXISTS runs (
122
130
  id INTEGER PRIMARY KEY AUTOINCREMENT,
123
131
  session_id TEXT NOT NULL,
124
- user_id INTEGER NOT NULL,
132
+ user_id TEXT NOT NULL,
125
133
  model TEXT NOT NULL DEFAULT '',
126
134
  status TEXT NOT NULL DEFAULT 'ok',
127
135
  iterations INTEGER NOT NULL DEFAULT 0,
@@ -142,23 +150,55 @@ export class AgentDatabase {
142
150
  created_at INTEGER NOT NULL
143
151
  );
144
152
  `);
153
+ this.#widenLegacyIdColumns();
154
+ }
155
+
156
+ /**
157
+ * 旧库的 user_state.user_id 是 INTEGER PRIMARY KEY(rowid 别名),
158
+ * 写入 openid 会直接抛 SQLiteError: datatype mismatch,这里重建成 TEXT。
159
+ */
160
+ #widenLegacyIdColumns(): void {
161
+ const info = this.db
162
+ .query("PRAGMA table_info(user_state)")
163
+ .all() as Array<{ name?: string; type?: string }>;
164
+ const column = info.find((item) => item.name === "user_id");
165
+ if (!column || String(column.type ?? "").toUpperCase() === "TEXT") return;
166
+
167
+ this.db.run("BEGIN");
168
+ try {
169
+ this.db.run("ALTER TABLE user_state RENAME TO user_state_legacy");
170
+ this.db.run(`
171
+ CREATE TABLE user_state (
172
+ user_id TEXT PRIMARY KEY,
173
+ generation INTEGER NOT NULL DEFAULT 0,
174
+ updated_at INTEGER NOT NULL
175
+ );
176
+ INSERT INTO user_state (user_id, generation, updated_at)
177
+ SELECT CAST(user_id AS TEXT), generation, updated_at FROM user_state_legacy;
178
+ DROP TABLE user_state_legacy;
179
+ `);
180
+ this.db.run("COMMIT");
181
+ } catch (err) {
182
+ this.db.run("ROLLBACK");
183
+ throw err;
184
+ }
145
185
  }
146
186
 
147
- getUserGeneration(userId: number): number {
187
+ getUserGeneration(userId: string): number {
148
188
  const row = this.db
149
189
  .query("SELECT generation FROM user_state WHERE user_id = ?")
150
190
  .get(userId) as SqlRow | null;
151
191
  return rowNumber(row, "generation", 0);
152
192
  }
153
193
 
154
- maxGeneration(userId: number): number {
194
+ maxGeneration(userId: string): number {
155
195
  const row = this.db
156
196
  .query("SELECT MAX(generation) AS max FROM sessions WHERE user_id = ?")
157
197
  .get(userId) as SqlRow | null;
158
198
  return rowNumber(row, "max", -1);
159
199
  }
160
200
 
161
- bumpUserGenerationTo(userId: number, generation: number): void {
201
+ bumpUserGenerationTo(userId: string, generation: number): void {
162
202
  this.db.run(
163
203
  `INSERT INTO user_state (user_id, generation, updated_at) VALUES (?, ?, ?)
164
204
  ON CONFLICT(user_id) DO UPDATE SET generation = ?, updated_at = ?`,
@@ -166,7 +206,7 @@ export class AgentDatabase {
166
206
  );
167
207
  }
168
208
 
169
- getOrCreateSession(sessionId: string, userId: number): AgentSessionRow {
209
+ getOrCreateSession(sessionId: string, userId: string): AgentSessionRow {
170
210
  const now = Date.now();
171
211
  this.db.run(
172
212
  `INSERT INTO sessions (session_id, user_id, generation, created_at, updated_at)
@@ -181,7 +221,7 @@ export class AgentDatabase {
181
221
  }
182
222
 
183
223
  getSessionByGeneration(
184
- userId: number,
224
+ userId: string,
185
225
  generation: number,
186
226
  ): AgentSessionRow | undefined {
187
227
  const row = this.db
@@ -191,7 +231,7 @@ export class AgentDatabase {
191
231
  }
192
232
 
193
233
  listSessions(
194
- userId: number,
234
+ userId: string,
195
235
  options: { archived?: boolean } = {},
196
236
  ): AgentSessionRow[] {
197
237
  const rows = options.archived === undefined
@@ -314,7 +354,7 @@ export class AgentDatabase {
314
354
  }
315
355
 
316
356
  /** 删除该用户的全部会话(含归档)及其消息、运行记录与工具调用明细。 */
317
- clearUserSessions(userId: number): ClearSessionsResult {
357
+ clearUserSessions(userId: string): ClearSessionsResult {
318
358
  const ids = this.listSessions(userId).map((session) => session.sessionId);
319
359
  const result: ClearSessionsResult = {
320
360
  sessions: ids.length,
@@ -360,7 +400,7 @@ export class AgentDatabase {
360
400
  return rowNumber(row, "count", 0);
361
401
  }
362
402
 
363
- startRun(sessionId: string, userId: number, model: string): number {
403
+ startRun(sessionId: string, userId: string, model: string): number {
364
404
  const result = this.db
365
405
  .query(
366
406
  "INSERT INTO runs (session_id, user_id, model, started_at) VALUES (?, ?, ?, ?)",
@@ -408,7 +448,7 @@ export class AgentDatabase {
408
448
  );
409
449
  }
410
450
 
411
- getRunStats(userId: number): { runs: number; toolCalls: number } {
451
+ getRunStats(userId: string): { runs: number; toolCalls: number } {
412
452
  const runRow = this.db
413
453
  .query("SELECT COUNT(*) AS count FROM runs WHERE user_id = ?")
414
454
  .get(userId) as SqlRow | null;
@@ -449,7 +489,7 @@ function toSessionRow(row: SqlRow): AgentSessionRow {
449
489
  const sessionId = rowString(row, "session_id");
450
490
  return {
451
491
  sessionId,
452
- userId: rowNumber(row, "user_id", 0),
492
+ userId: rowId(row, "user_id"),
453
493
  generation: rowNumber(row, "generation", parseGeneration(sessionId)),
454
494
  emotion: rowString(row, "emotion"),
455
495
  summary: rowString(row, "summary"),
@@ -1,13 +1,19 @@
1
1
  import type { MessageEvent } from "mioku";
2
2
  import type { AgentHost } from "../types";
3
+ import { identityOf } from "../core/identity";
3
4
  import { runAgentTurn } from "../core/loop";
5
+ import { genericPlatform } from "../platforms/generic";
6
+ import type { AgentPlatform } from "../platforms/types";
4
7
 
5
- export function createMessageHandler(host: AgentHost) {
6
- return async (e: MessageEvent) => {
7
- if (e.message_type === "group") return;
8
- const userId = Number(e.user_id || e.sender?.user_id || 0);
9
- if (!userId || userId === Number(e.self_id || 0)) return;
10
- if (!(await host.isAllowed(userId))) return;
11
- await runAgentTurn(host, e);
12
- };
8
+ /** 各平台分支共用的入口:权限/自消息过滤后进入 agent 轮次 */
9
+ export async function handleAgentMessage(
10
+ host: AgentHost,
11
+ event: MessageEvent,
12
+ platform: AgentPlatform = genericPlatform,
13
+ ): Promise<void> {
14
+ if (event.message_type === "group") return;
15
+ const identity = identityOf(event);
16
+ if (!identity.userId || identity.userId === identity.botId) return;
17
+ if (!(await host.isAllowed(identity.userId))) return;
18
+ await runAgentTurn(host, event, platform);
13
19
  }
package/index.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import { definePlugin, getService, Services } from "mioku";
2
- import type { AIInstance, AIModelRole, MiokuContext } from "mioku";
2
+ import type { MiokuContext } from "mioku";
3
3
  import { initDatabase } from "./db";
4
4
  import { SessionManager } from "./core/session";
5
5
  import { EmotionManager } from "./core/emotion";
6
6
  import { ApprovalManager } from "./tools/approval";
7
7
  import { readChatSharedConfig } from "./core/chat-config";
8
- import { createMessageHandler } from "./handlers/message";
8
+ import { registerAgentPlatforms } from "./platforms";
9
9
  import { registerCommands } from "./commands";
10
10
  import { mergeAgentConfig } from "./utils/config";
11
11
  import { workspaceRootFor } from "./tools/perm";
12
+ import {
13
+ prepareModelOverride,
14
+ resolveAgentModel,
15
+ type ModelOverride,
16
+ } from "./core/model";
12
17
  import { BASE_CONFIG } from "./configs/base";
13
18
  import { SETTINGS_CONFIG } from "./configs/settings";
14
19
  import type {
@@ -18,13 +23,13 @@ import type {
18
23
  ResolvedModel,
19
24
  } from "./types";
20
25
 
21
- function normalizeIdList(input: unknown): number[] {
26
+ function normalizeIdList(input: unknown): string[] {
22
27
  if (!Array.isArray(input)) return [];
23
28
  return Array.from(
24
29
  new Set(
25
30
  input
26
- .map((item) => Math.floor(Number(item)))
27
- .filter((id) => Number.isFinite(id) && id > 0),
31
+ .map((item) => String(item ?? "").trim())
32
+ .filter((id) => id.length > 0),
28
33
  ),
29
34
  );
30
35
  }
@@ -56,12 +61,33 @@ export default definePlugin({
56
61
  );
57
62
  if (!Array.isArray(cachedBase.access.users)) cachedBase.access.users = [];
58
63
 
64
+ let modelOverride: ModelOverride | undefined;
65
+
66
+ const refreshOverride = async (): Promise<void> => {
67
+ const full = String(cachedBase.model ?? "").trim();
68
+ if (!full) {
69
+ modelOverride = undefined;
70
+ return;
71
+ }
72
+ const alive = modelOverride
73
+ ? Boolean(aiService.get?.(modelOverride.instanceName))
74
+ : false;
75
+ if (modelOverride?.key === full && alive) return;
76
+ modelOverride = await prepareModelOverride(aiService, full, (message) =>
77
+ ctx.logger.warn(`[agent] ${message}`),
78
+ );
79
+ ctx.logger.info(
80
+ `[agent] 覆盖模型 ${full} -> ${modelOverride ? `实例 ${modelOverride.instanceName}` : "未绑定,退回主模型"}`,
81
+ );
82
+ };
83
+
59
84
  const refreshBase = async () => {
60
85
  cachedBase = mergeAgentConfig(
61
86
  BASE_CONFIG,
62
87
  (await configService?.getConfig("agent", "base")) ?? {},
63
88
  );
64
89
  if (!Array.isArray(cachedBase.access.users)) cachedBase.access.users = [];
90
+ await refreshOverride();
65
91
  };
66
92
  const refreshSettings = async () => {
67
93
  cachedSettings = mergeAgentConfig(
@@ -69,6 +95,7 @@ export default definePlugin({
69
95
  (await configService?.getConfig("agent", "settings")) ?? {},
70
96
  );
71
97
  };
98
+ await refreshOverride();
72
99
  if (configService) {
73
100
  configService.onConfigChange("agent", "base", () =>
74
101
  refreshBase().catch((err) =>
@@ -89,75 +116,12 @@ export default definePlugin({
89
116
  sessions.setEmotion(userId, emotion),
90
117
  );
91
118
 
92
- const resolveModel = (): ResolvedModel | null => {
93
- const getByRole = (role: AIModelRole) =>
94
- aiService.getInstanceByRole?.(role) ?? aiService.get?.(role);
95
- const main = getByRole("main") ?? aiService.getDefault?.();
96
- if (!main) return null;
97
- const working = getByRole("working") ?? main;
98
- const vision = getByRole("vision") ?? working;
99
- const bindings = aiService.getRoleBindings?.() ?? {
100
- main: undefined,
101
- working: undefined,
102
- vision: undefined,
103
- };
104
- const models = aiService.listModels?.() ?? [];
105
- const instanceName = (instance: AIInstance): string | undefined => {
106
- const name = (instance as { name?: unknown }).name;
107
- return typeof name === "string" ? name : undefined;
108
- };
109
- const pickModel = (full: string | undefined, instance: AIInstance) => {
110
- if (full && full.includes("/")) {
111
- return full.split("/").slice(1).join("/");
112
- }
113
- const name = instanceName(instance);
114
- const info = aiService
115
- .listInstances?.()
116
- ?.find((item) => item.role === name || item.name === name);
117
- return info?.modelId ?? "";
118
- };
119
-
120
- const overrideFullId = String(cachedBase.model ?? "").trim();
121
- const overrideDesc = overrideFullId
122
- ? models.find((item) => item.id === overrideFullId)
123
- : undefined;
124
- let instance = main;
125
- let model = pickModel(bindings.main, main) || "";
126
- if (overrideDesc) {
127
- model = overrideDesc.modelId;
128
- const info = aiService
129
- .listInstances?.()
130
- ?.find((item) => item.providerId === overrideDesc.providerId);
131
- const candidate = info ? aiService.get?.(info.name) : undefined;
132
- if (candidate) instance = candidate;
133
- } else if (overrideFullId) {
134
- model = overrideFullId.includes("/")
135
- ? overrideFullId.split("/").slice(1).join("/")
136
- : overrideFullId;
137
- }
138
-
139
- const workingModel = pickModel(bindings.working, working) || model;
140
- const visionModel = pickModel(bindings.vision, vision) || workingModel;
141
- const visionDesc =
142
- models.find((item) => item.id === bindings.vision) ||
143
- models.find((item) => item.modelId === visionModel);
144
- const isMultimodal =
145
- visionDesc?.capabilities?.includes("vision") ?? Boolean(visionModel);
146
- const mainDesc =
147
- overrideDesc ||
148
- models.find((item) => item.id === bindings.main) ||
149
- models.find((item) => item.modelId === model);
150
- return {
151
- instance,
152
- model,
153
- working,
154
- workingModel,
155
- vision,
156
- visionModel,
157
- isMultimodal,
158
- contextWindow: mainDesc?.contextWindow ?? 0,
159
- };
160
- };
119
+ const resolveModel = (): ResolvedModel | null =>
120
+ resolveAgentModel({
121
+ aiService,
122
+ overrideFullId: String(cachedBase.model ?? "").trim(),
123
+ override: modelOverride,
124
+ });
161
125
 
162
126
  const host: AgentHost = {
163
127
  ctx,
@@ -172,18 +136,18 @@ export default definePlugin({
172
136
  getSettings: () => cachedSettings,
173
137
  getChatShared: () => readChatSharedConfig(configService),
174
138
  resolveModel,
175
- workspaceRoot: (userId: number) =>
139
+ workspaceRoot: (userId: string) =>
176
140
  workspaceRootFor(cachedBase.workspaceDir, userId),
177
- isAllowed: async (userId: number) => {
178
- const owners = (ctx.config.owners ?? []).map(Number);
179
- if (owners.includes(userId)) return true;
180
- if (
181
- cachedBase.access.allowAdmins &&
182
- (ctx.config.admins ?? []).map(Number).includes(userId)
183
- ) {
141
+ isAllowed: async (userId: string) => {
142
+ const target = String(userId ?? "").trim();
143
+ if (!target) return false;
144
+ const matches = (list: readonly unknown[] | undefined): boolean =>
145
+ (list ?? []).some((item) => String(item ?? "").trim() === target);
146
+ if (matches(ctx.config.owners)) return true;
147
+ if (cachedBase.access.allowAdmins && matches(ctx.config.admins)) {
184
148
  return true;
185
149
  }
186
- return normalizeIdList(cachedBase.access.users).includes(userId);
150
+ return normalizeIdList(cachedBase.access.users).includes(target);
187
151
  },
188
152
  updateBase: async (patch) => {
189
153
  if (configService) {
@@ -196,7 +160,7 @@ export default definePlugin({
196
160
  };
197
161
 
198
162
  registerCommands(host);
199
- ctx.handle("message", createMessageHandler(host));
163
+ const disposePlatforms = registerAgentPlatforms(ctx, host);
200
164
 
201
165
  const resolved = resolveModel();
202
166
  ctx.logger.info(
@@ -204,6 +168,7 @@ export default definePlugin({
204
168
  );
205
169
 
206
170
  return () => {
171
+ disposePlatforms();
207
172
  approvals.dispose();
208
173
  db.close();
209
174
  ctx.logger.info("agent 插件已卸载");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-agent",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "超级用户 Agent 插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -0,0 +1,11 @@
1
+ import type { AgentPlatform } from "./types";
2
+ import { EMPTY_FILE_LOOKUP } from "./types";
3
+
4
+ /** 未单独登记的适配器(如 stdin、未来的新平台)走的兜底分支 */
5
+ export const genericPlatform: AgentPlatform = {
6
+ adapter: "",
7
+ route: "message",
8
+ async resolveFile() {
9
+ return EMPTY_FILE_LOOKUP;
10
+ },
11
+ };
@@ -0,0 +1,47 @@
1
+ import type { AgentPlatform, PlatformFileLookup } from "./types";
2
+ import { EMPTY_FILE_LOOKUP, mergeFileLookup } from "./types";
3
+
4
+ interface IcqqFileHolder {
5
+ getFileUrl?(fileId: string): Promise<string>;
6
+ getFileInfo?(fileId: string): Promise<{ url?: string; name?: string } | null>;
7
+ }
8
+
9
+ interface IcqqFileClient {
10
+ pickGroup?(groupId: string): IcqqFileHolder;
11
+ pickFriend?(userId: string): IcqqFileHolder;
12
+ }
13
+
14
+ /** icqq:pickGroup / pickFriend 上的 getFileInfo / getFileUrl */
15
+ export const icqqPlatform: AgentPlatform = {
16
+ adapter: "icqq",
17
+ route: "icqq:message",
18
+ async resolveFile(bot, ref): Promise<PlatformFileLookup> {
19
+ const client = bot.as<IcqqFileClient>();
20
+ const holder = ref.groupId
21
+ ? client.pickGroup?.(ref.groupId)
22
+ : ref.userId
23
+ ? client.pickFriend?.(ref.userId)
24
+ : undefined;
25
+ if (!holder) return EMPTY_FILE_LOOKUP;
26
+
27
+ const found: PlatformFileLookup = { sources: [], names: [] };
28
+ if (holder.getFileInfo) {
29
+ try {
30
+ mergeFileLookup(found, await holder.getFileInfo(ref.fileId));
31
+ } catch {
32
+ // 文件不存在或无权限时继续尝试 getFileUrl
33
+ }
34
+ }
35
+ if (found.sources.length === 0 && holder.getFileUrl) {
36
+ try {
37
+ const url = await holder.getFileUrl(ref.fileId);
38
+ if (typeof url === "string" && url.trim()) {
39
+ found.sources.push(url.trim());
40
+ }
41
+ } catch {
42
+ // 调用方会回退到消息段里自带的 url/file
43
+ }
44
+ }
45
+ return found;
46
+ },
47
+ };
@@ -0,0 +1,49 @@
1
+ import type { MessageEvent, MiokuContext } from "mioku";
2
+ import type { AgentHost } from "../types";
3
+ import { handleAgentMessage } from "../handlers/message";
4
+ import { genericPlatform } from "./generic";
5
+ import { icqqPlatform } from "./icqq";
6
+ import { onebotv11Platform } from "./onebotv11";
7
+ import { qqOfficialPlatform } from "./qq-official";
8
+ import type { AgentPlatform } from "./types";
9
+
10
+ /** 每个平台一个分支文件,新增平台在这里登记即可 */
11
+ export const AGENT_PLATFORMS: readonly AgentPlatform[] = [
12
+ onebotv11Platform,
13
+ icqqPlatform,
14
+ qqOfficialPlatform,
15
+ ];
16
+
17
+ /**
18
+ * 按适配器前缀路由注册平台分支:
19
+ * `onebotv11:message` / `icqq:message` / `qq-official:message` 各管自己的差异,
20
+ * 未登记的适配器由通用 `message` 分支兜底。
21
+ */
22
+ export function registerAgentPlatforms(
23
+ ctx: MiokuContext,
24
+ host: AgentHost,
25
+ ): () => void {
26
+ const disposers: Array<() => void> = [];
27
+
28
+ for (const platform of AGENT_PLATFORMS) {
29
+ disposers.push(
30
+ ctx.handle(platform.route, (event) =>
31
+ handleAgentMessage(host, event as unknown as MessageEvent, platform),
32
+ ),
33
+ );
34
+ }
35
+
36
+ const owned = new Set(AGENT_PLATFORMS.map((platform) => platform.adapter));
37
+ disposers.push(
38
+ ctx.handle("message", (event) => {
39
+ const messageEvent = event as unknown as MessageEvent;
40
+ const adapter = String(messageEvent?.bot?.adapter ?? "");
41
+ if (owned.has(adapter)) return;
42
+ return handleAgentMessage(host, messageEvent, genericPlatform);
43
+ }),
44
+ );
45
+
46
+ return () => {
47
+ for (const dispose of disposers) dispose();
48
+ };
49
+ }
@@ -0,0 +1,40 @@
1
+ import type { AgentPlatform, PlatformFileLookup } from "./types";
2
+ import { EMPTY_FILE_LOOKUP, mergeFileLookup } from "./types";
3
+
4
+ /** onebotv11:get_file / get_group_file_url / get_private_file_url */
5
+ export const onebotv11Platform: AgentPlatform = {
6
+ adapter: "onebotv11",
7
+ route: "onebotv11:message",
8
+ async resolveFile(bot, ref): Promise<PlatformFileLookup> {
9
+ const attempts: Array<[string, Record<string, unknown>]> = [
10
+ ["get_file", { file_id: ref.fileId }],
11
+ ];
12
+ if (ref.groupId) {
13
+ attempts.push([
14
+ "get_group_file_url",
15
+ { group_id: ref.groupId, file_id: ref.fileId },
16
+ ]);
17
+ }
18
+ if (ref.userId) {
19
+ attempts.push([
20
+ "get_private_file_url",
21
+ { user_id: ref.userId, file_id: ref.fileId },
22
+ ]);
23
+ }
24
+ const found: PlatformFileLookup = { sources: [], names: [] };
25
+ for (const [action, params] of attempts) {
26
+ try {
27
+ mergeFileLookup(
28
+ found,
29
+ await bot.sendApi<Record<string, unknown>>(action, params),
30
+ );
31
+ } catch {
32
+ // 协议端不支持该 action 时继续尝试下一个
33
+ }
34
+ if (found.sources.length > 0) break;
35
+ }
36
+ return found;
37
+ },
38
+ };
39
+
40
+ export { EMPTY_FILE_LOOKUP };
@@ -0,0 +1,14 @@
1
+ import type { AgentPlatform } from "./types";
2
+ import { EMPTY_FILE_LOOKUP } from "./types";
3
+
4
+ /**
5
+ * QQ 官方通道:附件事件自带公网 URL(见 adapter 的 segments.ts),
6
+ * 官方没有 file_id 换下载地址的公开接口,所以这里不做事。
7
+ */
8
+ export const qqOfficialPlatform: AgentPlatform = {
9
+ adapter: "qq-official",
10
+ route: "qq-official:message",
11
+ async resolveFile() {
12
+ return EMPTY_FILE_LOOKUP;
13
+ },
14
+ };