lightclawbot 1.2.0-beta.1 → 1.2.0-beta.3

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
  // 运行时设置函数,用于配置插件运行时的环境和参数
@@ -32,7 +32,7 @@ export const WS_URL = `wss://${DOMAIN}`;
32
32
  /** HTTP API 基础地址 */
33
33
  export const API_BASE_URL = `https://${DOMAIN}`;
34
34
  /** 文件存储相关配置 */
35
- export const COS_BASE_URL = `https://${DOMAIN}`;
35
+ export const SERVER_UPLOAD_BASE_URL = `https://${DOMAIN}`;
36
36
  // ============================================================
37
37
  // WebSocket 配置
38
38
  // ============================================================
@@ -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
  // ============================================================
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
- import { getFileDownloadUrl, downloadFileFromCos, uploadFileToCos, } from "./file-storage.js";
12
+ import { getFileDownloadUrl, downloadFileFromServer, uploadFileToServer, } from "./file-storage.js";
13
13
  import { formatFileSize } from "./media.js";
14
14
  import { resolveEffectiveApiKey } from "./config.js";
15
15
  // ============================================================
@@ -79,7 +79,7 @@ export function registerDownloadTool(api) {
79
79
  };
80
80
  }
81
81
  case "download_to_local": {
82
- const result = await downloadFileFromCos(filePath, { apiKey });
82
+ const result = await downloadFileFromServer(filePath, { apiKey });
83
83
  const targetDir = localDir || process.cwd();
84
84
  // 确保目录存在
85
85
  if (!fs.existsSync(targetDir)) {
@@ -107,7 +107,7 @@ export function registerDownloadTool(api) {
107
107
  content: [{ type: "text", text: `Error: local file not found: ${filePath}` }],
108
108
  };
109
109
  }
110
- const uploadResult = await uploadFileToCos(filePath, { apiKey });
110
+ const uploadResult = await uploadFileToServer(filePath, { apiKey });
111
111
  const fileName = path.basename(filePath);
112
112
  return {
113
113
  content: [{
@@ -10,7 +10,7 @@
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { guessMimeByExt } from "./media.js";
13
- import { COS_BASE_URL, API_PATH_UPLOAD, API_PATH_DOWNLOAD, UPLOAD_TIMEOUT, } from "./config.js";
13
+ import { SERVER_UPLOAD_BASE_URL, API_PATH_UPLOAD, API_PATH_DOWNLOAD, UPLOAD_TIMEOUT, } from "./config.js";
14
14
  import { buildAuthHeaders } from './utils/index.js';
15
15
  // ============================================================
16
16
  // 核心方法
@@ -23,7 +23,7 @@ import { buildAuthHeaders } from './utils/index.js';
23
23
  * @param customFileName - 自定义文件名(可选,默认使用原文件名)
24
24
  * @returns 上传结果,包含公网 URL
25
25
  */
26
- export async function uploadFileToCos(localPath, config = {}, customFileName) {
26
+ export async function uploadFileToServer(localPath, config = {}, customFileName) {
27
27
  // 验证文件存在
28
28
  if (!fs.existsSync(localPath)) {
29
29
  throw new Error(`File not found: ${localPath}`);
@@ -48,7 +48,7 @@ export async function uploadFileToCos(localPath, config = {}, customFileName) {
48
48
  const controller = new AbortController();
49
49
  const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT);
50
50
  try {
51
- const response = await fetch(`${COS_BASE_URL}${API_PATH_UPLOAD}`, {
51
+ const response = await fetch(`${SERVER_UPLOAD_BASE_URL}${API_PATH_UPLOAD}`, {
52
52
  method: "POST",
53
53
  body: formData,
54
54
  headers,
@@ -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
- return { url: `${COS_BASE_URL}${API_PATH_DOWNLOAD}?filePath=${filePath}`, filePath, isUploaded: true };
63
+ return { url: `${SERVER_UPLOAD_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);
@@ -77,7 +77,7 @@ export async function uploadFileToCos(localPath, config = {}, customFileName) {
77
77
  * @param config - 文件存储配置
78
78
  * @returns 上传结果,包含公网 URL
79
79
  */
80
- export async function uploadBufferToCos(buffer, fileName, mimeType, config = {}) {
80
+ export async function uploadBufferToServer(buffer, fileName, mimeType, config = {}) {
81
81
  const filePath = `${Date.now()}/${fileName}`;
82
82
  const { Blob: NodeBlob } = await import("node:buffer");
83
83
  const blob = new NodeBlob([new Uint8Array(buffer)], { type: mimeType });
@@ -88,7 +88,7 @@ export async function uploadBufferToCos(buffer, fileName, mimeType, config = {})
88
88
  const controller = new AbortController();
89
89
  const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT);
90
90
  try {
91
- const response = await fetch(`${COS_BASE_URL}${API_PATH_UPLOAD}`, {
91
+ const response = await fetch(`${SERVER_UPLOAD_BASE_URL}${API_PATH_UPLOAD}`, {
92
92
  method: "POST",
93
93
  body: formData,
94
94
  headers,
@@ -100,7 +100,7 @@ export async function uploadBufferToCos(buffer, fileName, mimeType, config = {})
100
100
  }
101
101
  const result = (await response.json());
102
102
  if (result.code === 0 && result.data?.uploaded) {
103
- return { url: `${COS_BASE_URL}${API_PATH_DOWNLOAD}?filePath=${filePath}`, filePath, isUploaded: true };
103
+ return { url: `${SERVER_UPLOAD_BASE_URL}${API_PATH_DOWNLOAD}?filePath=${filePath}`, filePath, isUploaded: true };
104
104
  }
105
105
  throw new Error(`Upload failed (HTTP ${response.status}): ${result.data}`);
106
106
  }
@@ -116,7 +116,7 @@ export async function uploadBufferToCos(buffer, fileName, mimeType, config = {})
116
116
  * @returns 公网下载 URL
117
117
  */
118
118
  export function getFileDownloadUrl(filePath) {
119
- return `${COS_BASE_URL}${API_PATH_DOWNLOAD}?filePath=${encodeURIComponent(filePath)}`;
119
+ return `${SERVER_UPLOAD_BASE_URL}${API_PATH_DOWNLOAD}?filePath=${encodeURIComponent(filePath)}`;
120
120
  }
121
121
  /**
122
122
  * 从云端存储下载文件内容
@@ -125,8 +125,8 @@ export function getFileDownloadUrl(filePath) {
125
125
  * @param config - 文件存储配置
126
126
  * @returns 下载结果,包含 Buffer 和文件信息
127
127
  */
128
- export async function downloadFileFromCos(filePath, config = {}) {
129
- const url = filePath.startsWith("http") ? filePath : `${COS_BASE_URL}${API_PATH_DOWNLOAD}?filePath=${encodeURIComponent(filePath)}`;
128
+ export async function downloadFileFromServer(filePath, config = {}) {
129
+ const url = filePath.startsWith("http") ? filePath : `${SERVER_UPLOAD_BASE_URL}${API_PATH_DOWNLOAD}?filePath=${encodeURIComponent(filePath)}`;
130
130
  const headers = buildAuthHeaders(config.apiKey ?? "");
131
131
  const controller = new AbortController();
132
132
  const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT);
@@ -154,6 +154,6 @@ export async function downloadFileFromCos(filePath, config = {}) {
154
154
  * @returns 公网可访问的下载 URL
155
155
  */
156
156
  export async function uploadAndGetPublicUrl(localPath, config = {}) {
157
- const result = await uploadFileToCos(localPath, config);
157
+ const result = await uploadFileToServer(localPath, config);
158
158
  return result.url || '';
159
159
  }
@@ -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(用于终止消息处理循环) */
@@ -7,13 +7,13 @@
7
7
  * - 并发由 session 写锁 + followup queue 保证;
8
8
  * - /stop 及自然语言 abort 由 tryFastAbortFromMessage 统一处理并递归 kill subagent。
9
9
  */
10
- import { emitSignal } from './types.js';
11
- import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID } from './config.js';
10
+ import { emitSignal } from './utils/common.js';
11
+ import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID, LOCALFILE_SCHEME } from './config.js';
12
12
  import { getLightclawRuntime } from './runtime.js';
13
13
  import { createChannelReplyPipeline } from 'openclaw/plugin-sdk/channel-reply-pipeline';
14
14
  import { generateMsgId } from './dedup.js';
15
15
  import { parseDataUrl, formatFileSize } from './media.js';
16
- import { uploadFileToCos, downloadFileFromCos, getFileDownloadUrl } from './file-storage.js';
16
+ import { uploadFileToServer, downloadFileFromServer, getFileDownloadUrl } from './file-storage.js';
17
17
  import { createStreamReplyConfig } from './streaming/index.js';
18
18
  export function createInboundHandler(account, emitter, log) {
19
19
  return async (msg) => {
@@ -92,7 +92,7 @@ export function createInboundHandler(account, emitter, log) {
92
92
  try {
93
93
  let buffer;
94
94
  let mimeType;
95
- let cosPublicUrl;
95
+ let uploadPublicUrl;
96
96
  const parsed = file.bytes ? parseDataUrl(file.bytes) : null;
97
97
  if (parsed) {
98
98
  buffer = parsed.buffer;
@@ -100,10 +100,10 @@ export function createInboundHandler(account, emitter, log) {
100
100
  }
101
101
  else if (file.uri) {
102
102
  log?.info(`[${CHANNEL_KEY}] File has URI, downloading from cloud: ${file.uri}`);
103
- const downloaded = await downloadFileFromCos(file.uri, { apiKey: effectiveApiKey });
103
+ const downloaded = await downloadFileFromServer(file.uri, { apiKey: effectiveApiKey });
104
104
  buffer = downloaded.buffer;
105
105
  mimeType = file.mimeType;
106
- cosPublicUrl = file.uri.startsWith('http') ? file.uri : getFileDownloadUrl(file.uri);
106
+ uploadPublicUrl = file.uri.startsWith('http') ? file.uri : getFileDownloadUrl(file.uri);
107
107
  }
108
108
  else {
109
109
  // 来源 3:既无 bytes 也无 uri,无法处理,跳过此文件
@@ -114,19 +114,20 @@ export function createInboundHandler(account, emitter, log) {
114
114
  const saved = await pluginRuntime.channel.media.saveMediaBuffer(buffer, mimeType, 'inbound', MEDIA_MAX_BYTES, file.name);
115
115
  localMediaPaths.push(saved.path);
116
116
  localMediaTypes.push(mimeType);
117
- if (cosPublicUrl) {
118
- publicMediaUrls.push(cosPublicUrl);
117
+ const localPath = `${LOCALFILE_SCHEME}${saved.path}`;
118
+ if (uploadPublicUrl) {
119
+ publicMediaUrls.push(localPath);
119
120
  }
120
121
  else {
121
- // data URL 来源:上传到 COS 获取公网 URL,失败则 fallback 到本地路径
122
+ // data URL 来源:用户自行上传到服务器
122
123
  try {
123
- const uploadResult = await uploadFileToCos(saved.path, { apiKey: effectiveApiKey });
124
- publicMediaUrls.push(uploadResult.url || `file://${saved.path}`);
124
+ const uploadResult = await uploadFileToServer(saved.path, { apiKey: effectiveApiKey });
125
+ publicMediaUrls.push(localPath);
125
126
  log?.info(`[${CHANNEL_KEY}] Uploaded inbound file: ${saved.path} → ${uploadResult.url}`);
126
127
  }
127
128
  catch (uploadErr) {
128
129
  log?.warn(`[${CHANNEL_KEY}] Upload failed, falling back to local path: ${uploadErr}`);
129
- publicMediaUrls.push(`file://${saved.path}`);
130
+ publicMediaUrls.push(localPath);
130
131
  }
131
132
  }
132
133
  const attachmentUrl = publicMediaUrls[publicMediaUrls.length - 1];
@@ -1,9 +1,40 @@
1
1
  /**
2
- * LightClaw — 插件运行时存储
2
+ * LightClaw — 插件运行时(PluginRuntime)全局存储模块
3
+ * ---------------------------------------------------------------
4
+ * 作用:
5
+ * 该文件负责保存并暴露插件在运行期间唯一的 `PluginRuntime` 实例,
6
+ * 使项目内各个模块(如 inbound / socket handlers / channel 逻辑等)
7
+ * 都能通过统一入口获取到平台运行时对象,而无需层层传递。
3
8
  *
4
- * 使用 SDK 标准的 createPluginRuntimeStore 替代手动单例管理,
5
- * 确保与官方 channel 插件(Discord/Slack)的模式一致。
9
+ * 使用方式:
10
+ * - 在插件启动阶段(通常在入口 `index.ts` 的 `onInit` 回调中)
11
+ * 调用 `setLightclawRuntime(api.runtime)` 完成注入;
12
+ * - 在业务模块中通过 `getLightclawRuntime()` 获取实例,
13
+ * 进而访问 `runtime.config` / `runtime.channel` 等能力。
6
14
  */
15
+ // createPluginRuntimeStore:SDK 提供的通用工厂函数,
7
16
  import { createPluginRuntimeStore } from 'openclaw/plugin-sdk/runtime-store';
17
+ /**
18
+ * 创建 LightClaw 专属的运行时存储实例。
19
+ *
20
+ * - 泛型 `PluginRuntime`:约束该 store 管理的对象形状;
21
+ * - 入参字符串:当调用方在未完成 `setRuntime` 之前就尝试 `getRuntime`,
22
+ * SDK 将抛出包含此错误信息的异常,便于快速定位初始化顺序问题。
23
+ *
24
+ * 通过解构重命名,将 SDK 通用的 `setRuntime / getRuntime`
25
+ * 改名为带插件前缀的 `setLightclawRuntime / getLightclawRuntime`,
26
+ * 避免与其它插件(或多插件共存场景)产生命名冲突,提高可读性。
27
+ */
8
28
  const { setRuntime: setLightclawRuntime, getRuntime: getLightclawRuntime } = createPluginRuntimeStore('LightClaw runtime not initialized');
29
+ /**
30
+ * 对外导出的运行时访问 API:
31
+ *
32
+ * @function getLightclawRuntime
33
+ * 获取当前已注入的 PluginRuntime 实例。
34
+ * 若未初始化则抛出错误(见上方错误信息)。
35
+ *
36
+ * @function setLightclawRuntime
37
+ * 在插件初始化阶段调用,将平台传入的 runtime 注入到全局存储中。
38
+ * 通常只应由插件入口调用一次,后续模块只读取不写入。
39
+ */
9
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 } 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 { uploadFileToServer } 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, account, 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
  // ③ 内容校验:消息既无文字内容也无附件文件时,直接丢弃
@@ -150,4 +168,134 @@ export function bindSocketHandlers(socket, deps) {
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. 通过 uploadFileToServer 将本机文件上传到 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, account, 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 = account.apiKey;
272
+ const uploadResult = await uploadFileToServer(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
  }
@@ -13,10 +13,10 @@
13
13
  * counts / *Count 计数器,供 markComplete 推断 NO_REPLY 原因
14
14
  */
15
15
  import { CHANNEL_KEY } from "../config.js";
16
- import { uploadFileToCos } from "../file-storage.js";
16
+ import { uploadFileToServer } 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",
@@ -87,7 +87,7 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
87
87
  if (localPath.startsWith("/") || localPath.match(/^[A-Za-z]:\\/)) {
88
88
  const { existsSync } = await import("node:fs");
89
89
  if (existsSync(localPath)) {
90
- const result = await uploadFileToCos(localPath, storageConfig);
90
+ const result = await uploadFileToServer(localPath, storageConfig);
91
91
  publicUrls.push(result.url || "");
92
92
  log?.info(`[${CHANNEL_KEY}] [stream] Uploaded to COS: ${localPath} → ${result.url}`);
93
93
  }
package/dist/src/types.js CHANGED
@@ -8,23 +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
- ...extra,
28
- agentId,
29
- });
30
- }
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.1",
3
+ "version": "1.2.0-beta.3",
4
4
  "description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging",
5
5
  "type": "module",
6
6
  "files": [