lightclawbot 1.2.6 → 1.2.7

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 (99) hide show
  1. package/dist/src/gateway.js +50 -6
  2. package/dist/src/group/constants/index.js +20 -0
  3. package/dist/src/group/inbound/index.js +254 -0
  4. package/dist/src/group/index.js +15 -0
  5. package/dist/src/group/orchestrator/execution/agent-runner.js +299 -0
  6. package/dist/src/group/orchestrator/execution/index.js +7 -0
  7. package/dist/src/group/orchestrator/execution/prompt-builder.js +288 -0
  8. package/dist/src/group/orchestrator/execution/soul-resolver.js +38 -0
  9. package/dist/src/group/orchestrator/execution/types.js +7 -0
  10. package/dist/src/group/orchestrator/index.js +14 -0
  11. package/dist/src/group/orchestrator/lifecycle/conversation-state.js +162 -0
  12. package/dist/src/group/orchestrator/lifecycle/index.js +7 -0
  13. package/dist/src/group/orchestrator/lifecycle/ledger-writer.js +96 -0
  14. package/dist/src/group/orchestrator/lifecycle/run-registry.js +174 -0
  15. package/dist/src/group/orchestrator/orchestrator.js +265 -0
  16. package/dist/src/group/orchestrator/planning/index.js +13 -0
  17. package/dist/src/group/orchestrator/planning/plan-validator.js +233 -0
  18. package/dist/src/group/orchestrator/planning/planning-parser.js +207 -0
  19. package/dist/src/group/orchestrator/planning/subtask-executor.js +345 -0
  20. package/dist/src/group/orchestrator/planning/summarizer-runner.js +224 -0
  21. package/dist/src/group/orchestrator/routes/index.js +9 -0
  22. package/dist/src/group/orchestrator/routes/leader-dispatch.js +229 -0
  23. package/dist/src/group/orchestrator/routes/leader-orchestration-route.js +179 -0
  24. package/dist/src/group/orchestrator/routes/leader-planning.js +92 -0
  25. package/dist/src/group/orchestrator/routes/leader-self-answer.js +223 -0
  26. package/dist/src/group/orchestrator/routes/mention-concurrent-route.js +226 -0
  27. package/dist/src/group/orchestrator/routes/route-helpers.js +186 -0
  28. package/dist/src/group/orchestrator/routes/types.js +8 -0
  29. package/dist/src/group/services/group-cleanup-service.js +183 -0
  30. package/dist/src/group/services/group-creation-service.js +122 -0
  31. package/dist/src/group/services/group-deletion-service.js +111 -0
  32. package/dist/src/group/services/group-history-service.js +73 -0
  33. package/dist/src/group/services/group-member-service.js +169 -0
  34. package/dist/src/group/services/group-query-service.js +133 -0
  35. package/dist/src/group/services/group-update-service.js +144 -0
  36. package/dist/src/group/services/index.js +20 -0
  37. package/dist/src/group/storage/concurrency-manager.js +119 -0
  38. package/dist/src/group/storage/group-storage-core.js +227 -0
  39. package/dist/src/group/storage/index.js +12 -0
  40. package/dist/src/group/storage/message-reader.js +213 -0
  41. package/dist/src/group/storage/message-writer.js +229 -0
  42. package/dist/src/group/storage/slice-manager.js +165 -0
  43. package/dist/src/group/types/common.js +5 -0
  44. package/dist/src/group/types/index.js +5 -0
  45. package/dist/src/group/types/message.js +5 -0
  46. package/dist/src/group/types/orchestrator.js +5 -0
  47. package/dist/src/group/types/storage.js +5 -0
  48. package/dist/src/group/utils/id-generator.js +15 -0
  49. package/dist/src/group/utils/index.js +12 -0
  50. package/dist/src/group/utils/mime.js +36 -0
  51. package/dist/src/group/utils/normalize.js +32 -0
  52. package/dist/src/group/utils/run-helpers.js +36 -0
  53. package/dist/src/outbound.js +12 -19
  54. package/dist/src/shared.js +4 -3
  55. package/dist/src/socket/events/agents-request.js +147 -0
  56. package/dist/src/socket/events/chat-request.js +67 -0
  57. package/dist/src/socket/events/file-download.js +121 -0
  58. package/dist/src/socket/events/group-abort.js +59 -0
  59. package/dist/src/socket/events/group-history.js +59 -0
  60. package/dist/src/socket/events/group-member.js +83 -0
  61. package/dist/src/socket/events/group-request.js +91 -0
  62. package/dist/src/socket/events/history-request.js +95 -0
  63. package/dist/src/socket/events/index.js +39 -0
  64. package/dist/src/socket/events/message-private.js +82 -0
  65. package/dist/src/socket/handlers.js +53 -568
  66. package/dist/src/socket/native-socket.js +21 -20
  67. package/dist/src/socket/registry.js +6 -3
  68. package/dist/src/socket/reliable-emitter.js +16 -13
  69. package/dist/src/socket/service/chat-common.js +36 -0
  70. package/dist/src/socket/service/chat-create.js +75 -0
  71. package/dist/src/socket/service/chat-delete.js +94 -0
  72. package/dist/src/socket/service/chat-list.js +82 -0
  73. package/dist/src/socket/service/chat-update.js +83 -0
  74. package/dist/src/socket/service/group-abort.js +104 -0
  75. package/dist/src/socket/service/group-history.js +140 -0
  76. package/dist/src/socket/service/group-member.js +209 -0
  77. package/dist/src/socket/service/group.js +233 -0
  78. package/dist/src/socket/service/history.js +102 -0
  79. package/dist/src/socket/service/index.js +14 -0
  80. package/dist/src/socket/types/index.js +7 -0
  81. package/dist/src/socket/types/request.js +8 -0
  82. package/dist/src/socket/types/service.js +8 -0
  83. package/dist/src/socket/utils/agent-soul.js +95 -0
  84. package/dist/src/socket/utils/index.js +8 -0
  85. package/dist/src/socket/utils/message.js +83 -0
  86. package/dist/src/socket/utils/validate.js +42 -0
  87. package/dist/src/streaming/index.js +1 -0
  88. package/dist/src/streaming/stream-reply-sink.js +270 -14
  89. package/dist/src/streaming/types.js +20 -1
  90. package/dist/src/{download-tool.js → tools/download-tool.js} +41 -35
  91. package/dist/src/tools/group-history-tool.js +172 -0
  92. package/dist/src/{upload-tool.js → tools/upload-tool.js} +2 -2
  93. package/dist/src/tools.js +4 -3
  94. package/dist/src/utils/index.js +1 -0
  95. package/dist/src/utils/logger.js +38 -0
  96. package/openclaw.plugin.json +2 -1
  97. package/package.json +1 -1
  98. package/dist/src/socket/agent-soul.js +0 -41
  99. package/dist/src/socket/chat.js +0 -257
@@ -1,71 +1,21 @@
1
1
  /**
2
- * LightClaw — Socket.IO 事件处理器
2
+ * LightClaw — Socket.IO 事件处理器(主入口)
3
3
  *
4
- * 将 socket 事件监听(message:private、history:request、sessions:request)
5
- * gateway 主逻辑中解耦,使 gateway.ts 只负责连接管理,本模块专注事件处理。
4
+ * 将 socket 事件监听从 gateway 主逻辑中解耦,本模块只负责事件注册与分发。
5
+ * 各事件的具体处理逻辑已拆分到 events/ 子目录下的独立模块中。
6
6
  *
7
7
  * 事件处理流程:
8
8
  * 1. message:private — 接收用户私信,过滤无效消息后入队等待 AI 处理
9
9
  * 2. history:request — 接收历史消息拉取请求,查询本地存储后返回
10
- * 3. sessions:request 接收会话列表请求,返回所有已存在的会话
11
- *
12
- * 所有出站 socket.emit 通过 ReliableEmitter 实现 ACK 确认 + 自动重试,
13
- * 保证消息在网络抖动时不丢失。
10
+ * 3. agents:request 接收 Agent 列表请求,返回配置中的 agents
11
+ * 4. chat:request — 接收会话管理请求(list/create/update/delete)
12
+ * 5. group:request — 接收群组管理请求(list/create/update/delete/clear)
13
+ * 6. group:member:request — 接收群成员管理请求(add/remove)
14
+ * 7. group:history:request — 接收群历史消息请求
15
+ * 8. group:abort:request — 接收群消息中止请求
14
16
  */
15
- import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, EVENT_HISTORY_REQUEST, EVENT_HISTORY_RESPONSE, DEFAULT_HISTORY_LIMIT, DEFAULT_AGENT_ID, EVENT_AGENTS_REQUEST, EVENT_AGENTS_RESPONSE, KIND_FILE_DOWNLOAD, FILE_DOWNLOAD_STATUS, EVENT_CHAT_REQUEST, } from '../config.js';
16
- import { isDuplicate, debounceHistoryRequest, generateMsgId } from '../dedup.js';
17
- import { getLightclawRuntime } from '../runtime.js';
18
- import { readSessionHistoryWithCron, readSessionHistoriesByIds, loadSessionStore } from '../history/index.js';
19
- import { handleChatList, handleChatCreate, handleChatUpdate, handleChatDelete } from './chat.js';
20
- import { uploadFileToServer } from '../file-storage.js';
21
- import { guessMimeByExt } from '../media.js';
22
- import { ensureSessionInHistory, readChatsFile, resolveChatsFilePath } from '../utils/common.js';
23
- import { attachSoulIdsToAgents, readSoulIdMap, LIGHTSOUL_CONFIG_PATH } from './agent-soul.js';
24
- import * as fs from 'node:fs';
25
- import * as path from 'node:path';
26
- const getNonEmptyString = (value) => {
27
- if (typeof value !== 'string')
28
- return undefined;
29
- const trimmed = value.trim();
30
- return trimmed || undefined;
31
- };
32
- const isMainAgent = (agent) => {
33
- const id = getNonEmptyString(agent.id);
34
- const name = getNonEmptyString(agent.name);
35
- return id === DEFAULT_AGENT_ID || name === DEFAULT_AGENT_ID;
36
- };
37
- const withDefaultWorkspace = (agent, defaultWorkspace) => {
38
- if (!defaultWorkspace || getNonEmptyString(agent.workspace))
39
- return agent;
40
- return { ...agent, workspace: defaultWorkspace };
41
- };
42
- /**
43
- * 组装 agents:request 响应列表,确保单 agent 场景也返回默认 main agent。
44
- */
45
- export const normalizeAgentsWithMainAgent = (currentCfg) => {
46
- const agentsConfig = currentCfg.agents;
47
- const agentsList = Array.isArray(agentsConfig?.list) ? agentsConfig.list : [];
48
- const defaultWorkspace = getNonEmptyString(agentsConfig?.defaults?.workspace);
49
- let hasMainAgent = false;
50
- const normalizedAgents = agentsList.map((agent) => {
51
- if (!isMainAgent(agent))
52
- return agent;
53
- hasMainAgent = true;
54
- return withDefaultWorkspace(agent, defaultWorkspace);
55
- });
56
- if (hasMainAgent)
57
- return normalizedAgents;
58
- const mainAgent = {
59
- id: DEFAULT_AGENT_ID,
60
- name: DEFAULT_AGENT_ID,
61
- displayName: DEFAULT_AGENT_ID,
62
- isDefault: true,
63
- };
64
- if (defaultWorkspace) {
65
- mainAgent.workspace = defaultWorkspace;
66
- }
67
- return [mainAgent, ...normalizedAgents];
68
- };
17
+ // 从子模块导入各事件处理器绑定函数
18
+ import { bindMessagePrivateHandler, bindHistoryRequestHandler, bindAgentsRequestHandler, bindChatRequestHandler, bindGroupRequestHandler, bindGroupHistoryHandler, bindGroupMemberHandler, bindGroupAbortHandler, } from './events/index.js';
69
19
  /**
70
20
  * 绑定所有 Socket.IO 事件监听器到指定 socket 实例。
71
21
  *
@@ -76,519 +26,54 @@ export const normalizeAgentsWithMainAgent = (currentCfg) => {
76
26
  * @param deps - 处理事件所需的外部依赖(账户信息、队列、日志等)
77
27
  */
78
28
  export function bindSocketHandlers(socket, deps) {
79
- const { account, botClientId, log, handleMessage, onEvent, reliableEmitter } = deps;
80
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
29
+ const { botClientId, onEvent, reliableEmitter, orchestrator, groupStorage } = deps;
81
30
  // 事件:接收用户私信(message:private)
82
- // 职责:对消息做多层过滤,将合法消息放入处理队列
83
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
84
- socket.on(EVENT_MESSAGE_PRIVATE, (data, ack) => {
85
- // 立即回复 ACK,告知服务端消息已收到,避免服务端重发
86
- ack?.();
87
- // {"msgId":"91647ae9-c123-48e4-9d80-26f2e9a91ac9","from":"100013456706","to":"lhins-jem9a0br","content":"今天天气怎么样","files":[],"timestamp":1776077511044}
88
- log?.info(`[${CHANNEL_KEY}] Received private message: ${JSON.stringify(data)},botClientId:${botClientId}`);
89
- // ① 回环防御:过滤 bot 自身发出的消息,防止自问自答死循环
90
- if (data.from === botClientId)
91
- return;
92
- // ② 分发文件下载信令(kind=file:download, status=download_req):独立链路,不入 AI 处理队列
93
- if (data.kind === KIND_FILE_DOWNLOAD) {
94
- const reqTransferData = data.extra?.transferData;
95
- if (reqTransferData?.status === FILE_DOWNLOAD_STATUS.REQ) {
96
- // 去重:同一 transferId 的重复请求直接跳过
97
- if (isDuplicate(data.msgId))
98
- return;
99
- onEvent?.();
100
- void handleFileDownloadReq(data, botClientId, reliableEmitter, account, log);
101
- }
102
- // 其他 status(ready/url/error)是本端自己发出的下行消息,理论上不会从前端回流;
103
- // 即便误触发,此处直接 return 不做处理,避免进入 AI 处理队列
104
- return;
105
- }
106
- // ③ 跳过其他控制消息(如 typing 状态、stream 信号等),只处理 kind=text 的真实用户消息
107
- if (data.kind && data.kind !== 'text')
108
- return;
109
- // ③ 内容校验:消息既无文字内容也无附件文件时,直接丢弃
110
- const hasContent = data.content?.trim();
111
- const hasFiles = data.files && data.files.length > 0;
112
- if (!hasContent && !hasFiles)
113
- return;
114
- // 去重
115
- if (isDuplicate(data.msgId))
116
- return;
117
- // 通知框架收到入站事件,更新 lastEventAt 时间戳,防止 stale-socket 检测误判断线
118
- onEvent?.();
119
- log?.info(`[${CHANNEL_KEY}] Message from Agent: ${data.agentId}, ${data.from}: "${(data.content || '').slice(0, 60)}" files=${data.files?.length ?? 0}`);
120
- // /stop 及自然语言 abort 不做旁路,统一交给 openclaw fast-abort。
121
- const chatId = extractChatId(data);
122
- handleMessage({
123
- senderId: data.from,
124
- text: data.content || '',
125
- messageId: data.msgId,
126
- files: data.files ?? [],
127
- timestamp: data.timestamp,
128
- agentId: data.agentId, // 透传前端指定的 agentId
129
- chatId, // 透传前端指定的 chatId(多会话分桶用)
130
- });
131
- // ④ 异步登记当前生效的 sessionId 到 chats.json[chatId].sessionIdHistory。
132
- //
133
- // 为什么用 setImmediate?
134
- // - 首条消息到达时,框架还未创建 session,sessions.json 查不到该 sessionKey;
135
- // - /reset 等场景下,框架会在派发消息前 rotate sessionId,本回合的当前 sessionId
136
- // 是 rotate 之后的新 ID;
137
- // - 把登记放到下一个事件循环 tick,给框架留出时序窗口(创建/rotate session);
138
- // - 与主消息流解耦,登记失败不影响 handleMessage 主流程。
139
- //
140
- // 即使本次未登记成功(首条消息时 sessions.json 还没条目),下一条消息进来时
141
- // 仍会再次触发本逻辑,幂等的 ensureSessionInHistory 会补登记,最终一致。
142
- setImmediate(() => {
143
- try {
144
- recordSessionInHistory({
145
- userId: data.from,
146
- agentId: data.agentId,
147
- chatId,
148
- accountId: account.accountId,
149
- log,
150
- });
151
- }
152
- catch (err) {
153
- log?.warn(`[${CHANNEL_KEY}] recordSessionInHistory(message:private) error: ${err instanceof Error ? err.message : String(err)}`);
154
- }
155
- });
156
- });
157
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
31
+ bindMessagePrivateHandler(socket, deps);
158
32
  // 事件:历史消息请求(history:request)
159
- // 职责:根据用户 ID 解析 sessionKey,从本地存储读取历史消息后返回给客户端
160
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
161
- socket.on(EVENT_HISTORY_REQUEST, (data, ack) => {
162
- // 立即回复 ACK,告知服务端请求已收到
163
- ack?.();
164
- if (!data?.from) {
165
- log?.warn(`[${CHANNEL_KEY}] History request missing userId, ignoring`);
166
- return;
167
- }
168
- // 回环防御:忽略 bot 自身发出的请求
169
- if (data.from === botClientId)
170
- return;
171
- // 去重:如果有 msgId,走消息级去重
172
- if (data.msgId && isDuplicate(data.msgId)) {
173
- log?.warn(`[${CHANNEL_KEY}] Duplicate history request (msgId), ignoring`);
174
- return;
175
- }
176
- // 防抖:同一用户高频请求只处理最后一条
177
- debounceHistoryRequest(data.from, () => {
178
- // 通知框架收到入站事件(更新 lastEventAt,防止 stale-socket 误判)
179
- onEvent?.();
180
- try {
181
- // 获取插件运行时,读取最新配置(支持热更新,不缓存旧配置)
182
- const pluginRuntime = getLightclawRuntime();
183
- const currentCfg = pluginRuntime.config.loadConfig();
184
- // 通过路由解析器获取该用户对应的 Agent 路由信息
185
- // resolveAgentRoute 会综合账户配置、用户 ID、渠道等信息计算出 sessionKey
186
- const baseRoute = pluginRuntime.channel.routing.resolveAgentRoute({
187
- cfg: currentCfg,
188
- channel: CHANNEL_KEY,
189
- accountId: account.accountId,
190
- peer: { kind: 'direct', id: data.from },
191
- });
192
- // agentId 合法性校验:防止前端传入任意 agentId 访问其他 Agent 的数据
193
- const currentCfgTyped = currentCfg;
194
- const validAgentIds = currentCfgTyped.agents?.list?.map((a) => a.id) ?? [DEFAULT_AGENT_ID];
195
- log?.info(`[${CHANNEL_KEY}], 当前合法的agentId为:${validAgentIds}`);
196
- const resolvedAgentId = data.agentId && validAgentIds.includes(data.agentId) ? data.agentId : DEFAULT_AGENT_ID;
197
- log?.info(`[${CHANNEL_KEY}], 当前agentId为:${resolvedAgentId}`);
198
- // 始终用 effectiveAgentId 调用 buildAgentSessionKey 生成 sessionKey:
199
- // - 前端传了合法 agentId → 用它;否则降级到 baseRoute.agentId(框架路由解析出的默认值)
200
- //
201
- // 框架生成的基础 sessionKey 形如:
202
- // agent:<agentId>:<channel>:direct:<userId>
203
- // 当前端带上 chatId(多会话场景)时,需要在基础 sessionKey 末尾追加 `:<chatId>`,
204
- // 形成:agent:<agentId>:<channel>:direct:<userId>:<chatId>,
205
- // 以区分同一用户下的不同会话历史文件。框架本身不感知 chatId,
206
- // 故在此处显式拼接,保持与写入端(chat 维度的 sessionId)一致。
207
- const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({
208
- agentId: resolvedAgentId,
209
- channel: CHANNEL_KEY,
210
- accountId: baseRoute.accountId,
211
- peer: { kind: 'direct', id: data.from },
212
- dmScope: 'per-channel-peer',
213
- });
214
- const chatIdSuffix = extractChatId(data);
215
- const sessionKey = chatIdSuffix ? `${baseSessionKey}:${chatIdSuffix}` : baseSessionKey;
216
- log?.info(`[${CHANNEL_KEY}] userId=${data.from},chatId=${chatIdSuffix || '-'},当前的sessionKey=${sessionKey}`);
217
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
218
- // ① 兜底登记当前 sessionId 到 sessionIdHistory
219
- //
220
- // 为什么在拉历史时也做登记?—— 兜底覆盖一个边界场景:用户 reset 后没再
221
- // 发过消息,只是重新打开聊天界面拉历史。此时 message:private 的登记
222
- // 路径不会被触发,仅靠这里能补登记,确保历史不丢。
223
- //
224
- // 必须先于读历史,因为读历史依赖 sessionIdHistory 来合并多份 jsonl,
225
- // 当前 sessionId 也要一起读到。
226
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
227
- try {
228
- const store = loadSessionStore(resolvedAgentId);
229
- const currentSessionId = store[sessionKey]?.sessionId;
230
- if (currentSessionId) {
231
- ensureSessionInHistory(resolvedAgentId, data.from, chatIdSuffix, currentSessionId);
232
- }
233
- }
234
- catch (err) {
235
- log?.warn(`[${CHANNEL_KEY}] ensureSessionInHistory(history:request) error: ${err instanceof Error ? err.message : String(err)}`);
236
- }
237
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
238
- // ② 读取历史消息:优先按 sessionIdHistory 合并多份 jsonl(跨 reset 历史回看)
239
- //
240
- // - chats.json 命中(含 chatId === '' 的默认对话兜底条目):
241
- // 把该 chat 用过的所有 sessionId(含归档的旧 sessionId)的 jsonl
242
- // 全部读出来按 timestamp 合并;
243
- // - 否则:退化到原来的"按 sessionKey 读当前 jsonl + cron 合并"路径,
244
- // 保持向后兼容(chats.json 不存在 / 解析异常等极端场景)。
245
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
246
- const limit = data.limit ?? DEFAULT_HISTORY_LIMIT;
247
- const chatOnly = data.chatOnly ?? true;
248
- let messages = [];
249
- let readMode = 'single-session-with-cron';
250
- try {
251
- // 查询侧使用与登记侧同一个 chatId(已在 extractChatId 统一归一)。
252
- const targetChatId = chatIdSuffix;
253
- const chatsPath = resolveChatsFilePath(resolvedAgentId, data.from);
254
- // 这里使用**纯只读**的 readChatsFile:
255
- // - 拉历史是只读接口,不应该在用户还没发任何消息前就把
256
- // chats.json 写到磁盘(避免凭空出现一个默认对话);
257
- // - 如果是默认对话首次消息后拉历史,前面的 ensureSessionInHistory
258
- // 已经走 readChatsFileOrInitDefault 打点过 chats.json,这里能读到。
259
- const matched = readChatsFile(chatsPath).find((c) => c.chatId === targetChatId);
260
- const historyIds = matched?.sessionIdHistory ?? [];
261
- if (historyIds.length > 0) {
262
- messages = readSessionHistoriesByIds(historyIds, {
263
- limit,
264
- chatOnly,
265
- agentId: resolvedAgentId,
266
- });
267
- readMode = 'multi-session';
268
- }
269
- }
270
- catch (err) {
271
- log?.warn(`[${CHANNEL_KEY}] readSessionHistoriesByIds fallback to single-session: ${err instanceof Error ? err.message : String(err)}`);
272
- }
273
- if (messages.length === 0 && readMode !== 'multi-session') {
274
- // 退路 / 兜底:仅当未走多 sessionId 合并路径时,退化到按当前 sessionKey 读(含 cron 合并)。
275
- // 已走 multi-session 但消息为空,意味着 chats.json 里 sessionIdHistory 全部 jsonl 都没内容,
276
- // 这是合理的"空历史"状态,不应再退回 single-session 路径,避免重复 cron 拼接的副作用。
277
- messages = readSessionHistoryWithCron(sessionKey, {
278
- limit,
279
- chatOnly,
280
- includeCron: true,
281
- agentId: resolvedAgentId,
282
- });
283
- }
284
- log?.info(`[${CHANNEL_KEY}] History request: userId=${data.from} sessionKey=${sessionKey} mode=${readMode} found=${messages.length}`);
285
- // 过滤掉内容为空的消息(既无文字也无附件),避免客户端渲染空气泡
286
- const historyMsgId = generateMsgId();
287
- reliableEmitter.emitWithAck(EVENT_HISTORY_RESPONSE, {
288
- msgId: historyMsgId,
289
- from: botClientId,
290
- to: data.from,
291
- sessionKey,
292
- messages: messages.filter((msg) => !!msg.content.trim() || (msg.files && msg.files.length > 0)),
293
- agentId: resolvedAgentId,
294
- }, historyMsgId);
295
- }
296
- catch (err) {
297
- // 查询失败时返回空列表 + 错误信息,客户端可据此展示错误提示
298
- log?.error(`[${CHANNEL_KEY}] History request error: ${err}`);
299
- const errorMsgId = generateMsgId();
300
- reliableEmitter.emitWithAck(EVENT_HISTORY_RESPONSE, {
301
- msgId: errorMsgId,
302
- from: botClientId,
303
- to: data.from,
304
- sessionKey: '',
305
- messages: [],
306
- error: err instanceof Error ? err.message : String(err),
307
- agentId: data.agentId || DEFAULT_AGENT_ID,
308
- }, errorMsgId);
309
- }
310
- });
33
+ bindHistoryRequestHandler(socket, {
34
+ account: deps.account,
35
+ botClientId,
36
+ reliableEmitter,
37
+ onEvent,
311
38
  });
312
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
313
39
  // 事件:Agents 列表请求(agents:request)
314
- // 职责:读取 openclaw.json 中 agents.list,并在单 agent 场景补齐默认 main(支持热更新,不缓存)
315
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
316
- socket.on(EVENT_AGENTS_REQUEST, (data, ack) => {
317
- // 立即回复 ACK,告知服务端请求已收到
318
- ack?.();
319
- if (data.from === botClientId)
320
- return;
321
- // 通知框架收到入站事件(更新 lastEventAt,防止 stale-socket 误判)
322
- onEvent?.();
323
- try {
324
- // 获取插件运行时,读取最新配置
325
- const pluginRuntime = getLightclawRuntime();
326
- const currentCfg = pluginRuntime.config.loadConfig();
327
- const agentsList = normalizeAgentsWithMainAgent(currentCfg);
328
- let agentsWithSoulIds = agentsList;
329
- try {
330
- agentsWithSoulIds = attachSoulIdsToAgents(agentsList, readSoulIdMap());
331
- }
332
- catch (soulErr) {
333
- log?.warn(`[${CHANNEL_KEY}] Failed to read soul config ${LIGHTSOUL_CONFIG_PATH}: ${soulErr instanceof Error ? soulErr.message : String(soulErr)}`);
334
- }
335
- log?.info(`[${CHANNEL_KEY}] Agents request: count=${agentsWithSoulIds.length}`);
336
- const agentsMsgId = generateMsgId();
337
- reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, {
338
- msgId: agentsMsgId,
339
- to: data.from,
340
- from: botClientId,
341
- agents: agentsWithSoulIds,
342
- timestamp: Date.now(),
343
- }, agentsMsgId);
344
- }
345
- catch (err) {
346
- log?.error(`[${CHANNEL_KEY}] Agents request error: ${err}`);
347
- const agentsErrMsgId = generateMsgId();
348
- reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, {
349
- msgId: agentsErrMsgId,
350
- from: botClientId,
351
- to: data.from,
352
- agents: [],
353
- error: err instanceof Error ? err.message : String(err),
354
- timestamp: Date.now(),
355
- }, agentsErrMsgId);
356
- }
40
+ bindAgentsRequestHandler(socket, {
41
+ botClientId,
42
+ reliableEmitter,
43
+ onEvent,
357
44
  });
358
- socket.on(EVENT_CHAT_REQUEST, (data, ack) => {
359
- ack?.();
360
- log?.info(`[${CHANNEL_KEY}] Received chat request from ${data.from}: ${data.type}`);
361
- if (!data?.from) {
362
- log?.warn(`[${CHANNEL_KEY}] Chat request missing userId, ignoring`);
363
- return;
364
- }
365
- // 回环防御
366
- if (data.from === botClientId)
367
- return;
368
- onEvent?.();
369
- switch (data.type) {
370
- case 'list':
371
- handleChatList({
372
- userId: data.from,
373
- agentId: data.agentId,
374
- botClientId,
375
- reliableEmitter,
376
- log,
377
- });
378
- break;
379
- case 'create':
380
- handleChatCreate({
381
- userId: data.from,
382
- agentId: data.agentId,
383
- botClientId,
384
- reliableEmitter,
385
- log,
386
- });
387
- break;
388
- case 'update':
389
- handleChatUpdate({
390
- userId: data.from,
391
- agentId: data.agentId,
392
- chatId: data.chatId,
393
- title: data.title,
394
- botClientId,
395
- reliableEmitter,
396
- log,
397
- });
398
- break;
399
- case 'delete':
400
- handleChatDelete({
401
- userId: data.from,
402
- agentId: data.agentId,
403
- chatId: data.chatId,
404
- botClientId,
405
- reliableEmitter,
406
- log,
407
- });
408
- break;
409
- }
45
+ // 事件:会话管理请求(chat:request)
46
+ bindChatRequestHandler(socket, {
47
+ botClientId,
48
+ reliableEmitter,
49
+ onEvent,
410
50
  });
411
- }
412
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
413
- // 内部工具:从 PrivateMessageData / HistoryRequestData 中统一提取 chatId
414
- //
415
- // 抽离原因:原代码同一字段在 message:private(||)、setImmediate(??)、
416
- // history:request(?.trim())三处使用了三种不同写法,虽然在协议正常时结果一致,
417
- // 但语义有偏差且日后极易踩坑。统一为 extractChatId 后:
418
- // - 入参可能是 undefined / null / 非字符串 / 含空白字符串 / 合法字符串;
419
- // - 一律 trim 后归一为字符串;undefined / null / 非字符串 → '';
420
- // - 返回值要么是 ''(默认对话标识),要么是非空合法 chatId。
421
- // 这样写入侧(chats.json[chatId].sessionIdHistory)与读取侧(按 chatId 查询)
422
- // 始终拿到同一个值,杜绝因写法差异引起的"找不到 chat 条目"问题。
423
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
424
- function extractChatId(data) {
425
- const raw = data?.extra?.chatId;
426
- if (typeof raw !== 'string')
427
- return '';
428
- return raw.trim();
429
- }
430
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
431
- // 内部工具:把当前 chat 在用的 sessionId 登记到 chats.json[chatId].sessionIdHistory
432
- //
433
- // 设计要点:
434
- // 1. 解析 sessionKey —— 与 history:request 分支保持一致:
435
- // agent:<agentId>:<channel>:direct:<userId>[:<chatId>]
436
- // 若无 chatId 直接早返(无 chatId 的会话不在 chats.json 中登记)。
437
- // 2. 通过 agentId 合法性校验,避免前端伪造 agentId 污染他人 chats.json。
438
- // 3. 从 sessions.json 索引读出当前 sessionKey 对应的 sessionId
439
- // —— 框架在派发消息前完成 mint/rotate,本时刻读到的就是"当前生效"的 sessionId。
440
- // 4. 调用幂等的 ensureSessionInHistory:已存在则跳过;新出现则末尾追加并落盘。
441
- //
442
- // 调用时机:
443
- // - EVENT_MESSAGE_PRIVATE:在 setImmediate 中异步调用,确保框架已建好/rotate 好 session;
444
- // - EVENT_HISTORY_REQUEST:在 sessionKey 解析后同步调用,作为兜底登记入口。
445
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
446
- function recordSessionInHistory(params) {
447
- const { userId, agentId, chatId, accountId, log } = params;
448
- // 取最新配置,复用 history 分支同款 agentId 校验逻辑(防止伪造 agentId)
449
- const pluginRuntime = getLightclawRuntime();
450
- const currentCfg = pluginRuntime.config.loadConfig();
451
- const currentCfgTyped = currentCfg;
452
- const validAgentIds = currentCfgTyped.agents?.list?.map((a) => a.id) ?? [DEFAULT_AGENT_ID];
453
- const resolvedAgentId = agentId && validAgentIds.includes(agentId) ? agentId : DEFAULT_AGENT_ID;
454
- // 计算 sessionKey(必须与 history:request 一致,否则查不到 sessions.json 条目)
455
- const baseRoute = pluginRuntime.channel.routing.resolveAgentRoute({
456
- cfg: currentCfg,
457
- channel: CHANNEL_KEY,
458
- accountId,
459
- peer: { kind: 'direct', id: userId },
51
+ // 事件:群组管理请求(group:request)
52
+ // 传入 orchestrator 用于创建群后唤醒主Agent生成欢迎消息
53
+ bindGroupRequestHandler(socket, {
54
+ botClientId,
55
+ reliableEmitter,
56
+ onEvent,
57
+ orchestrator,
460
58
  });
461
- const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({
462
- agentId: resolvedAgentId,
463
- channel: CHANNEL_KEY,
464
- accountId: baseRoute.accountId,
465
- peer: { kind: 'direct', id: userId },
466
- dmScope: 'per-channel-peer',
59
+ // 事件:群历史消息请求(group:history:request)
60
+ bindGroupHistoryHandler(socket, {
61
+ botClientId,
62
+ reliableEmitter,
63
+ onEvent,
64
+ });
65
+ // 事件:群成员管理请求(group:member:request)
66
+ bindGroupMemberHandler(socket, {
67
+ botClientId,
68
+ reliableEmitter,
69
+ onEvent,
70
+ });
71
+ // 事件:群消息中止请求(group:abort:request)
72
+ bindGroupAbortHandler(socket, {
73
+ botClientId,
74
+ reliableEmitter,
75
+ onEvent,
76
+ orchestrator,
77
+ groupStorage,
467
78
  });
468
- // 与 history:request 保持完全一致的 sessionKey 拼接规则:
469
- // - 空 chatId → 不追加后缀,直接用 baseSessionKey(默认对话场景)
470
- // - 非空 chatId → 追加 `:<chatId>` 后缀(多会话场景)
471
- // 这是写入侧已建立的约定,框架对无 chatId 会话写到 baseSessionKey 上。
472
- const sessionKey = chatId ? `${baseSessionKey}:${chatId}` : baseSessionKey;
473
- // 查 sessions.json 拿当前生效 sessionId;首条消息时可能查不到 → 跳过,下一条消息会补登记
474
- const store = loadSessionStore(resolvedAgentId);
475
- const currentSessionId = store[sessionKey]?.sessionId;
476
- if (!currentSessionId) {
477
- log?.info(`[${CHANNEL_KEY}] recordSessionInHistory: sessionId not found yet, skip. sessionKey=${sessionKey}`);
478
- return;
479
- }
480
- // 登记 sessionId 到 chats.json,确保历史记录能被查到;已存在则跳过,新增则末尾追加并落盘
481
- ensureSessionInHistory(resolvedAgentId, userId, chatId, currentSessionId);
482
- }
483
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
484
- // 文件下载处理器(kind=file:download, status=download_req)
485
- //
486
- // 流程:
487
- // 1. 从 data.extra.transferData 解析 transferId / localPath
488
- // 2. 基础校验:必须是绝对路径,文件必须存在
489
- // 3. 先回告 download_ready 帧(transferId + 文件元数据),前端据此更新下载按钮状态
490
- // 4. 通过 uploadFileToServer 将本机文件上传到 ai-server,拿到下载 URL
491
- // 5. 回告 download_url 帧(transferId + url),前端触发浏览器原生下载
492
- // 6. 任一阶段失败 → 回告 download_error 帧(transferId + error message)
493
- //
494
- // 备注:不走目录白名单校验,因为 localPath 来自 AI 工具返回的 localfile:// 链接,
495
- // AI 只会返回自己刚写盘的文件;上传到 ai-server 后会经过一轮内容审核兜底。
496
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
497
- async function handleFileDownloadReq(data, botClientId, reliableEmitter, account, log) {
498
- const transferData = data.extra?.transferData;
499
- const transferId = transferData?.transferId;
500
- const localPath = transferData?.localPath;
501
- log?.info(`[${CHANNEL_KEY}] file:download(req) received: transferId=${transferId}, localPath=${localPath}, from=${data.from}`);
502
- const sendError = (error) => {
503
- const msgId = generateMsgId();
504
- reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, {
505
- msgId,
506
- from: botClientId,
507
- to: data.from,
508
- content: '',
509
- timestamp: Date.now(),
510
- kind: KIND_FILE_DOWNLOAD,
511
- extra: {
512
- chatId: extractChatId(data),
513
- transferData: { transferId, status: FILE_DOWNLOAD_STATUS.ERROR, error },
514
- },
515
- }, msgId);
516
- log?.error(`[${CHANNEL_KEY}] file:download(error) sent: transferId=${transferId}, error=${error}`);
517
- };
518
- if (!transferId || !localPath) {
519
- sendError('Missing transferId or localPath in extra.transferData');
520
- return;
521
- }
522
- // 基础校验:必须是绝对路径
523
- if (!path.isAbsolute(localPath)) {
524
- sendError(`localPath must be an absolute path: ${localPath}`);
525
- return;
526
- }
527
- const resolvedPath = path.resolve(localPath);
528
- if (!fs.existsSync(resolvedPath)) {
529
- sendError(`File not found: ${resolvedPath}`);
530
- return;
531
- }
532
- const stat = fs.statSync(resolvedPath);
533
- if (!stat.isFile()) {
534
- sendError(`Not a regular file: ${resolvedPath}`);
535
- return;
536
- }
537
- const fileName = path.basename(resolvedPath);
538
- const mimeType = guessMimeByExt(path.extname(fileName).toLowerCase()) || 'application/octet-stream';
539
- // 回告 download_ready(文件确认存在 + 元数据)
540
- const readyMsgId = generateMsgId();
541
- reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, {
542
- msgId: readyMsgId,
543
- from: botClientId,
544
- to: data.from,
545
- content: '',
546
- timestamp: Date.now(),
547
- kind: KIND_FILE_DOWNLOAD,
548
- extra: {
549
- chatId: extractChatId(data),
550
- transferData: {
551
- transferId,
552
- status: FILE_DOWNLOAD_STATUS.READY,
553
- name: fileName,
554
- size: stat.size,
555
- contentType: mimeType,
556
- },
557
- },
558
- }, readyMsgId);
559
- log?.info(`[${CHANNEL_KEY}] file:download(ready) sent: transferId=${transferId}, name=${fileName}, size=${stat.size}`);
560
- // 上传本机文件到 ai-server(含审核),成功后把 URL 回告前端
561
- try {
562
- // 通过 senderId 解析真实 apiKey,用于 /drive/save 鉴权
563
- const apiKey = account.apiKey;
564
- const uploadResult = await uploadFileToServer(resolvedPath, { apiKey });
565
- if (!uploadResult.isUploaded || !uploadResult.url) {
566
- sendError('Upload to ai-server failed');
567
- return;
568
- }
569
- const urlMsgId = generateMsgId();
570
- reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, {
571
- msgId: urlMsgId,
572
- from: botClientId,
573
- to: data.from,
574
- content: '',
575
- timestamp: Date.now(),
576
- kind: KIND_FILE_DOWNLOAD,
577
- extra: {
578
- chatId: extractChatId(data),
579
- transferData: {
580
- transferId,
581
- status: FILE_DOWNLOAD_STATUS.URL,
582
- url: uploadResult.url,
583
- name: fileName,
584
- size: stat.size,
585
- contentType: mimeType,
586
- },
587
- },
588
- }, urlMsgId);
589
- log?.info(`[${CHANNEL_KEY}] file:download(url) sent: transferId=${transferId}, url=${uploadResult.url}`);
590
- }
591
- catch (err) {
592
- sendError(err instanceof Error ? err.message : String(err));
593
- }
594
79
  }