lightclawbot 1.2.0-beta.0 → 1.2.0-beta.2

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/dist/index.js CHANGED
@@ -2,19 +2,18 @@
2
2
  * LightClaw — 主入口文件
3
3
  *
4
4
  * 使用 OpenClaw SDK 标准的 defineChannelPluginEntry 工厂函数替代手动创建 plugin 对象,
5
- * 遵循 Discord/Slack 等官方 channel 插件的标准 index.ts 模式。
6
5
  *
7
6
  * 该文件定义了 LightClawBot channel plugin 的入口配置,包括插件ID、名称、描述、
8
7
  * 核心插件实现、运行时设置和工具注册等功能。
9
8
  */
10
9
  // 导入 OpenClaw SDK 的核心功能
11
- import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
10
+ import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core';
12
11
  // 导入本地模块
13
- import { lightclawPlugin } from "./src/channel.js"; // 核心插件实现
14
- import { setLightclawRuntime } from "./src/runtime.js"; // 运行时配置设置函数
15
- import { registerLightclawTools } from "./src/tools.js"; // 工具注册函数
12
+ import { lightclawPlugin } from './src/channel.js'; // 核心插件实现
13
+ import { setLightclawRuntime } from './src/runtime.js'; // 运行时配置设置函数
14
+ import { registerLightclawTools } from './src/tools.js'; // 工具注册函数
16
15
  // 导出运行时设置函数,供外部使用
17
- export { setLightclawRuntime } from "./src/runtime.js";
16
+ export { setLightclawRuntime } from './src/runtime.js';
18
17
  /**
19
18
  * 创建并配置 LightClawBot channel plugin 入口
20
19
  *
@@ -25,11 +24,11 @@ export { setLightclawRuntime } from "./src/runtime.js";
25
24
  */
26
25
  const entry = defineChannelPluginEntry({
27
26
  // 插件唯一标识符,用于在系统中识别该插件
28
- id: "lightclawbot",
27
+ id: 'lightclawbot',
29
28
  // 插件显示名称,在UI中展示给用户
30
- name: "LightClawBot",
29
+ name: 'LightClawBot',
31
30
  // 插件功能描述,说明插件的主要用途
32
- description: "Channel plugin for LightClawBot",
31
+ description: 'Channel plugin for LightClawBot',
33
32
  // 核心插件实现,包含消息处理、事件响应等核心逻辑
34
33
  plugin: lightclawPlugin,
35
34
  // 运行时设置函数,用于配置插件运行时的环境和参数
@@ -1,313 +1,182 @@
1
1
  /**
2
- * LightClaw — ChannelPlugin 主定义文件
2
+ * LightClaw — ChannelPlugin 主定义
3
3
  *
4
- * 使用 OpenClaw SDK 标准的 createChatChannelPlugin 构建器创建聊天频道插件,
4
+ * 使用 SDK 标准的 createChatChannelPlugin 构建器,
5
+ * 参照官方 Discord/Slack channel 实现。
5
6
  *
6
- * 主要结构组成:
7
- * base: 基础配置 - 来自 shared.ts 的 createLightclawPluginBase + 扩展字段
8
- * messaging: 消息目标解析 - 处理消息发送目标的标准化和验证
9
- * status: 状态管理 - 插件运行状态监控和状态面板构建
10
- * gateway: Gateway 生命周期 - WebSocket 连接管理和账户生命周期
11
- * outbound: 出站消息适配器 - 消息发送和媒体文件处理
12
- *
13
- * 该插件实现了与 AI 助手服务的 WebSocket 通信、消息收发、状态管理等核心功能。
7
+ * 结构:
8
+ * base: 来自 shared.ts 的 createLightclawPluginBase + 扩展字段
9
+ * security: DM 安全策略
10
+ * outbound: 出站消息适配器
14
11
  */
15
12
  import { createChatChannelPlugin } from 'openclaw/plugin-sdk/core';
16
- // 导入工具函数和常量
17
13
  import { chunkText, defaultAccountId } from './utils/index.js';
18
14
  import { CHANNEL_KEY, DEFAULT_ACCOUNT_ID, TEXT_CHUNK_LIMIT } from './config.js';
19
- // 导入功能模块
20
- import { sendText, sendMedia } from './outbound.js'; // 出站消息发送
21
- import { startGateway } from './gateway.js'; // WebSocket Gateway 启动
22
- import { createLightclawPluginBase } from './shared.js'; // 基础插件配置
23
- import { lightclawSetupAdapter } from './setup-core.js'; // Setup 适配器
24
- import { normalizeTarget, looksLikeId } from './messaging.js'; // 消息目标处理
25
- /**
26
- * LightClaw 主插件实例
27
- *
28
- * 使用 createChatChannelPlugin 工厂函数创建类型安全的 ChannelPlugin 实例,
29
- * 泛型参数 ResolvedAssistantAccount 指定了账户数据的类型结构。
30
- *
31
- * @type {ChannelPlugin<ResolvedAssistantAccount>}
32
- */
15
+ import { sendText, sendMedia } from './outbound.js';
16
+ import { startGateway } from './gateway.js';
17
+ import { createLightclawPluginBase } from './shared.js';
18
+ import { lightclawSetupAdapter } from './setup-core.js';
19
+ import { normalizeTarget, looksLikeId } from './messaging.js';
20
+ // ============================================================
21
+ // ChannelPlugin 定义(使用 createChatChannelPlugin)
22
+ // ============================================================
33
23
  export const lightclawPlugin = createChatChannelPlugin({
34
- // ==================== 基础配置 ====================
35
24
  base: {
36
25
  // ---- 来自 shared.ts 的基础定义 ----
37
- // 使用扩展运算符合并基础配置和 setup 适配器
38
26
  ...createLightclawPluginBase({
39
- setup: lightclawSetupAdapter, // Setup 阶段使用的适配器
27
+ setup: lightclawSetupAdapter,
40
28
  }),
41
- // ---- 消息目标解析配置 ----
29
+ // ---- 消息目标解析 ----
42
30
  messaging: {
43
- /**
44
- * 目标标准化函数
45
- * 对原始目标字符串进行标准化处理:去除空格、统一格式、转换编码等
46
- *
47
- * @param {string} target - 原始目标字符串
48
- * @returns {string} 标准化后的目标字符串
49
- */
31
+ // 对原始目标字符串进行标准化处理,如去除空格、统一格式、转换编码等,返回标准化后的字符串
50
32
  normalizeTarget: (target) => {
51
33
  return normalizeTarget(target);
52
34
  },
53
- /** 目标解析器配置 */
54
35
  targetResolver: {
55
- /**
56
- * ID 格式检测函数
57
- * 判断一个原始字符串是否看起来像一个有效的 ID 格式
58
- * 用于在完整解析前快速分流处理逻辑,提高性能
59
- *
60
- * @param {string} id - 待检测的字符串
61
- * @param {string} [normalized] - 可选的标准化的字符串
62
- * @returns {boolean} 是否看起来像 ID 格式
63
- */
36
+ // 判断一个原始字符串是否看起来像一个 ID,用于在解析前快速分流处理逻辑
64
37
  looksLikeId: (id, normalized) => {
65
38
  return looksLikeId(id, normalized);
66
39
  },
67
- /**
68
- * 目标格式提示文本
69
- * 显示在 CLI 帮助信息中,指导用户正确的目标格式
70
- */
40
+ /** 目标格式提示文本,显示在 CLI 帮助信息中 */
71
41
  hint: `user:<userId> or channel:<groupId>`,
72
42
  },
73
43
  },
74
- // ---- 状态面板配置 ----
44
+ // ---- 状态面板 ----
75
45
  status: {
76
- /** 默认运行时状态配置 */
77
46
  defaultRuntime: {
78
- accountId: DEFAULT_ACCOUNT_ID, // 默认账户ID
79
- running: false, // 运行状态:未运行
80
- connected: false, // 连接状态:未连接
81
- lastConnectedAt: null, // 最后连接时间:null
82
- lastError: null, // 最后错误信息:null
47
+ accountId: DEFAULT_ACCOUNT_ID,
48
+ running: false,
49
+ connected: false,
50
+ lastConnectedAt: null,
51
+ lastError: null,
83
52
  },
84
- /**
85
- * 构建频道摘要信息
86
- * 根据快照数据生成频道级别的状态摘要
87
- *
88
- * @param {Object} param0 - 参数对象
89
- * @param {Object} param0.snapshot - 状态快照数据
90
- * @returns {Object} 频道摘要信息
91
- */
92
53
  buildChannelSummary: ({ snapshot }) => ({
93
- configured: snapshot.configured ?? false, // 是否已配置
94
- running: snapshot.running ?? false, // 是否正在运行
95
- connected: snapshot.connected ?? false, // 是否已连接
96
- lastConnectedAt: snapshot.lastConnectedAt ?? null, // 最后连接时间
97
- lastError: snapshot.lastError ?? null, // 最后错误信息
54
+ configured: snapshot.configured ?? false,
55
+ running: snapshot.running ?? false,
56
+ connected: snapshot.connected ?? false,
57
+ lastConnectedAt: snapshot.lastConnectedAt ?? null,
58
+ lastError: snapshot.lastError ?? null,
98
59
  }),
99
- /**
100
- * 构建账户快照信息
101
- * 根据账户、运行时和配置数据生成账户级别的状态快照
102
- *
103
- * @param {Object} param0 - 参数对象
104
- * @param {ResolvedAssistantAccount} param0.account - 账户信息
105
- * @param {Object} param0.runtime - 运行时状态
106
- * @param {OpenClawConfig} param0.cfg - 配置信息
107
- * @returns {Object} 账户快照信息
108
- */
109
60
  buildAccountSnapshot: ({ account, runtime, cfg }) => ({
110
- accountId: account?.accountId ?? defaultAccountId(cfg), // 账户ID,优先使用账户的ID
111
- name: account?.name, // 账户名称
112
- enabled: account?.enabled ?? false, // 是否启用
113
- configured: Boolean(account?.apiKey), // 是否已配置(有API密钥)
114
- running: runtime?.running ?? false, // 是否正在运行
115
- connected: runtime?.connected ?? false, // 是否已连接
116
- lastConnectedAt: runtime?.lastConnectedAt ?? null, // 最后连接时间
117
- lastError: runtime?.lastError ?? null, // 最后错误信息
61
+ accountId: account?.accountId ?? defaultAccountId(cfg),
62
+ name: account?.name,
63
+ enabled: account?.enabled ?? false,
64
+ configured: Boolean(account?.apiKey),
65
+ running: runtime?.running ?? false,
66
+ connected: runtime?.connected ?? false,
67
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
68
+ lastError: runtime?.lastError ?? null,
118
69
  }),
119
70
  },
120
- // ---- Gateway 生命周期管理(核心功能) ----
71
+ // ---- Gateway 生命周期(核心!) ----
121
72
  gateway: {
122
73
  /**
123
- * 启动账户
124
- * 建立 WebSocket 长连接到 AI 助手服务器
125
- * OpenClaw 框架会在加载配置后为每个启用的账户调用此方法
126
- *
127
- * @async
128
- * @param {Object} ctx - 上下文对象
129
- * @param {ResolvedAssistantAccount} ctx.account - 账户信息
130
- * @param {AbortSignal} ctx.abortSignal - 中止信号,用于取消操作
131
- * @param {Object} ctx.log - 日志记录器
132
- * @param {OpenClawConfig} ctx.cfg - 配置信息
133
- * @param {Function} ctx.setStatus - 状态设置函数
134
- * @param {Function} ctx.getStatus - 状态获取函数
74
+ * 启动账户:建立 WS 长连接到 AI 助手 Server
75
+ * OpenClaw 会在加载配置后为每个 enabled 的账户调用此方法
135
76
  */
136
77
  startAccount: async (ctx) => {
137
- const { account, abortSignal, log, cfg, accountId } = ctx;
138
- // 记录启动日志
139
- log?.info(`[${CHANNEL_KEY}:${accountId}] Starting gateway...`);
78
+ const { account, abortSignal, log, cfg } = ctx;
79
+ log?.info(`[${CHANNEL_KEY}:${account.accountId}] Starting gateway...`);
140
80
  // 启动并维持一个 Socket.IO Gateway 实例
141
81
  await startGateway({
142
- account, // 账户信息
143
- abortSignal, // 中止信号
144
- cfg, // 配置信息
145
- log, // 日志记录器
146
- /**
147
- * WebSocket 连接就绪回调
148
- * 当 WebSocket 连接成功建立时调用
149
- */
82
+ account,
83
+ abortSignal,
84
+ cfg,
85
+ log,
86
+ /** WS 连接就绪回调 */
150
87
  onReady: () => {
151
- log?.info(`[${CHANNEL_KEY}:${accountId}] Gateway ready: WS connected`);
88
+ log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway ready: WS connected`);
152
89
  const now = Date.now();
153
- // 更新运行时状态
154
90
  ctx.setStatus({
155
- ...ctx.getStatus(), // 保留现有状态
156
- running: true, // 设置为运行中
157
- connected: true, // 设置为已连接
158
- lastConnectedAt: now, // 记录最后连接时间
91
+ ...ctx.getStatus(),
92
+ running: true,
93
+ connected: true,
94
+ lastConnectedAt: now,
159
95
  // 连接建立时即设置 lastEventAt,作为 health-monitor stale-socket 检测的初始基准
160
- lastEventAt: now, // 最后事件时间
96
+ lastEventAt: now,
161
97
  });
162
98
  },
163
- /**
164
- * WebSocket 连接断开回调
165
- * 当 WebSocket 连接断开时调用
166
- */
99
+ /** WS 连接断开回调 */
167
100
  onDisconnect: () => {
168
- log?.info(`[${CHANNEL_KEY}:${accountId}] Gateway ready: WS disconnect`);
169
- // 更新连接状态为断开
101
+ log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway ready: WS disconnect`);
170
102
  ctx.setStatus({
171
- ...ctx.getStatus(), // 保留现有状态
172
- connected: false, // 设置为未连接
103
+ ...ctx.getStatus(),
104
+ connected: false,
173
105
  });
174
106
  },
175
- /**
176
- * 错误回调
177
- * 当发生错误时调用,记录错误信息但不中断重连逻辑
178
- * 重连逻辑由 gateway 内部处理
179
- *
180
- * @param {Error} error - 错误对象
181
- */
107
+ /** 错误回调:记录错误信息,不中断重连逻辑(由 gateway 内部处理) */
182
108
  onError: (error) => {
183
- log?.error(`[${CHANNEL_KEY}:${accountId}] Gateway error: ${error.message}`);
184
- // 记录错误信息
109
+ log?.error(`[${CHANNEL_KEY}:${account.accountId}] Gateway error: ${error.message}`);
185
110
  ctx.setStatus({
186
- ...ctx.getStatus(), // 保留现有状态
187
- lastError: error.message, // 记录错误消息
111
+ ...ctx.getStatus(),
112
+ lastError: error.message,
188
113
  });
189
114
  },
190
115
  /**
191
- * 入站事件回调
192
- * 每次收到消息时调用,用于:
193
- * - 刷新 lastEventAt 和 lastInboundAt 时间戳
194
- * - 通知框架 health-monitor 该连接仍处于活跃状态
195
- * - 防止因长时间无活动而被判定为僵死连接
116
+ * 入站事件回调:每次收到消息时调用。
117
+ * 刷新 lastEventAt 和 lastInboundAt,
118
+ * 通知框架 health-monitor 该连接仍处于活跃状态。
196
119
  */
197
120
  onEvent: () => {
198
- log?.info(`[${CHANNEL_KEY}:${accountId}] Gateway Ready: WS message enter`);
199
- // 更新事件时间戳
121
+ log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway Ready: WS message enter`);
200
122
  ctx.setStatus({
201
- ...ctx.getStatus(), // 保留现有状态
202
- lastEventAt: Date.now(), // 最后事件时间
203
- lastInboundAt: Date.now(), // 最后入站消息时间
123
+ ...ctx.getStatus(),
124
+ lastEventAt: Date.now(),
125
+ lastInboundAt: Date.now(),
204
126
  });
205
127
  },
206
128
  });
207
129
  },
208
- /**
209
- * 登出账户
210
- * 清除账户的凭证信息(如 API 密钥)
211
- *
212
- * @async
213
- * @param {Object} param0 - 参数对象
214
- * @param {string} param0.accountId - 账户ID
215
- * @param {OpenClawConfig} param0.cfg - 配置信息
216
- * @returns {Promise<{ok: boolean, cleared: boolean}>} 操作结果
217
- */
130
+ /** 登出账户:清除凭证 */
218
131
  logoutAccount: async ({ accountId, cfg }) => {
219
- // 创建配置副本以避免修改原始配置
220
132
  const nextCfg = { ...cfg };
221
- // 获取当前 channel 的配置节
222
133
  const section = nextCfg.channels?.[CHANNEL_KEY];
223
- let cleared = false; // 清理标志
134
+ let cleared = false;
224
135
  if (section) {
225
- // 处理默认账户的 API 密钥
226
136
  if (accountId === DEFAULT_ACCOUNT_ID && section.apiKeys) {
227
- delete section.apiKeys; // 删除默认账户的 API 密钥
228
- cleared = true; // 标记为已清理
137
+ delete section.apiKeys;
138
+ cleared = true;
229
139
  }
230
- // 处理特定账户的 API 密钥
231
140
  const accounts = section.accounts;
232
141
  if (accounts?.[accountId]?.apiKeys) {
233
- delete accounts[accountId].apiKeys; // 删除指定账户的 API 密钥
234
- cleared = true; // 标记为已清理
142
+ delete accounts[accountId].apiKeys;
143
+ cleared = true;
235
144
  }
236
145
  }
237
- // 返回操作结果
238
146
  return { ok: true, cleared };
239
147
  },
240
148
  },
241
149
  },
242
- // ==================== 出站消息适配器 ====================
150
+ // ---- 出站消息适配器 ----
243
151
  outbound: {
244
- // 基础出站配置
245
152
  base: {
246
- deliveryMode: 'direct', // 交付模式:直接交付
247
- chunker: chunkText, // 文本分块处理器
248
- chunkerMode: 'markdown', // 分块模式:Markdown
249
- textChunkLimit: TEXT_CHUNK_LIMIT, // 文本分块限制大小
250
- /**
251
- * 目标解析函数
252
- * 解析消息发送的目标地址
253
- *
254
- * @param {Object} param0 - 参数对象
255
- * @param {string} param0.to - 显式指定的目标
256
- * @param {string[]} param0.allowFrom - 允许的来源列表
257
- * @param {string} param0.mode - 模式('direct' 或 'implicit')
258
- * @returns {Object} 解析结果
259
- */
153
+ deliveryMode: 'direct',
154
+ chunker: chunkText,
155
+ chunkerMode: 'markdown',
156
+ textChunkLimit: TEXT_CHUNK_LIMIT,
260
157
  resolveTarget: ({ to, allowFrom, mode }) => {
261
- // 优先使用显式指定的目标地址
158
+ // 优先使用显式 to;implicit 模式下回退到 allowFrom 第一个非通配符条目
262
159
  const effectiveTo = to?.trim();
263
160
  if (effectiveTo) {
264
161
  return { ok: true, to: effectiveTo };
265
162
  }
266
- // implicit 模式下回退到 allowFrom 第一个非通配符条目
267
163
  if (mode === 'implicit' && allowFrom && allowFrom.length > 0) {
268
164
  const candidate = allowFrom.find((entry) => entry && entry !== '*');
269
165
  if (candidate) {
270
166
  return { ok: true, to: candidate };
271
167
  }
272
168
  }
273
- // 没有找到有效目标,返回错误
274
169
  return {
275
170
  ok: false,
276
171
  error: new Error(`No delivery target for ${CHANNEL_KEY}. Specify a target with --to or configure allowFrom.`),
277
172
  };
278
173
  },
279
174
  },
280
- // 附加结果处理
281
175
  attachedResults: {
282
- channel: CHANNEL_KEY, // 频道标识
283
- /**
284
- * 发送文本消息
285
- *
286
- * @async
287
- * @param {Object} param0 - 参数对象
288
- * @param {string} param0.to - 目标地址
289
- * @param {string} param0.text - 文本内容
290
- * @param {string} param0.accountId - 账户ID
291
- * @param {string} param0.replyToId - 回复消息ID
292
- * @param {OpenClawConfig} param0.cfg - 配置信息
293
- * @returns {Promise<any>} 发送结果
294
- */
176
+ channel: CHANNEL_KEY,
295
177
  sendText: async ({ to, text, accountId, replyToId, cfg }) => {
296
178
  return sendText({ to, text, accountId, replyToId, cfg });
297
179
  },
298
- /**
299
- * 发送媒体消息
300
- *
301
- * @async
302
- * @param {Object} param0 - 参数对象
303
- * @param {string} param0.to - 目标地址
304
- * @param {string} param0.text - 文本内容
305
- * @param {string} param0.mediaUrl - 媒体文件URL
306
- * @param {string} param0.accountId - 账户ID
307
- * @param {string} param0.replyToId - 回复消息ID
308
- * @param {OpenClawConfig} param0.cfg - 配置信息
309
- * @returns {Promise<any>} 发送结果
310
- */
311
180
  sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
312
181
  return sendMedia({ to, text, mediaUrl, accountId, replyToId, cfg });
313
182
  },
@@ -145,6 +145,29 @@ export const API_PATH_DOWNLOAD = '/drive/preview';
145
145
  export const EVENT_MESSAGE_PRIVATE = 'message:private';
146
146
  export const EVENT_HISTORY_REQUEST = 'message:history:request';
147
147
  export const EVENT_HISTORY_RESPONSE = 'message:history:response';
148
+ export const EVENT_SESSIONS_REQUEST = 'sessions:request';
149
+ export const EVENT_SESSIONS_RESPONSE = 'sessions:response';
150
+ // ============================================================
151
+ // 文件下载信令(kind 字段值)
152
+ // ============================================================
153
+ // 业务数据统一放入消息的 extra.transferData 字段,不污染顶层协议。
154
+ /**
155
+ * 文件下载信令统一 kind。
156
+ *
157
+ * 所有下载相关消息(请求 / 就绪 / URL / 错误)都使用同一 kind,
158
+ * 通过 `extra.transferData.status` 区分四种状态:
159
+
160
+ */
161
+ export const KIND_FILE_DOWNLOAD = 'file:download';
162
+ /** 下载信令 status 枚举(对应旧协议的四个 kind) */
163
+ export const FILE_DOWNLOAD_STATUS = {
164
+ REQ: 'download_req',
165
+ READY: 'download_ready',
166
+ URL: 'download_url',
167
+ ERROR: 'download_error',
168
+ };
169
+ /** localfile:// URI 前缀(AI 生成文件 / upload-tool 标识,前端据此发起下载信令) */
170
+ export const LOCALFILE_SCHEME = 'localfile://';
148
171
  // ============================================================
149
172
  // 超时 & 限制配置
150
173
  // ============================================================
@@ -56,13 +56,13 @@ export async function uploadFileToCos(localPath, config = {}, customFileName) {
56
56
  });
57
57
  if (!response.ok) {
58
58
  const text = await response.text().catch(() => "");
59
- throw new Error(`Upload failed (HTTP ${response.status}): ${text}`);
59
+ throw new Error(`Upload failed (HTTP ${response.ok} : ${response.status}): ${text}`);
60
60
  }
61
61
  const result = (await response.json());
62
62
  if (result.code === 0 && result.data?.uploaded) {
63
63
  return { url: `${COS_BASE_URL}${API_PATH_DOWNLOAD}?filePath=${filePath}`, filePath, isUploaded: true };
64
64
  }
65
- throw new Error(`Upload failed (HTTP ${response.status}): ${result.data}`);
65
+ throw new Error(`Upload failed other (HTTP ${response.status}): ${result.data}`);
66
66
  }
67
67
  finally {
68
68
  clearTimeout(timeoutId);
@@ -95,25 +95,26 @@ async function resolveBotClientId(apiKey, log) {
95
95
  */
96
96
  export async function startGateway(ctx) {
97
97
  const { account, abortSignal, onReady, onDisconnect, onEvent, log } = ctx;
98
+ const prefix = `[${CHANNEL_KEY}:${account.accountId}]`;
98
99
  // 判断是否存在有效的apikey
99
100
  if (!account.apiKey) {
100
- log?.error(`[${CHANNEL_KEY}:${account.accountId}] apiKey is not exist`);
101
- throw new Error(`[${CHANNEL_KEY}] Missing apiKey in config`);
101
+ log?.error(`${prefix} apiKey is not exist`);
102
+ throw new Error(`${prefix} Missing apiKey in config`);
102
103
  }
103
104
  // 判断 accountId 在缓存中是否存在,避免同一账号同时持有多个 WebSocket 连接
104
105
  // 防重入:如果同一 accountId 已有活跃 gateway,先销毁旧实例再创建新实例
105
106
  const existingCleanup = activeGateways.get(account.accountId);
106
107
  if (existingCleanup) {
107
- log?.warn(`[${CHANNEL_KEY}:${account.accountId}] Destroying existing gateway for account ${account.accountId} before creating new one`);
108
+ log?.warn(`${prefix} Destroying existing gateway for account ${account.accountId} before creating new one`);
108
109
  existingCleanup();
109
110
  }
110
111
  // 通过 HTTP 接口获取 Bot 的 clientId(botClientId)
111
112
  // 新配置格式:accountId 即为 uin,apiKey 与 uin 一一对应,直接构建映射表
112
- log?.info(`[${CHANNEL_KEY}: ${account.accountId}] Resolving botClientId for account ${account.accountId}...`);
113
+ log?.info(`${prefix} Resolving botClientId for account ${account.accountId}...`);
113
114
  const { botClientId, ticket } = await resolveBotClientId(account.apiKey, log);
114
115
  // 构建 uin→apiKey 映射表(新格式下每个 account 只有一条记录)
115
116
  const apiKeyMap = new Map([[account.accountId, account.apiKey]]);
116
- log?.info(`[${CHANNEL_KEY}] Bot clientId: ${botClientId}, uin=${account.accountId} → apiKey=***${account.apiKey.slice(-4)}`);
117
+ log?.info(`${prefix} Bot clientId: ${botClientId}, uin=${account.accountId} → apiKey=***${account.apiKey.slice(-4)}`);
117
118
  // 将映射表写入全局 config 模块,供 inbound 处理时按 uin 查找对应 apiKey
118
119
  setApiKeyMap(apiKeyMap, account.apiKey);
119
120
  /** Gateway 是否已被 abort(用于终止消息处理循环) */
@@ -130,7 +131,6 @@ export async function startGateway(ctx) {
130
131
  * 底层 emit:将 PrivateMessageData 通过 ReliableEmitter 发送给客户端。
131
132
  * - 统一对 content 字段执行 formatCosUrls(将内部 COS 路径转换为公网 URL)
132
133
  * - 所有消息都走可靠发送队列,入队即视为"发送成功",实际 ACK 由 ReliableEmitter 保证
133
- * - 需求约束:所有出站消息必须携带 agentId。调用方未显式指定时,回退到包装 emitter 绑定的 agentId。
134
134
  */
135
135
  const emit = (data) => {
136
136
  // 完整发送日志由 ReliableEmitter 打印(含 idempotencyKey)
@@ -147,8 +147,7 @@ export async function startGateway(ctx) {
147
147
  * @param targetId - 接收方用户 ID
148
148
  * @param text - 回复文本内容
149
149
  * @param replyToMsgId - 可选,回复对应的原始消息 ID(用于前端展示引用关系)
150
- * @param agentId - 可选,指定此条出站消息归属的 Agent ID
151
- * 缺省时由上层包装 emitter 注入,所有出站消息都将携带 agentId。
150
+ * @param agentId - 可选,目标 Agent ID
152
151
  */
153
152
  const sendReply = (targetId, text, replyToMsgId, agentId) => {
154
153
  log?.info(`[${CHANNEL_KEY}] sendReply: ${text} to ${targetId} (replyTo: ${replyToMsgId || 'none'})`);
@@ -170,7 +169,7 @@ export async function startGateway(ctx) {
170
169
  * @param text - 消息文本(可为空字符串)
171
170
  * @param files - 文件附件列表({name, mimeType, bytes} 格式)
172
171
  * @param replyToMsgId - 可选,回复对应的原始消息 ID
173
- * @param agentId - 可选,指定此条出站消息归属的 Agent ID
172
+ * @param agentId - 可选,目标 Agent ID
174
173
  */
175
174
  const sendFiles = (targetId, text, files, replyToMsgId, agentId) => {
176
175
  return emit({
@@ -202,7 +201,7 @@ export async function startGateway(ctx) {
202
201
  catch (err) {
203
202
  log?.error(`[${CHANNEL_KEY}] Message handler error: ${err}`);
204
203
  try {
205
- emitter.sendReply(msg.senderId, "消息处理异常,请稍后重试。", msg.messageId, msg.agentId);
204
+ emitter.sendReply(msg.senderId, "消息处理异常,请稍后重试。", msg.messageId);
206
205
  emitter.emit({
207
206
  msgId: generateMsgId(),
208
207
  from: emitter.botClientId,
@@ -256,7 +255,7 @@ export async function startGateway(ctx) {
256
255
  // 使用原生 WebSocket 封装层建立连接
257
256
  const socket = new NativeSocketClient(WS_URL, {
258
257
  // 认证:ticket 作为 URL query 参数,替代 Authorization header
259
- path: `${SOCKET_PATH}${ticketQuery}`,
258
+ path: `${SOCKET_PATH}${ticketQuery}&enableMultiLogin=false`,
260
259
  // 注入日志对象,便于 NativeSocketClient 内部打印连接/重连/错误等诊断信息
261
260
  log,
262
261
  logPrefix: `[${CHANNEL_KEY}:${account.accountId}:NativeSocket]`,
@@ -7,7 +7,7 @@
7
7
  * - 并发由 session 写锁 + followup queue 保证;
8
8
  * - /stop 及自然语言 abort 由 tryFastAbortFromMessage 统一处理并递归 kill subagent。
9
9
  */
10
- import { emitSignal } from './types.js';
10
+ import { emitSignal } from './utils/common.js';
11
11
  import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID } from './config.js';
12
12
  import { getLightclawRuntime } from './runtime.js';
13
13
  import { createChannelReplyPipeline } from 'openclaw/plugin-sdk/channel-reply-pipeline';
@@ -77,24 +77,7 @@ export function createInboundHandler(account, emitter, log) {
77
77
  const targetId = msg.senderId;
78
78
  // 2. 提前发 typing_start(作为"已读回执",减少 COS 下载/LLM TTFT 期间的无反馈感)
79
79
  const replyMsgId = generateMsgId();
80
- // route.agentId 作为整条消息处理链路的绑定默认值,所有出站消息均携带此 agentId
81
- const effectiveAgentId = route.agentId ?? resolvedAgentId ?? DEFAULT_AGENT_ID;
82
- // 基于上层 gateway 的 emitter,构造一个绑定当前 agentId 的包装 emitter
83
- // 所有调用 emit / sendReply / sendFiles 未显式传 agentId 时,消息体都会自动注入 effectiveAgentId
84
- const boundEmitter = {
85
- botClientId: emitter.botClientId,
86
- agentId: effectiveAgentId,
87
- emit: (data) => emitter.emit({ ...data, agentId: data.agentId ?? effectiveAgentId }),
88
- sendReply: (tid, text, replyTo, agentId) => emitter.sendReply(tid, text, replyTo, agentId ?? effectiveAgentId),
89
- sendFiles: (tid, text, files, replyTo, agentId) => emitter.sendFiles(tid, text, files, replyTo, agentId ?? effectiveAgentId),
90
- };
91
- const signalCtx = {
92
- emitter: boundEmitter,
93
- targetId,
94
- replyMsgId,
95
- originalMsgId: msg.messageId,
96
- agentId: effectiveAgentId,
97
- };
80
+ const signalCtx = { emitter, targetId, replyMsgId, originalMsgId: msg.messageId, agentId: resolvedAgentId };
98
81
  emitSignal(signalCtx, 'typing_start');
99
82
  // 3. 处理文件附件(files[] → 本地存储 + 公网 URL)
100
83
  const localMediaPaths = [];
@@ -241,14 +224,13 @@ export function createInboundHandler(account, emitter, log) {
241
224
  });
242
225
  // 7. 构建流式 dispatcher
243
226
  const { dispatcher, replyOptions: streamReplyOptions, hasEmittedContent, } = createStreamReplyConfig({
244
- emitter: boundEmitter,
227
+ emitter,
245
228
  targetId,
246
229
  replyMsgId,
247
230
  originalMsgId: msg.messageId,
248
231
  log,
249
232
  effectiveApiKey,
250
233
  typingAlreadyStarted: true,
251
- agentId: effectiveAgentId,
252
234
  }, { ...prefixOptions, onModelSelected }, signalCtx);
253
235
  // 8. 分发给 AI 引擎(真流式)。abort 由 openclaw fast-abort 处理,sink 层
254
236
  // 把英文回执汉化;此处仅兜底自身抛错。
@@ -268,7 +250,7 @@ export function createInboundHandler(account, emitter, log) {
268
250
  log?.error(`[${CHANNEL_KEY}] Dispatch error: ${errMsg}`);
269
251
  // 已推过可见内容时不再追加错误文案,避免打断阅读。
270
252
  if (!hasEmittedContent()) {
271
- emitter.sendReply(targetId, '抱歉,处理您的消息时出现了问题,请稍后重试', msg.messageId, effectiveAgentId);
253
+ emitter.sendReply(targetId, '抱歉,处理您的消息时出现了问题,请稍后重试', msg.messageId);
272
254
  }
273
255
  }
274
256
  };
@@ -10,7 +10,7 @@
10
10
  * - 连接断开但 entry 存在(重连中)→ 缓冲消息,重连后自动 flush
11
11
  * 2. WS 不可用时 fallback 到 REST API(需配置 apiBaseUrl)
12
12
  */
13
- import { CHANNEL_KEY, DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID, EVENT_MESSAGE_PRIVATE } from './config.js';
13
+ import { CHANNEL_KEY, DEFAULT_ACCOUNT_ID, EVENT_MESSAGE_PRIVATE } from './config.js';
14
14
  import { getLightclawRuntime } from './runtime.js';
15
15
  import { getSocket, bufferMessage, hasEntry, getBotClientId, getReliableEmitter } from './socket/index.js';
16
16
  import { resolveAccount } from './utils/index.js';
@@ -99,8 +99,6 @@ function sendViaSocket(accountId, target, text, replyToId) {
99
99
  timestamp: Date.now(),
100
100
  // undefined 表示非回复消息,避免发送多余字段
101
101
  replyToMsgId: replyToId ?? undefined,
102
- // 所有出站消息必须携带 agentId,outbound 主动发送场景使用默认 Agent
103
- agentId: DEFAULT_AGENT_ID,
104
102
  };
105
103
  if (entry) {
106
104
  // 策略 1:Socket 已连接,优先走可靠发送(emitWithAck + 自动重试)
@@ -1,29 +1,40 @@
1
1
  /**
2
- * LightClaw — 插件运行时存储模块
2
+ * LightClaw — 插件运行时(PluginRuntime)全局存储模块
3
+ * ---------------------------------------------------------------
4
+ * 作用:
5
+ * 该文件负责保存并暴露插件在运行期间唯一的 `PluginRuntime` 实例,
6
+ * 使项目内各个模块(如 inbound / socket handlers / channel 逻辑等)
7
+ * 都能通过统一入口获取到平台运行时对象,而无需层层传递。
3
8
  *
4
- * 使用 OpenClaw SDK 标准的 createPluginRuntimeStore 工具函数替代传统的手动单例管理模式,
5
- *
6
- * 该模块的主要功能:
7
- * - 提供类型安全的运行时存储管理
8
- * - 避免手动单例模式带来的潜在问题
9
- * - 提供统一的错误处理和初始化状态管理
10
- * - 支持插件运行时的动态设置和获取
9
+ * 使用方式:
10
+ * - 在插件启动阶段(通常在入口 `index.ts` 的 `onInit` 回调中)
11
+ * 调用 `setLightclawRuntime(api.runtime)` 完成注入;
12
+ * - 在业务模块中通过 `getLightclawRuntime()` 获取实例,
13
+ * 进而访问 `runtime.config` / `runtime.channel` 等能力。
11
14
  */
12
- // 导入运行时存储创建工具函数
15
+ // createPluginRuntimeStore:SDK 提供的通用工厂函数,
13
16
  import { createPluginRuntimeStore } from 'openclaw/plugin-sdk/runtime-store';
14
17
  /**
15
- * 创建 LightClaw 插件运行时存储
18
+ * 创建 LightClaw 专属的运行时存储实例。
19
+ *
20
+ * - 泛型 `PluginRuntime`:约束该 store 管理的对象形状;
21
+ * - 入参字符串:当调用方在未完成 `setRuntime` 之前就尝试 `getRuntime`,
22
+ * SDK 将抛出包含此错误信息的异常,便于快速定位初始化顺序问题。
23
+ *
24
+ * 通过解构重命名,将 SDK 通用的 `setRuntime / getRuntime`
25
+ * 改名为带插件前缀的 `setLightclawRuntime / getLightclawRuntime`,
26
+ * 避免与其它插件(或多插件共存场景)产生命名冲突,提高可读性。
27
+ */
28
+ const { setRuntime: setLightclawRuntime, getRuntime: getLightclawRuntime } = createPluginRuntimeStore('LightClaw runtime not initialized');
29
+ /**
30
+ * 对外导出的运行时访问 API:
16
31
  *
17
- * 使用 createPluginRuntimeStore 工厂函数创建运行时存储对象,
18
- * 该函数返回一个包含 setRuntime 和 getRuntime 方法的对象。
32
+ * @function getLightclawRuntime
33
+ * 获取当前已注入的 PluginRuntime 实例。
34
+ * 若未初始化则抛出错误(见上方错误信息)。
19
35
  *
20
- * @template PluginRuntime - 运行时对象的类型参数
21
- * @param {string} 'LightClaw runtime not initialized' - 运行时未初始化时的错误消息
22
- * @returns {Object} 包含 setRuntime 和 getRuntime 方法的对象
23
- * @property {Function} setRuntime - 设置运行时实例的函数
24
- * @property {Function} getRuntime - 获取运行时实例的函数(如果未设置会抛出错误)
36
+ * @function setLightclawRuntime
37
+ * 在插件初始化阶段调用,将平台传入的 runtime 注入到全局存储中。
38
+ * 通常只应由插件入口调用一次,后续模块只读取不写入。
25
39
  */
26
- const { setRuntime: setLightclawRuntime, getRuntime: getLightclawRuntime } = createPluginRuntimeStore('LightClaw runtime not initialized' // 运行时未初始化时的自定义错误消息
27
- );
28
- // 导出运行时管理函数,供其他模块使用
29
40
  export { getLightclawRuntime, setLightclawRuntime };
@@ -12,10 +12,14 @@
12
12
  * 所有出站 socket.emit 通过 ReliableEmitter 实现 ACK 确认 + 自动重试,
13
13
  * 保证消息在网络抖动时不丢失。
14
14
  */
15
- import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, EVENT_HISTORY_REQUEST, EVENT_HISTORY_RESPONSE, DEFAULT_HISTORY_LIMIT, DEFAULT_AGENT_ID, } from '../config.js';
15
+ import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, EVENT_HISTORY_REQUEST, EVENT_HISTORY_RESPONSE, DEFAULT_HISTORY_LIMIT, DEFAULT_AGENT_ID, EVENT_SESSIONS_REQUEST, EVENT_SESSIONS_RESPONSE, KIND_FILE_DOWNLOAD, FILE_DOWNLOAD_STATUS, resolveEffectiveApiKey } from '../config.js';
16
16
  import { isDuplicate, debounceHistoryRequest, generateMsgId } from '../dedup.js';
17
17
  import { getLightclawRuntime } from '../runtime.js';
18
- import { readSessionHistoryWithCron } from '../history/index.js';
18
+ import { readSessionHistoryWithCron, listSessions } from '../history/index.js';
19
+ import { uploadFileToCos } from '../file-storage.js';
20
+ import { guessMimeByExt } from '../media.js';
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
19
23
  /**
20
24
  * 绑定所有 Socket.IO 事件监听器到指定 socket 实例。
21
25
  *
@@ -39,7 +43,21 @@ export function bindSocketHandlers(socket, deps) {
39
43
  // ① 回环防御:过滤 bot 自身发出的消息,防止自问自答死循环
40
44
  if (data.from === botClientId)
41
45
  return;
42
- // ② 跳过控制消息(如 typing 状态、stream 信号等),只处理 kind=text 的真实用户消息
46
+ // ② 分发文件下载信令(kind=file:download, status=download_req):独立链路,不入 AI 处理队列
47
+ if (data.kind === KIND_FILE_DOWNLOAD) {
48
+ const reqTransferData = data.extra?.transferData;
49
+ if (reqTransferData?.status === FILE_DOWNLOAD_STATUS.REQ) {
50
+ // 去重:同一 transferId 的重复请求直接跳过
51
+ if (isDuplicate(data.msgId))
52
+ return;
53
+ onEvent?.();
54
+ void handleFileDownloadReq(data, botClientId, reliableEmitter, log);
55
+ }
56
+ // 其他 status(ready/url/error)是本端自己发出的下行消息,理论上不会从前端回流;
57
+ // 即便误触发,此处直接 return 不做处理,避免进入 AI 处理队列
58
+ return;
59
+ }
60
+ // ③ 跳过其他控制消息(如 typing 状态、stream 信号等),只处理 kind=text 的真实用户消息
43
61
  if (data.kind && data.kind !== 'text')
44
62
  return;
45
63
  // ③ 内容校验:消息既无文字内容也无附件文件时,直接丢弃
@@ -145,9 +163,139 @@ export function bindSocketHandlers(socket, deps) {
145
163
  sessionKey: '',
146
164
  messages: [],
147
165
  error: err instanceof Error ? err.message : String(err),
148
- agentId: data.agentId,
166
+ agentId: data.agentId || DEFAULT_AGENT_ID,
149
167
  }, errorMsgId);
150
168
  }
151
169
  });
152
170
  });
171
+ socket.on(EVENT_SESSIONS_REQUEST, (data) => {
172
+ // 通知框架收到入站事件(更新 lastEventAt,防止 stale-socket 误判)
173
+ onEvent?.();
174
+ try {
175
+ const sessions = listSessions();
176
+ const sessionsMsgId = generateMsgId();
177
+ reliableEmitter.emitWithAck(EVENT_SESSIONS_RESPONSE, {
178
+ requestId: data.requestId,
179
+ sessions,
180
+ msgId: sessionsMsgId,
181
+ }, sessionsMsgId);
182
+ }
183
+ catch (err) {
184
+ log?.error(`[${CHANNEL_KEY}] Sessions list error: ${err}`);
185
+ const sessionsErrMsgId = generateMsgId();
186
+ reliableEmitter.emitWithAck(EVENT_SESSIONS_RESPONSE, {
187
+ requestId: data.requestId,
188
+ sessions: [],
189
+ error: err instanceof Error ? err.message : String(err),
190
+ msgId: sessionsErrMsgId,
191
+ }, sessionsErrMsgId);
192
+ }
193
+ });
194
+ }
195
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
196
+ // 文件下载处理器(kind=file:download, status=download_req)
197
+ //
198
+ // 流程:
199
+ // 1. 从 data.extra.transferData 解析 transferId / localPath
200
+ // 2. 基础校验:必须是绝对路径,文件必须存在
201
+ // 3. 先回告 download_ready 帧(transferId + 文件元数据),前端据此更新下载按钮状态
202
+ // 4. 通过 uploadFileToCos 将本机文件上传到 ai-server,拿到下载 URL
203
+ // 5. 回告 download_url 帧(transferId + url),前端触发浏览器原生下载
204
+ // 6. 任一阶段失败 → 回告 download_error 帧(transferId + error message)
205
+ //
206
+ // 备注:不走目录白名单校验,因为 localPath 来自 AI 工具返回的 localfile:// 链接,
207
+ // AI 只会返回自己刚写盘的文件;上传到 ai-server 后会经过一轮内容审核兜底。
208
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
209
+ async function handleFileDownloadReq(data, botClientId, reliableEmitter, log) {
210
+ const transferData = data.extra?.transferData;
211
+ const transferId = transferData?.transferId;
212
+ const localPath = transferData?.localPath;
213
+ log?.info(`[${CHANNEL_KEY}] file:download(req) received: transferId=${transferId}, localPath=${localPath}, from=${data.from}`);
214
+ const sendError = (error) => {
215
+ const msgId = generateMsgId();
216
+ reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, {
217
+ msgId,
218
+ from: botClientId,
219
+ to: data.from,
220
+ content: '',
221
+ timestamp: Date.now(),
222
+ kind: KIND_FILE_DOWNLOAD,
223
+ extra: { transferData: { transferId, status: FILE_DOWNLOAD_STATUS.ERROR, error } },
224
+ }, msgId);
225
+ log?.error(`[${CHANNEL_KEY}] file:download(error) sent: transferId=${transferId}, error=${error}`);
226
+ };
227
+ if (!transferId || !localPath) {
228
+ sendError('Missing transferId or localPath in extra.transferData');
229
+ return;
230
+ }
231
+ // 基础校验:必须是绝对路径
232
+ if (!path.isAbsolute(localPath)) {
233
+ sendError(`localPath must be an absolute path: ${localPath}`);
234
+ return;
235
+ }
236
+ const resolvedPath = path.resolve(localPath);
237
+ if (!fs.existsSync(resolvedPath)) {
238
+ sendError(`File not found: ${resolvedPath}`);
239
+ return;
240
+ }
241
+ const stat = fs.statSync(resolvedPath);
242
+ if (!stat.isFile()) {
243
+ sendError(`Not a regular file: ${resolvedPath}`);
244
+ return;
245
+ }
246
+ const fileName = path.basename(resolvedPath);
247
+ const mimeType = guessMimeByExt(path.extname(fileName).toLowerCase()) || 'application/octet-stream';
248
+ // 回告 download_ready(文件确认存在 + 元数据)
249
+ const readyMsgId = generateMsgId();
250
+ reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, {
251
+ msgId: readyMsgId,
252
+ from: botClientId,
253
+ to: data.from,
254
+ content: '',
255
+ timestamp: Date.now(),
256
+ kind: KIND_FILE_DOWNLOAD,
257
+ extra: {
258
+ transferData: {
259
+ transferId,
260
+ status: FILE_DOWNLOAD_STATUS.READY,
261
+ name: fileName,
262
+ size: stat.size,
263
+ contentType: mimeType,
264
+ },
265
+ },
266
+ }, readyMsgId);
267
+ log?.info(`[${CHANNEL_KEY}] file:download(ready) sent: transferId=${transferId}, name=${fileName}, size=${stat.size}`);
268
+ // 上传本机文件到 ai-server(含审核),成功后把 URL 回告前端
269
+ try {
270
+ // 通过 senderId 解析真实 apiKey,用于 /drive/save 鉴权
271
+ const apiKey = resolveEffectiveApiKey({ senderId: data.from });
272
+ const uploadResult = await uploadFileToCos(resolvedPath, { apiKey });
273
+ if (!uploadResult.isUploaded || !uploadResult.url) {
274
+ sendError('Upload to ai-server failed');
275
+ return;
276
+ }
277
+ const urlMsgId = generateMsgId();
278
+ reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, {
279
+ msgId: urlMsgId,
280
+ from: botClientId,
281
+ to: data.from,
282
+ content: '',
283
+ timestamp: Date.now(),
284
+ kind: KIND_FILE_DOWNLOAD,
285
+ extra: {
286
+ transferData: {
287
+ transferId,
288
+ status: FILE_DOWNLOAD_STATUS.URL,
289
+ url: uploadResult.url,
290
+ name: fileName,
291
+ size: stat.size,
292
+ contentType: mimeType,
293
+ },
294
+ },
295
+ }, urlMsgId);
296
+ log?.info(`[${CHANNEL_KEY}] file:download(url) sent: transferId=${transferId}, url=${uploadResult.url}`);
297
+ }
298
+ catch (err) {
299
+ sendError(err instanceof Error ? err.message : String(err));
300
+ }
153
301
  }
@@ -16,7 +16,7 @@ import { CHANNEL_KEY } from "../config.js";
16
16
  import { uploadFileToCos } from "../file-storage.js";
17
17
  import { mediaUrlsToFiles } from "../media.js";
18
18
  import { createDeltaTrackerState, toStreamDeltaText } from "./delta-tracker.js";
19
- import { emitSignal } from "../types.js";
19
+ import { emitSignal } from "../utils/common.js";
20
20
  /** 派生/管理 subagent 的工具名;前端可据此特化渲染 tool_start。 */
21
21
  const SUBAGENT_TOOL_NAMES = new Set([
22
22
  "sessions_spawn",
@@ -30,7 +30,7 @@ function localizeAbortReplyText(text) {
30
30
  return OPENCLAW_ABORT_REPLY_RE.test(text.trim()) ? LOCALIZED_ABORT_REPLY : text;
31
31
  }
32
32
  export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
33
- const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, agentId } = opts;
33
+ const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted } = opts;
34
34
  // ── 增量追踪 & 已推送文本 ──
35
35
  let partialReplyState = createDeltaTrackerState();
36
36
  let streamedText = "";
@@ -110,11 +110,11 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
110
110
  enrichedText = enrichedText ? `${enrichedText}\n\n${urlSection}` : urlSection;
111
111
  }
112
112
  if (files.length > 0) {
113
- emitter.sendFiles(targetId, enrichedText, files, originalMsgId, agentId);
113
+ emitter.sendFiles(targetId, enrichedText, files, originalMsgId);
114
114
  emittedUserVisible = true;
115
115
  }
116
116
  else if (enrichedText.trim()) {
117
- emitter.sendReply(targetId, enrichedText, originalMsgId, agentId);
117
+ emitter.sendReply(targetId, enrichedText, originalMsgId);
118
118
  emittedUserVisible = true;
119
119
  }
120
120
  };
@@ -256,7 +256,7 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
256
256
  : "LLM only produced thinking, no visible text";
257
257
  log?.warn(`[${CHANNEL_KEY}] [stream] NO_REPLY: counts=${JSON.stringify(counts)} ` +
258
258
  `toolStart=${toolStartCount} assistantMsg=${assistantMessageCount}. Cause: ${cause}`);
259
- emitter.sendReply(targetId, "NO_REPLY", originalMsgId, agentId);
259
+ emitter.sendReply(targetId, "NO_REPLY", originalMsgId);
260
260
  }
261
261
  else {
262
262
  log?.info(`[${CHANNEL_KEY}] [stream] markComplete: counts=${JSON.stringify(counts)} ` +
package/dist/src/types.js CHANGED
@@ -8,24 +8,4 @@
8
8
  *
9
9
  * @format
10
10
  */
11
- /**
12
- * 统一的信号发送出口,收敛 typing / stream / tool 控制帧的构造逻辑。
13
- *
14
- * - typing_start 不带 replyToMsgId(协议要求);其余帧都携带。
15
- * - extra 用于透传 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。
16
- */
17
- export function emitSignal(ctx, kind, content = "", extra) {
18
- const { emitter, targetId, replyMsgId, originalMsgId, agentId } = ctx;
19
- return emitter.emit({
20
- msgId: replyMsgId,
21
- from: emitter.botClientId,
22
- to: targetId,
23
- content,
24
- timestamp: Date.now(),
25
- kind,
26
- ...(kind !== "typing_start" ? { replyToMsgId: originalMsgId } : {}),
27
- // 所有出站信号帧统一携带 agentId,优先使用 signalCtx.agentId,否则回退 emitter 绑定值
28
- ...(agentId || emitter.agentId ? { agentId: agentId ?? emitter.agentId } : {}),
29
- ...extra,
30
- });
31
- }
11
+ export {};
@@ -1,16 +1,20 @@
1
1
  /**
2
- * LightClaw — 文件上传工具
2
+ * LightClaw — 文件注册工具
3
3
  *
4
- * 注册为 OpenClaw Agent Tool,允许 AI 将本地文件上传到云端存储,
5
- * 获得公网可访问的 URL,方便用户直接下载。
4
+ * 注册为 OpenClaw Agent ToolAI 生成本机文件后调用此工具,
5
+ * 返回 `localfile://<绝对路径>` 格式的 Markdown 下载链接。AI 将链接
6
+ * 原样写入回复,前端识别 `localfile://` 前缀后通过 WS file:download 信令
7
+ * (status=download_req)让 lightclaw 按需上传到 ai-server 再推回浏览器下载。
6
8
  *
7
9
  * 工具名: lightclaw_upload_file
10
+ *
11
+ * 备注:名字保留 "upload" 以兼容现有 skill 表述,实际不再主动上传 COS,
12
+ * 只在本机做存在性校验并生成标准格式的 Markdown 链接。
8
13
  */
9
- import * as fs from "node:fs";
14
+ import * as fsp from "node:fs/promises";
10
15
  import * as path from "node:path";
11
- import { uploadFileToCos } from "./file-storage.js";
12
16
  import { formatFileSize } from "./media.js";
13
- import { resolveEffectiveApiKey } from "./config.js";
17
+ import { LOCALFILE_SCHEME } from "./config.js";
14
18
  // ============================================================
15
19
  // 工具参数 schema(JSON Schema 格式)
16
20
  // ============================================================
@@ -23,13 +27,13 @@ export const uploadToolSchema = {
23
27
  items: { type: "string" },
24
28
  minItems: 1,
25
29
  maxItems: 5,
26
- description: "需要上传的本地文件绝对路径数组,文件必须已写入磁盘,最多 5 个文件",
30
+ description: "需要注册的本地文件绝对路径数组,文件必须已写入磁盘,最多 5 个文件",
27
31
  },
28
32
  },
29
33
  required: ["paths"],
30
34
  };
31
35
  // ============================================================
32
- // 工具注册(飞书工厂函数模式)
36
+ // 工具注册(工厂函数模式)
33
37
  // ============================================================
34
38
  export function registerUploadTool(api) {
35
39
  // 通过闭包捕获 api.logger,tool ctx 中没有 log
@@ -37,22 +41,16 @@ export function registerUploadTool(api) {
37
41
  api.registerTool((ctx) => {
38
42
  // 硬隔离:非 lightclawbot 通道时直接返回 null,框架不会暴露此工具
39
43
  if (ctx.messageChannel !== "lightclawbot") {
40
- // log.warn(`[lightclaw_upload_file] channel=${ctx.messageChannel}, skip..."`);
41
44
  return null;
42
45
  }
43
- const defaultAccountId = ctx.agentAccountId;
44
- const sessionKey = ctx.sessionKey;
45
- // log.warn(`[lightclaw_upload_file] channel=${ctx.messageChannel}, contiue..."`);
46
46
  return {
47
47
  name: UPLOAD_TOOL_NAME,
48
- description: "将本地文件上传到云端临时存储并返回下载链接。" +
48
+ description: "将 AI 生成的本机文件注册为可下载的引用,返回 `localfile://<绝对路径>` 格式的 Markdown 下载链接。" +
49
49
  "当需要把生成的文件(图片、文档、代码包等)交付给用户时,必须使用此工具。" +
50
- "文件必须先写入磁盘,然后传入绝对路径。单次最多 5 个文件,超出请分批调用。",
50
+ "文件必须先写入磁盘,然后传入绝对路径。单次最多 5 个文件,超出请分批调用。" +
51
+ "重要:工具返回的 Markdown 链接已是最终格式,请原样使用,不要改写为 https:// 或本机路径字符串。",
51
52
  parameters: uploadToolSchema,
52
53
  async execute(_toolCallId, params) {
53
- // 每次 execute 时动态解析 apiKey(多 key 模式下通过 sessionKey 直接获取)
54
- const apiKey = resolveEffectiveApiKey({ sessionKey });
55
- // log.warn(`[lightclaw_upload_file] resolved apiKey="${apiKey?.slice(0, 8)}..."`);
56
54
  const { paths } = params;
57
55
  // 参数校验
58
56
  if (!Array.isArray(paths) || paths.length === 0) {
@@ -65,78 +63,55 @@ export function registerUploadTool(api) {
65
63
  content: [{ type: "text", text: "Error: maximum 5 files per upload." }],
66
64
  };
67
65
  }
68
- // 验证所有文件存在
69
- const validationErrors = [];
70
- for (const p of paths) {
66
+ // 并发校验所有路径并顺便拿到 stat
67
+ // - 路径合法性(绝对路径、非空字符串):同步判定
68
+ // - 文件存在 + 必须是 regular file:靠 fsp.stat 抛错 / isFile() 判定
69
+ const results = await Promise.all(paths.map(async (p) => {
71
70
  if (typeof p !== "string" || !p.trim()) {
72
- validationErrors.push(`Invalid path: empty or non-string`);
73
- continue;
71
+ return { path: p, error: `Invalid path: empty or non-string` };
74
72
  }
75
- if (!fs.existsSync(p)) {
76
- validationErrors.push(`File not found: ${p}`);
77
- continue;
73
+ if (!p.startsWith("/") && !/^[A-Za-z]:\\/.test(p)) {
74
+ return { path: p, error: `Path must be absolute: ${p}` };
78
75
  }
79
- const stat = fs.statSync(p);
80
- if (!stat.isFile()) {
81
- validationErrors.push(`Not a regular file: ${p}`);
76
+ try {
77
+ const stat = await fsp.stat(p);
78
+ if (!stat.isFile()) {
79
+ return { path: p, error: `Not a regular file: ${p}` };
80
+ }
81
+ return { path: p, stat };
82
82
  }
83
- }
83
+ catch (err) {
84
+ const code = err?.code;
85
+ if (code === 'ENOENT') {
86
+ return { path: p, error: `File not found: ${p}` };
87
+ }
88
+ return { path: p, error: `Stat failed: ${p} (${err.message})` };
89
+ }
90
+ }));
91
+ const validationErrors = results
92
+ .filter((r) => 'error' in r)
93
+ .map((r) => r.error);
84
94
  if (validationErrors.length > 0) {
85
95
  return {
86
96
  content: [{ type: "text", text: `Validation errors:\n${validationErrors.join("\n")}` }],
87
97
  };
88
98
  }
89
- // 并发上传(最多 3 个并发)
90
- const results = [];
91
- const concurrency = 3;
92
- for (let i = 0; i < paths.length; i += concurrency) {
93
- const batch = paths.slice(i, i + concurrency);
94
- const batchResults = await Promise.allSettled(batch.map(async (filePath) => {
95
- const stat = fs.statSync(filePath);
96
- const uploadResult = await uploadFileToCos(filePath, { apiKey });
97
- return {
98
- path: filePath,
99
- success: true,
100
- url: uploadResult.url,
101
- filePath: uploadResult.filePath,
102
- size: formatFileSize(stat.size),
103
- };
104
- }));
105
- for (let j = 0; j < batchResults.length; j++) {
106
- const r = batchResults[j];
107
- if (r.status === "fulfilled") {
108
- results.push(r.value);
109
- }
110
- else {
111
- results.push({
112
- path: batch[j],
113
- success: false,
114
- error: r.reason instanceof Error ? r.reason.message : String(r.reason),
115
- });
116
- }
117
- }
118
- }
119
- // 构建结果文本(使用 Markdown 链接格式,引导模型原样输出给用户)
120
- const lines = [];
121
- const successResults = [];
122
- for (const r of results) {
123
- const name = path.basename(r.path);
124
- if (r.success && r.url) {
125
- lines.push(`✅ [${name}](${r.url}) (${r.size})`);
126
- successResults.push({ name, url: r.url, size: r.size });
127
- }
128
- else {
129
- lines.push(`❌ ${name}: ${r.error}`);
130
- }
131
- }
132
- const summary = results.length === 1
133
- ? successResults.length === 1
134
- ? `File uploaded successfully.\n\n[${successResults[0].name}](${successResults[0].url})`
135
- : `File upload failed: ${results[0].error}`
136
- : `Uploaded ${successResults.length}/${results.length} files.\n${lines.join("\n")}`;
99
+ // 基于本机路径生成 localfile:// 链接(不上传 COS),复用上一步已拿到的 stat
100
+ const successResults = results.map(({ path: filePath, stat }) => ({
101
+ name: path.basename(filePath),
102
+ path: filePath,
103
+ url: `${LOCALFILE_SCHEME}${filePath}`,
104
+ size: formatFileSize(stat.size),
105
+ }));
106
+ log?.info?.(`[${UPLOAD_TOOL_NAME}] registered ${successResults.length} file(s)`);
107
+ // 构建结果文本(Markdown 链接,引导模型原样输出给用户)
108
+ const lines = successResults.map((r) => `✅ [${r.name}](${r.url}) (${r.size})`);
109
+ const summary = successResults.length === 1
110
+ ? `File registered successfully.\n\n[${successResults[0].name}](${successResults[0].url})`
111
+ : `Registered ${successResults.length} files.\n${lines.join("\n")}`;
137
112
  return {
138
113
  content: [{ type: "text", text: summary }],
139
- details: { uploads: results },
114
+ details: { uploads: successResults },
140
115
  };
141
116
  },
142
117
  };
@@ -46,3 +46,23 @@ export function buildAuthHeaders(apiKey) {
46
46
  'x-product': X_PRODUCT,
47
47
  };
48
48
  }
49
+ /**
50
+ * 统一的信号发送出口,收敛 typing / stream / tool 控制帧的构造逻辑。
51
+ *
52
+ * - typing_start 不带 replyToMsgId(协议要求);其余帧都携带。
53
+ * - extra 用于透传 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。
54
+ */
55
+ export function emitSignal(ctx, kind, content = '', extra) {
56
+ const { emitter, targetId, replyMsgId, originalMsgId, agentId } = ctx;
57
+ return emitter.emit({
58
+ msgId: replyMsgId,
59
+ from: emitter.botClientId,
60
+ to: targetId,
61
+ content,
62
+ timestamp: Date.now(),
63
+ kind,
64
+ ...(kind !== 'typing_start' ? { replyToMsgId: originalMsgId } : {}),
65
+ ...extra,
66
+ agentId,
67
+ });
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightclawbot",
3
- "version": "1.2.0-beta.0",
3
+ "version": "1.2.0-beta.2",
4
4
  "description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging",
5
5
  "type": "module",
6
6
  "files": [