lightclawbot 1.2.6 → 1.2.8

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.
Files changed (102) hide show
  1. package/dist/src/config.js +30 -3
  2. package/dist/src/gateway.js +58 -12
  3. package/dist/src/group/constants/index.js +20 -0
  4. package/dist/src/group/inbound/index.js +254 -0
  5. package/dist/src/group/index.js +15 -0
  6. package/dist/src/group/orchestrator/execution/agent-runner.js +299 -0
  7. package/dist/src/group/orchestrator/execution/index.js +7 -0
  8. package/dist/src/group/orchestrator/execution/prompt-builder.js +288 -0
  9. package/dist/src/group/orchestrator/execution/soul-resolver.js +38 -0
  10. package/dist/src/group/orchestrator/execution/types.js +7 -0
  11. package/dist/src/group/orchestrator/index.js +14 -0
  12. package/dist/src/group/orchestrator/lifecycle/conversation-state.js +162 -0
  13. package/dist/src/group/orchestrator/lifecycle/index.js +7 -0
  14. package/dist/src/group/orchestrator/lifecycle/ledger-writer.js +96 -0
  15. package/dist/src/group/orchestrator/lifecycle/run-registry.js +174 -0
  16. package/dist/src/group/orchestrator/orchestrator.js +265 -0
  17. package/dist/src/group/orchestrator/planning/index.js +13 -0
  18. package/dist/src/group/orchestrator/planning/plan-validator.js +233 -0
  19. package/dist/src/group/orchestrator/planning/planning-parser.js +207 -0
  20. package/dist/src/group/orchestrator/planning/subtask-executor.js +345 -0
  21. package/dist/src/group/orchestrator/planning/summarizer-runner.js +224 -0
  22. package/dist/src/group/orchestrator/routes/index.js +9 -0
  23. package/dist/src/group/orchestrator/routes/leader-dispatch.js +229 -0
  24. package/dist/src/group/orchestrator/routes/leader-orchestration-route.js +179 -0
  25. package/dist/src/group/orchestrator/routes/leader-planning.js +92 -0
  26. package/dist/src/group/orchestrator/routes/leader-self-answer.js +223 -0
  27. package/dist/src/group/orchestrator/routes/mention-concurrent-route.js +226 -0
  28. package/dist/src/group/orchestrator/routes/route-helpers.js +186 -0
  29. package/dist/src/group/orchestrator/routes/types.js +8 -0
  30. package/dist/src/group/services/group-cleanup-service.js +183 -0
  31. package/dist/src/group/services/group-creation-service.js +122 -0
  32. package/dist/src/group/services/group-deletion-service.js +111 -0
  33. package/dist/src/group/services/group-history-service.js +73 -0
  34. package/dist/src/group/services/group-member-service.js +169 -0
  35. package/dist/src/group/services/group-query-service.js +133 -0
  36. package/dist/src/group/services/group-update-service.js +144 -0
  37. package/dist/src/group/services/index.js +20 -0
  38. package/dist/src/group/storage/concurrency-manager.js +119 -0
  39. package/dist/src/group/storage/group-storage-core.js +227 -0
  40. package/dist/src/group/storage/index.js +12 -0
  41. package/dist/src/group/storage/message-reader.js +213 -0
  42. package/dist/src/group/storage/message-writer.js +229 -0
  43. package/dist/src/group/storage/slice-manager.js +165 -0
  44. package/dist/src/group/types/common.js +5 -0
  45. package/dist/src/group/types/index.js +5 -0
  46. package/dist/src/group/types/message.js +5 -0
  47. package/dist/src/group/types/orchestrator.js +5 -0
  48. package/dist/src/group/types/storage.js +5 -0
  49. package/dist/src/group/utils/id-generator.js +15 -0
  50. package/dist/src/group/utils/index.js +12 -0
  51. package/dist/src/group/utils/mime.js +36 -0
  52. package/dist/src/group/utils/normalize.js +32 -0
  53. package/dist/src/group/utils/run-helpers.js +36 -0
  54. package/dist/src/history/session-reader.js +8 -2
  55. package/dist/src/outbound.js +12 -19
  56. package/dist/src/shared.js +4 -3
  57. package/dist/src/socket/events/agents-request.js +147 -0
  58. package/dist/src/socket/events/chat-request.js +67 -0
  59. package/dist/src/socket/events/file-download.js +121 -0
  60. package/dist/src/socket/events/group-abort.js +59 -0
  61. package/dist/src/socket/events/group-history.js +59 -0
  62. package/dist/src/socket/events/group-member.js +83 -0
  63. package/dist/src/socket/events/group-request.js +91 -0
  64. package/dist/src/socket/events/history-request.js +95 -0
  65. package/dist/src/socket/events/index.js +39 -0
  66. package/dist/src/socket/events/message-private.js +82 -0
  67. package/dist/src/socket/handlers.js +53 -568
  68. package/dist/src/socket/native-socket.js +21 -20
  69. package/dist/src/socket/registry.js +6 -3
  70. package/dist/src/socket/reliable-emitter.js +16 -13
  71. package/dist/src/socket/service/chat-common.js +36 -0
  72. package/dist/src/socket/service/chat-create.js +75 -0
  73. package/dist/src/socket/service/chat-delete.js +94 -0
  74. package/dist/src/socket/service/chat-list.js +82 -0
  75. package/dist/src/socket/service/chat-update.js +83 -0
  76. package/dist/src/socket/service/group-abort.js +104 -0
  77. package/dist/src/socket/service/group-history.js +140 -0
  78. package/dist/src/socket/service/group-member.js +209 -0
  79. package/dist/src/socket/service/group.js +233 -0
  80. package/dist/src/socket/service/history.js +102 -0
  81. package/dist/src/socket/service/index.js +14 -0
  82. package/dist/src/socket/types/index.js +7 -0
  83. package/dist/src/socket/types/request.js +8 -0
  84. package/dist/src/socket/types/service.js +8 -0
  85. package/dist/src/socket/utils/agent-soul.js +95 -0
  86. package/dist/src/socket/utils/index.js +8 -0
  87. package/dist/src/socket/utils/message.js +83 -0
  88. package/dist/src/socket/utils/validate.js +42 -0
  89. package/dist/src/streaming/index.js +1 -0
  90. package/dist/src/streaming/stream-reply-sink.js +367 -20
  91. package/dist/src/streaming/thinking-formatter.js +325 -0
  92. package/dist/src/streaming/types.js +20 -1
  93. package/dist/src/{download-tool.js → tools/download-tool.js} +41 -35
  94. package/dist/src/tools/group-history-tool.js +172 -0
  95. package/dist/src/{upload-tool.js → tools/upload-tool.js} +2 -2
  96. package/dist/src/tools.js +4 -3
  97. package/dist/src/utils/index.js +1 -0
  98. package/dist/src/utils/logger.js +38 -0
  99. package/openclaw.plugin.json +2 -1
  100. package/package.json +1 -1
  101. package/dist/src/socket/agent-soul.js +0 -41
  102. package/dist/src/socket/chat.js +0 -257
@@ -55,9 +55,36 @@ let globalApiKeyMap = new Map();
55
55
  let globalDefaultApiKey = '';
56
56
  /** sessionKey → apiKey 直接映射(inbound 处理时写入,tool 执行时读取) */
57
57
  const sessionKeyToApiKey = new Map();
58
- /** 设置 apiKeyMap(gateway 启动时调用) */
59
- export function setApiKeyMap(map, defaultApiKey) {
60
- globalApiKeyMap = map;
58
+ /** 合并单条 account 的 apiKey 到全局映射(gateway 启动时调用) */
59
+ export function addApiKeyToMap(accountId, apiKey) {
60
+ globalApiKeyMap.set(accountId, apiKey);
61
+ // 仅在尚未设置 defaultApiKey 时设置(取第一个 account 的 key 作为默认)
62
+ if (!globalDefaultApiKey) {
63
+ globalDefaultApiKey = apiKey;
64
+ }
65
+ }
66
+ /**
67
+ * 一次性构建全局 apiKeyMap(插件初始化时调用)。
68
+ *
69
+ * 遍历配置中所有 accounts,将 accountId→apiKey 映射一次性写入全局 Map,
70
+ * 避免每个 account 启动 gateway 时覆盖其他 account 的映射。
71
+ *
72
+ * @param cfg - OpenClaw 全局配置对象
73
+ */
74
+ export function buildGlobalApiKeyMap(cfg) {
75
+ const channels = cfg?.channels;
76
+ const channelConfig = channels?.[CHANNEL_KEY];
77
+ if (!channelConfig?.accounts)
78
+ return;
79
+ const accounts = channelConfig.accounts;
80
+ let defaultApiKey = globalDefaultApiKey; // 保留已有的 default
81
+ for (const [accountId, accountConfig] of Object.entries(accounts)) {
82
+ if (accountConfig?.apiKey) {
83
+ globalApiKeyMap.set(accountId, accountConfig.apiKey);
84
+ if (!defaultApiKey)
85
+ defaultApiKey = accountConfig.apiKey;
86
+ }
87
+ }
61
88
  globalDefaultApiKey = defaultApiKey;
62
89
  }
63
90
  /** 记录 sessionKey → apiKey 映射(inbound 处理消息时调用) */
@@ -13,12 +13,13 @@
13
13
  * 媒体文件处理 → media.ts
14
14
  */
15
15
  import { NativeSocketClient } from './socket/native-socket.js';
16
- import { CHANNEL_KEY, WS_URL, API_BASE_URL, SOCKET_PATH, API_PATH_USER_CURRENT, EVENT_MESSAGE_PRIVATE, HEALTH_HEARTBEAT_INTERVAL, setApiKeyMap, } from './config.js';
16
+ import { CHANNEL_KEY, WS_URL, API_BASE_URL, SOCKET_PATH, API_PATH_USER_CURRENT, EVENT_MESSAGE_PRIVATE, HEALTH_HEARTBEAT_INTERVAL, addApiKeyToMap, buildGlobalApiKeyMap, } from './config.js';
17
17
  import { generateMsgId } from './dedup.js';
18
18
  import { createInboundHandler } from './inbound.js';
19
19
  import { bindSocketHandlers, registerSocket, unregisterSocket, flushPendingMessages } from './socket/index.js';
20
20
  import { ReliableEmitter } from './socket/reliable-emitter.js';
21
21
  import { buildAuthHeaders } from './utils/index.js';
22
+ import { GroupStorageCore, Orchestrator, createGroupInboundHandler, } from './group/index.js';
22
23
  // ============================================================
23
24
  // Gateway 实例追踪(防止同一 accountId 创建多个连接)
24
25
  // ============================================================
@@ -94,7 +95,7 @@ async function resolveBotClientId(apiKey, log) {
94
95
  * @param ctx - Gateway 上下文,包含账户配置、abort 信号和生命周期回调
95
96
  */
96
97
  export async function startGateway(ctx) {
97
- const { account, abortSignal, onReady, onDisconnect, onEvent, log } = ctx;
98
+ const { account, abortSignal, cfg, onReady, onDisconnect, onEvent, log } = ctx;
98
99
  const prefix = `[${CHANNEL_KEY}:${account.accountId}]`;
99
100
  // 判断是否存在有效的apikey
100
101
  if (!account.apiKey) {
@@ -112,11 +113,13 @@ export async function startGateway(ctx) {
112
113
  // 新配置格式:accountId 即为 uin,apiKey 与 uin 一一对应,直接构建映射表
113
114
  log?.info(`${prefix} Resolving botClientId for account ${account.accountId}...`);
114
115
  const { botClientId, ticket } = await resolveBotClientId(account.apiKey, log);
115
- // 构建 uin→apiKey 映射表(新格式下每个 account 只有一条记录)
116
- const apiKeyMap = new Map([[account.accountId, account.apiKey]]);
116
+ // 一次性构建全局 uin→apiKey 映射(遍历所有 accounts,幂等操作)
117
+ // 解决多账号场景下每个 gateway 启动时覆盖全局 Map 的问题
118
+ buildGlobalApiKeyMap(cfg);
119
+ // 将当前 account 的 uin→apiKey 映射合并到全局 config 模块
120
+ // 确保 gateway 重启/reconnect 时映射仍然完整
121
+ addApiKeyToMap(account.accountId, account.apiKey);
117
122
  log?.info(`${prefix} Bot clientId: ${botClientId}, uin=${account.accountId} → apiKey=***${account.apiKey.slice(-4)}`);
118
- // 将映射表写入全局 config 模块,供 inbound 处理时按 uin 查找对应 apiKey
119
- setApiKeyMap(apiKeyMap, account.apiKey);
120
123
  /** Gateway 是否已被 abort(用于终止消息处理循环) */
121
124
  let isAborted = false;
122
125
  /** 当前活跃的 WebSocket 实例(断线时为 null) */
@@ -125,7 +128,7 @@ export async function startGateway(ctx) {
125
128
  let healthHeartbeatTimer = null;
126
129
  // ---- 可靠发送器(所有出站 emit 都通过此实例) ----
127
130
  // ReliableEmitter 负责:断线时缓冲消息、重连后 flush、ACK 超时重试
128
- const reliableEmitter = new ReliableEmitter(() => currentSocket, log);
131
+ const reliableEmitter = new ReliableEmitter(() => currentSocket);
129
132
  // ---- WebSocket 发送抽象 ----
130
133
  /**
131
134
  * 底层 emit:将 PrivateMessageData 通过 ReliableEmitter 发送给客户端。
@@ -196,10 +199,24 @@ export async function startGateway(ctx) {
196
199
  };
197
200
  /** SocketEmitter 抽象:将 emit / sendReply / sendFiles 和 botClientId 打包传给 inbound 处理器 */
198
201
  const emitter = { emit, sendReply, sendFiles, botClientId };
202
+ // ---- 群聊调度器装配(路径 A 全链路服务于 chatKind === 'group' 的消息) ----
203
+ // 多 Gateway 进程共享同一份 ~/.openclaw/groups 目录,但每 account 一份 Orchestrator
204
+ // (RunRegistry 是进程内存态,跨 account 互不干扰)。
205
+ const groupStorage = new GroupStorageCore();
206
+ const orchestrator = new Orchestrator({
207
+ accountId: account.accountId,
208
+ storage: groupStorage,
209
+ emitter,
210
+ });
211
+ const groupInboundHandler = createGroupInboundHandler({
212
+ emitter,
213
+ storage: groupStorage,
214
+ orchestrator,
215
+ });
199
216
  // ---- 入站消息处理器 ----
200
217
  // createInboundHandler 返回一个异步函数,负责处理单条用户消息(文件处理、路由、AI 分发、回复)
201
218
  const handleInboundMessage = createInboundHandler(account, emitter, log);
202
- // ---- 消息处理(fire-and-forget) ----
219
+ // ---- 私聊消息处理(fire-and-forget) ----
203
220
  // 不做串行队列:并发/abort 由 openclaw 的 session 写锁 + followup queue 负责。
204
221
  // 这里仅兜底 inbound 自身未捕获的异常。
205
222
  const handleMessage = (msg) => {
@@ -231,6 +248,35 @@ export async function startGateway(ctx) {
231
248
  }
232
249
  })();
233
250
  };
251
+ // ---- 群聊消息处理 ----
252
+ const handleGroupMessage = (data) => {
253
+ if (isAborted)
254
+ return;
255
+ void (async () => {
256
+ try {
257
+ await groupInboundHandler(data);
258
+ }
259
+ catch (err) {
260
+ log?.error(`[${CHANNEL_KEY}] Group handler error: ${err}`);
261
+ try {
262
+ const groupId = data.extra?.groupId;
263
+ emitter.emit({
264
+ msgId: generateMsgId(),
265
+ from: emitter.botClientId,
266
+ to: data.from,
267
+ content: '群消息处理异常,请稍后重试。',
268
+ timestamp: Date.now(),
269
+ replyToMsgId: data.msgId,
270
+ chatKind: 'group',
271
+ extra: { groupId: typeof groupId === 'string' ? groupId : undefined },
272
+ });
273
+ }
274
+ catch (notifyErr) {
275
+ log?.error(`[${CHANNEL_KEY}] Failed to notify user about group handler error: ${notifyErr}`);
276
+ }
277
+ }
278
+ })();
279
+ };
234
280
  // ---- 生命周期 ----
235
281
  /**
236
282
  * 销毁当前 Gateway 实例,释放所有资源:
@@ -268,8 +314,6 @@ export async function startGateway(ctx) {
268
314
  const socket = new NativeSocketClient(WS_URL, {
269
315
  // 认证:ticket 作为 URL query 参数,替代 Authorization header
270
316
  path: `${SOCKET_PATH}${ticketQuery}&enableMultiLogin=false`,
271
- // 注入日志对象,便于 NativeSocketClient 内部打印连接/重连/错误等诊断信息
272
- log,
273
317
  logPrefix: `[${CHANNEL_KEY}:${account.accountId}:NativeSocket]`,
274
318
  });
275
319
  currentSocket = socket;
@@ -291,7 +335,7 @@ export async function startGateway(ctx) {
291
335
  // 恢复可靠发送器(断线时已 pause,重连后需要 resume 才能继续发送)
292
336
  reliableEmitter.resume();
293
337
  // 重连后 flush 断线期间缓冲的 outbound 消息(主动发送场景)
294
- const { sent, failed } = flushPendingMessages(account.accountId, log);
338
+ const { sent, failed } = flushPendingMessages(account.accountId);
295
339
  if (sent > 0 || failed > 0) {
296
340
  log?.info(`[${CHANNEL_KEY}] Flushed buffered outbound messages: sent=${sent}, failed=${failed}`);
297
341
  }
@@ -311,10 +355,12 @@ export async function startGateway(ctx) {
311
355
  bindSocketHandlers(socket, {
312
356
  account,
313
357
  botClientId,
314
- log,
315
358
  handleMessage,
316
359
  onEvent,
317
360
  reliableEmitter,
361
+ handleGroupMessage,
362
+ orchestrator,
363
+ groupStorage,
318
364
  });
319
365
  /**
320
366
  * 连接错误事件处理。
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @file 群聊事件常量定义
3
+ * @description 定义群聊模块中所有的事件类型和对应的消息通道名称
4
+ */
5
+ /** 群组基础操作请求事件 */
6
+ export const EVENT_GROUP_REQUEST = 'group:request';
7
+ /** 群组基础操作响应事件 */
8
+ export const EVENT_GROUP_RESPONSE = 'group:response';
9
+ /** 群组成员管理请求事件 */
10
+ export const EVENT_GROUP_MEMBER_REQUEST = 'group:member:request';
11
+ /** 群组成员管理响应事件 */
12
+ export const EVENT_GROUP_MEMBER_RESPONSE = 'group:member:response';
13
+ /** 群组历史消息请求事件 */
14
+ export const EVENT_GROUP_HISTORY_REQUEST = 'group:history:request';
15
+ /** 群组历史消息响应事件 */
16
+ export const EVENT_GROUP_HISTORY_RESPONSE = 'group:history:response';
17
+ /** 群组操作中止请求事件 */
18
+ export const EVENT_GROUP_ABORT_REQUEST = 'group:abort:request';
19
+ /** 群组操作中止响应事件 */
20
+ export const EVENT_GROUP_ABORT_RESPONSE = 'group:abort:response';
@@ -0,0 +1,254 @@
1
+ /**
2
+ * @file 群聊消息入站处理器
3
+ * @description
4
+ * 接收 Socket 私信消息,基于 extra.groupId 路由为群聊消息,
5
+ * 完成权限校验后写入账本并交由 Orchestrator 调度。
6
+ * 所有依赖通过 {@link GroupInboundDeps} 注入,错误通过 {@link sendError} 回发。
7
+ */
8
+ import { generateMsgId } from '../../dedup.js';
9
+ import { CHANNEL_KEY, MEDIA_MAX_BYTES, LOCALFILE_SCHEME, resolveEffectiveApiKey } from '../../config.js';
10
+ import { normalizeMentions, guessMimeType } from '../utils/index.js';
11
+ import { getModuleLogger } from '../../utils/logger.js';
12
+ import { parseDataUrl, formatFileSize } from '../../media.js';
13
+ import { downloadFileFromServer, uploadFileToServer, getFileDownloadUrl } from '../../file-storage.js';
14
+ import { getLightclawRuntime } from '../../runtime.js';
15
+ /**
16
+ * 工厂函数:基于注入的依赖创建一个群聊入站处理器。
17
+ *
18
+ * @param deps 依赖注入对象,参见 {@link GroupInboundDeps}
19
+ * @returns 一个可直接挂载到 Socket 私信事件上的异步处理函数
20
+ */
21
+ export function createGroupInboundHandler(deps) {
22
+ const { emitter, storage, orchestrator } = deps;
23
+ const logger = getModuleLogger('group.inbound');
24
+ /**
25
+ * 以群聊系统消息的形式向指定用户回发错误提示。
26
+ * 内部捕获 emit 异常,确保错误回发本身不会再抛出影响主流程。
27
+ *
28
+ * @param userId 目标用户 ID
29
+ * @param message 错误文案
30
+ * @param extra 附加 extra 字段(通常包含 groupId)
31
+ * @param replyToMsgId 关联的原始消息 ID(用于在 UI 上展示回复关系)
32
+ */
33
+ const sendError = (userId, message, extra, replyToMsgId) => {
34
+ // 错误回发统一带上 streamStatus: 'failed',便于前端流式状态机识别终态
35
+ const finalExtra = { ...extra, streamStatus: 'failed' };
36
+ try {
37
+ const ok = emitter.emit({
38
+ msgId: generateMsgId(),
39
+ from: emitter.botClientId,
40
+ to: userId,
41
+ content: message,
42
+ timestamp: Date.now(),
43
+ replyToMsgId,
44
+ chatKind: 'group',
45
+ extra: finalExtra,
46
+ });
47
+ if (!ok) {
48
+ logger.warn(`[${CHANNEL_KEY}] sendError 发送失败(socket 可能已断开)。to=${userId}`);
49
+ }
50
+ }
51
+ catch (err) {
52
+ logger.warn(`[${CHANNEL_KEY}] sendError 执行异常: ${err}`);
53
+ }
54
+ };
55
+ /**
56
+ * 实际的消息处理函数。整体流程:
57
+ * 1. 从 extra 解析 groupId,校验合法性;
58
+ * 2. 读取群元数据 meta,校验群存在且用户为拥有者;
59
+ * 3. 规范化 mentions;
60
+ * 4. 将用户消息写入 Ledger;
61
+ * 5. 构造 DispatchContext 并交由 Orchestrator 异步调度。
62
+ */
63
+ return async function handle(data) {
64
+ const userId = data.from;
65
+ // 统一原始触发消息 ID:缺失时本地生成一个,使 conversationId / replyToMsgId / ctx.msgId 保持一致
66
+ const triggerMsgId = data.msgId ?? generateMsgId();
67
+ // 注意:data.extra 来源于客户端,字段类型不可信,统一以 unknown 承载,由下游做严格的运行时校验。
68
+ const extraIn = (data.extra ?? {});
69
+ const groupId = extraIn.groupId;
70
+ // 统一日志前缀
71
+ const tag = `[${CHANNEL_KEY}][group=${groupId}][user=${userId}]`;
72
+ // —— 1. 入参校验:必须携带合法的 groupId ——
73
+ if (typeof groupId !== 'string' || !groupId.trim()) {
74
+ logger.warn(`[${tag}] 群消息缺少 groupId,无法处理。msgId=${triggerMsgId} from=${userId}`);
75
+ sendError(userId, '群消息缺少 groupId,无法处理', {}, triggerMsgId);
76
+ return;
77
+ }
78
+ // —— 2. 读取群元数据 ——
79
+ let meta;
80
+ try {
81
+ meta = await storage.readGroupMeta(groupId);
82
+ }
83
+ catch (err) {
84
+ logger.error(`${tag} 读取群元数据失败: ${err}`);
85
+ sendError(userId, '读取群信息失败,请稍后重试', { groupId }, triggerMsgId);
86
+ return;
87
+ }
88
+ // 群不存在/已解散
89
+ if (!meta) {
90
+ logger.warn(`${tag} 群不存在或已解散`);
91
+ sendError(userId, '群不存在或已解散', { groupId }, triggerMsgId);
92
+ return;
93
+ }
94
+ // —— 3. 权限校验:仅群拥有者可在群内发言 ——
95
+ if (!meta.ownerIds.includes(userId)) {
96
+ logger.warn(`${tag} 你不是该群的拥有者,无法在此群发言`);
97
+ sendError(userId, '你不是该群的拥有者,无法在此群发言', { groupId }, triggerMsgId);
98
+ return;
99
+ }
100
+ // —— 4. 规范化业务数据 ——
101
+ const content = (data.content ?? '').trim();
102
+ const memberIds = meta.members.map((m) => m.agentId);
103
+ const mentions = normalizeMentions(extraIn.mentions, memberIds);
104
+ // —— 4.5 处理文件附件(参考私聊 inbound.ts 的文件处理模式) ——
105
+ const rawFiles = data.files ?? [];
106
+ const pluginRuntime = getLightclawRuntime();
107
+ // 获取当前用户的 apiKey,用于从云端下载文件(需要认证)
108
+ const effectiveApiKey = resolveEffectiveApiKey({ senderId: userId });
109
+ /** 群聊文件附件(与私聊 FileAttachment 对齐:name + mimeType + size + uri) */
110
+ const groupFiles = [];
111
+ /** 传递给 AI 上下文的 Attachments(name + mimeType + url + localPath) */
112
+ const ctxAttachments = [];
113
+ /** 本地媒体路径列表(供 SDK vision 模型直接读取文件内容) */
114
+ const localMediaPaths = [];
115
+ /** 本地文件 MIME 类型列表,与 localMediaPaths 一一对应 */
116
+ const localMediaTypes = [];
117
+ /** 附加到用户消息末尾的文件描述文本(告知 AI 用户发送了哪些文件) */
118
+ let attachmentDescription = '';
119
+ for (const file of rawFiles) {
120
+ // 前端发送 FileAttachment 格式:{ name, mimeType, size, uri, bytes }
121
+ const fileName = file.name || 'unknown';
122
+ const fileMimeType = file.mimeType || guessMimeType(fileName);
123
+ const fileUri = file.uri || '';
124
+ const fileSize = file.size ?? 0;
125
+ groupFiles.push({ name: fileName, mimeType: fileMimeType, size: fileSize, uri: fileUri || undefined });
126
+ try {
127
+ let buffer;
128
+ let mimeType;
129
+ let uploadPublicUrl;
130
+ // 来源 1:data URL(base64 编码的文件内容)
131
+ const parsed = file.bytes ? parseDataUrl(file.bytes) : null;
132
+ if (parsed) {
133
+ buffer = parsed.buffer;
134
+ mimeType = parsed.mimeType;
135
+ }
136
+ else if (fileUri) {
137
+ // 来源 2:云端 URI(CDN 地址或文件路径)
138
+ logger.info(`${tag} 文件有 URI,从云端下载: ${fileUri}`);
139
+ const downloaded = await downloadFileFromServer(fileUri, { apiKey: effectiveApiKey });
140
+ buffer = downloaded.buffer;
141
+ mimeType = fileMimeType;
142
+ uploadPublicUrl = fileUri.startsWith('http') ? fileUri : getFileDownloadUrl(fileUri);
143
+ }
144
+ else {
145
+ // 来源 3:既无 bytes 也无 uri,无法处理,跳过此文件
146
+ logger.warn(`${tag} 文件无 bytes 或 uri: ${fileName},跳过`);
147
+ attachmentDescription += `\n用户发送了文件: ${fileName} (${formatFileSize(fileSize)})`;
148
+ continue;
149
+ }
150
+ // 保存到本地媒体存储(框架统一管理)
151
+ const saved = await pluginRuntime.channel.media.saveMediaBuffer(buffer, mimeType, 'inbound', MEDIA_MAX_BYTES, fileName);
152
+ localMediaPaths.push(saved.path);
153
+ localMediaTypes.push(mimeType);
154
+ const localPath = `${LOCALFILE_SCHEME}${saved.path}`;
155
+ if (uploadPublicUrl) {
156
+ // URI 来源:已有公网 URL,直接使用
157
+ ctxAttachments.push({ name: fileName, mimeType, url: localPath, localPath: saved.path });
158
+ }
159
+ else {
160
+ // data URL 来源:上传到 COS 获取公网 URL
161
+ try {
162
+ const uploadResult = await uploadFileToServer(saved.path, { apiKey: effectiveApiKey });
163
+ ctxAttachments.push({ name: fileName, mimeType, url: localPath, localPath: saved.path });
164
+ logger.info(`${tag} 文件已上传到 COS: ${saved.path} → ${uploadResult.url}`);
165
+ }
166
+ catch (uploadErr) {
167
+ logger.warn(`${tag} COS 上传失败,降级使用本地路径: ${uploadErr}`);
168
+ ctxAttachments.push({ name: fileName, mimeType, url: localPath, localPath: saved.path });
169
+ }
170
+ }
171
+ logger.info(`${tag} 文件已保存: ${saved.path} (${mimeType}, ${formatFileSize(saved.size)})`);
172
+ }
173
+ catch (err) {
174
+ // 单个文件处理失败不影响整条消息的处理,记录错误后继续
175
+ logger.error(`${tag} 文件处理失败 ${fileName}: ${err}`);
176
+ if (fileUri) {
177
+ // 降级:仅传 CDN URL
178
+ ctxAttachments.push({ name: fileName, mimeType: fileMimeType, url: fileUri });
179
+ }
180
+ }
181
+ attachmentDescription += `\n用户发送了文件: ${fileName} (${formatFileSize(fileSize)})`;
182
+ logger.info(`${tag} 文件附件: ${fileName} (${fileMimeType}, ${formatFileSize(fileSize)}) uri=${fileUri ? '有' : '无'}`);
183
+ }
184
+ // 将文件描述追加到用户消息中(与私聊一致,让 AI 知道用户发送了哪些文件)
185
+ const userMessage = content + attachmentDescription;
186
+ // —— 5. 用户消息入账(Ledger)——
187
+ try {
188
+ await orchestrator.ledger.appendUser({
189
+ groupId,
190
+ userId,
191
+ content,
192
+ mentions,
193
+ files: groupFiles.length > 0 ? groupFiles : undefined,
194
+ msgId: triggerMsgId,
195
+ });
196
+ }
197
+ catch (err) {
198
+ logger.error(`${tag} 用户消息入账失败: ${err}`);
199
+ sendError(userId, '消息入账失败,请稍后重试', { groupId }, triggerMsgId);
200
+ return;
201
+ }
202
+ // —— 6. 构造调度上下文并触发 dispatch ——
203
+ const conversationId = `conv:${groupId}:${triggerMsgId}`;
204
+ // 提前 ensure 以获取 abortSignal(dispatch 内部会幂等处理)
205
+ const abortSignal = orchestrator.runRegistry.ensureConversation(conversationId);
206
+ // 从 openclaw.json 配置中读取 agents.list,过滤掉不在配置中的成员
207
+ const currentCfg = pluginRuntime.config.loadConfig();
208
+ const configAgentIds = new Set((currentCfg.agents?.list ?? []).map((a) => a.id));
209
+ // 仅保留在 openclaw.json agents 列表中存在的成员
210
+ const validMembers = configAgentIds.size > 0 ? meta.members.filter((m) => configAgentIds.has(m.agentId)) : meta.members;
211
+ const ctx = {
212
+ conversationId,
213
+ groupId,
214
+ userId,
215
+ meta: {
216
+ groupId: meta.groupId,
217
+ members: validMembers,
218
+ defaultAgentId: meta.defaultAgentId,
219
+ ownerIds: meta.ownerIds,
220
+ },
221
+ userMessage,
222
+ mentions,
223
+ abortSignal,
224
+ msgId: triggerMsgId,
225
+ files: ctxAttachments.length > 0
226
+ ? ctxAttachments.map((a) => ({
227
+ name: a.name,
228
+ mimeType: a.mimeType,
229
+ size: groupFiles.find((f) => f.name === a.name)?.size ?? 0,
230
+ uri: a.url,
231
+ localPath: a.localPath,
232
+ }))
233
+ : undefined,
234
+ mediaPaths: localMediaPaths.length > 0 ? localMediaPaths : undefined,
235
+ mediaTypes: localMediaTypes.length > 0 ? localMediaTypes : undefined,
236
+ effectiveApiKey,
237
+ };
238
+ logger.info(`${tag} 开始调度 conv=${conversationId} mentions=[${mentions.join(',')}]`);
239
+ // dispatch 采用「即发即忘」模式,结果通过 .then 处理
240
+ orchestrator.dispatch(ctx).then((result) => {
241
+ logger.info(`${tag} 调度完成 status=${result.finalStatus} route=${result.route}`);
242
+ }, (err) => {
243
+ logger.error(`${tag} 调度异常: ${err instanceof Error ? err.message : String(err)}`);
244
+ // 主动 dispose 防止注册表泄漏
245
+ try {
246
+ orchestrator.runRegistry.disposeConversation(conversationId);
247
+ }
248
+ catch (cleanupErr) {
249
+ logger.warn(`${tag} 调度异常后清理会话失败: ${cleanupErr}`);
250
+ }
251
+ sendError(userId, '群消息处理异常,请稍后重试', { groupId }, triggerMsgId);
252
+ });
253
+ };
254
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @file 群聊功能入口文件
3
+ * @description 群聊模块的公共 API 出口,外部模块应从此文件导入所需功能
4
+ */
5
+ // ── 统一常量 ──
6
+ export { EVENT_GROUP_REQUEST, EVENT_GROUP_RESPONSE, EVENT_GROUP_MEMBER_REQUEST, EVENT_GROUP_MEMBER_RESPONSE, EVENT_GROUP_HISTORY_REQUEST, EVENT_GROUP_HISTORY_RESPONSE, EVENT_GROUP_ABORT_REQUEST, EVENT_GROUP_ABORT_RESPONSE, } from './constants/index.js';
7
+ // ── 统一工具函数 ──
8
+ export { generatePreRunId, normalizeRunStatus, errorToString, } from './utils/index.js';
9
+ // ── 存储层 ──
10
+ export { GroupStorageCore } from './storage/index.js';
11
+ export { ConcurrencyManager } from './storage/concurrency-manager.js';
12
+ // ── 调度器 ──
13
+ export { Orchestrator } from './orchestrator/orchestrator.js';
14
+ // ── 群入站处理器 ──
15
+ export { createGroupInboundHandler } from './inbound/index.js';