@xgjktech/xg_cwork_im 1.11.1 → 1.11.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/README.md +15 -8
- package/index.ts +37 -37
- package/openclaw.plugin.json +9 -0
- package/package.json +1 -1
- package/src/channel.ts +81 -20
- package/src/resource-file.ts +339 -339
- package/src/send-service.ts +80 -25
- package/src/types.ts +6 -2
package/README.md
CHANGED
|
@@ -304,19 +304,26 @@ openclaw-channel-xg-cwork-im/
|
|
|
304
304
|
|
|
305
305
|
代码在内部 Git 上维护,发布到 npm 后同事即可一条命令安装。按下列步骤操作。
|
|
306
306
|
|
|
307
|
-
### 步骤 1:在 npm 注册 /
|
|
307
|
+
### 步骤 1:在 npm 注册 / 登录(仅本次发布走官方)
|
|
308
|
+
|
|
309
|
+
你可以保留系统默认镜像不变;只在本项目发布时显式指定官方 registry。
|
|
308
310
|
|
|
309
311
|
1. 无 npm 账号:打开 [https://www.npmjs.com/signup](https://www.npmjs.com/signup) 注册。
|
|
310
|
-
2.
|
|
312
|
+
2. 在项目根目录执行(不会改全局配置):
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
npm login --registry=https://registry.npmjs.org/
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
3. 登录成功后校验当前身份(同样只看官方 registry):
|
|
311
319
|
|
|
312
320
|
```bash
|
|
313
|
-
npm
|
|
321
|
+
npm whoami --registry=https://registry.npmjs.org/
|
|
314
322
|
```
|
|
315
323
|
|
|
316
|
-
3. 按提示输入 **Username**、**Password**、**Email**。
|
|
317
324
|
4. 若账号开启了**双因素认证(2FA)**,发布时必须使用:
|
|
318
325
|
- 在 npm 网站 **Access Tokens** 里创建 **Granular Access Token**,勾选 **Publish packages**,并勾选 **Bypass 2FA**;或
|
|
319
|
-
-
|
|
326
|
+
- 在发布时输入 **OTP**(一次性密码)。
|
|
320
327
|
否则会报错:`Two-factor authentication or granular access token with bypass 2fa enabled is required to publish packages`。
|
|
321
328
|
|
|
322
329
|
### 步骤 2:确认 scope 与权限
|
|
@@ -335,12 +342,12 @@ npm pack --dry-run
|
|
|
335
342
|
|
|
336
343
|
确认列出的文件无敏感内容(如 `.env`、密钥)。发布内容由 `package.json` 的 `"files"` 控制。
|
|
337
344
|
|
|
338
|
-
### 步骤 4
|
|
345
|
+
### 步骤 4:执行发布(强制官方 registry)
|
|
339
346
|
|
|
340
347
|
**scoped 包必须加 `--access public`**,否则会按私有包计费并可能报 402:
|
|
341
348
|
|
|
342
349
|
```bash
|
|
343
|
-
npm publish --access public
|
|
350
|
+
npm publish --access public --registry=https://registry.npmjs.org/
|
|
344
351
|
```
|
|
345
352
|
|
|
346
353
|
成功后可在此查看:
|
|
@@ -355,7 +362,7 @@ npm publish --access public
|
|
|
355
362
|
3. 在项目根目录执行:
|
|
356
363
|
|
|
357
364
|
```bash
|
|
358
|
-
npm publish --access public
|
|
365
|
+
npm publish --access public --registry=https://registry.npmjs.org/
|
|
359
366
|
```
|
|
360
367
|
|
|
361
368
|
4. 通知同事执行 **更新已安装的插件**(见上文「更新已安装的插件」),例如:
|
package/index.ts
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* XG-IM Channel Plugin for OpenClaw
|
|
3
|
-
*
|
|
4
|
-
* 插件入口:注册 XG-IM channel,并注入 PluginRuntime 供消息路由使用
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
8
|
-
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
9
|
-
import { setXgImRuntime, xgCworkImChannelPlugin } from "./src/channel.js";
|
|
10
|
-
import { buildGroupHistoryTool, extractXgCworkImConfig } from "./src/group-history-tool.js";
|
|
11
|
-
import { buildSendGroupMessageTool } from "./src/send-group-message-tool.js";
|
|
12
|
-
import type { XgCworkImPluginModule } from "./src/types.js";
|
|
13
|
-
|
|
14
|
-
export { XG_IM_RECOMMENDED_CHANNEL_SYSTEM_PROMPT } from "./src/recommended-system-prompt.js";
|
|
15
|
-
|
|
16
|
-
const plugin: XgCworkImPluginModule = {
|
|
17
|
-
id: "xg_cwork_im",
|
|
18
|
-
name: "XG CWork IM Channel",
|
|
19
|
-
description: "工作说说 IM 机器人 Channel 插件,通过 WebSocket 长连接接入",
|
|
20
|
-
configSchema: emptyPluginConfigSchema(),
|
|
21
|
-
register(api: OpenClawPluginApi): void {
|
|
22
|
-
setXgImRuntime(api.runtime);
|
|
23
|
-
api.registerChannel({ plugin: xgCworkImChannelPlugin });
|
|
24
|
-
// 注册 AI 工具(静态工具对象,确保 toolNames 正确注册)
|
|
25
|
-
const cworkConfig = extractXgCworkImConfig(api.config);
|
|
26
|
-
if (cworkConfig) {
|
|
27
|
-
// 工具涉及网络请求 / 副作用,按文档要求标记为 optional,由用户在 Agent 的 tools.allow 中显式启用
|
|
28
|
-
api.registerTool(buildGroupHistoryTool(cworkConfig), { optional: true });
|
|
29
|
-
// 主动推送(Cron/定时等)向 IM 发纯文本;用户经 IM 对话时不用此工具
|
|
30
|
-
api.registerTool(buildSendGroupMessageTool(cworkConfig), { optional: true });
|
|
31
|
-
} else {
|
|
32
|
-
console.warn("[xg_cwork_im] channel config not found, skipping tool registration");
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export default plugin;
|
|
1
|
+
/**
|
|
2
|
+
* XG-IM Channel Plugin for OpenClaw
|
|
3
|
+
*
|
|
4
|
+
* 插件入口:注册 XG-IM channel,并注入 PluginRuntime 供消息路由使用
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
8
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
9
|
+
import { setXgImRuntime, xgCworkImChannelPlugin } from "./src/channel.js";
|
|
10
|
+
import { buildGroupHistoryTool, extractXgCworkImConfig } from "./src/group-history-tool.js";
|
|
11
|
+
import { buildSendGroupMessageTool } from "./src/send-group-message-tool.js";
|
|
12
|
+
import type { XgCworkImPluginModule } from "./src/types.js";
|
|
13
|
+
|
|
14
|
+
export { XG_IM_RECOMMENDED_CHANNEL_SYSTEM_PROMPT } from "./src/recommended-system-prompt.js";
|
|
15
|
+
|
|
16
|
+
const plugin: XgCworkImPluginModule = {
|
|
17
|
+
id: "xg_cwork_im",
|
|
18
|
+
name: "XG CWork IM Channel",
|
|
19
|
+
description: "工作说说 IM 机器人 Channel 插件,通过 WebSocket 长连接接入",
|
|
20
|
+
configSchema: emptyPluginConfigSchema(),
|
|
21
|
+
register(api: OpenClawPluginApi): void {
|
|
22
|
+
setXgImRuntime(api.runtime);
|
|
23
|
+
api.registerChannel({ plugin: xgCworkImChannelPlugin });
|
|
24
|
+
// 注册 AI 工具(静态工具对象,确保 toolNames 正确注册)
|
|
25
|
+
const cworkConfig = extractXgCworkImConfig(api.config);
|
|
26
|
+
if (cworkConfig) {
|
|
27
|
+
// 工具涉及网络请求 / 副作用,按文档要求标记为 optional,由用户在 Agent 的 tools.allow 中显式启用
|
|
28
|
+
api.registerTool(buildGroupHistoryTool(cworkConfig), { optional: true });
|
|
29
|
+
// 主动推送(Cron/定时等)向 IM 发纯文本;用户经 IM 对话时不用此工具
|
|
30
|
+
api.registerTool(buildSendGroupMessageTool(cworkConfig), { optional: true });
|
|
31
|
+
} else {
|
|
32
|
+
console.warn("[xg_cwork_im] channel config not found, skipping tool registration");
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default plugin;
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
"channels": [
|
|
4
4
|
"xg_cwork_im"
|
|
5
5
|
],
|
|
6
|
+
"channelConfigs": {
|
|
7
|
+
"xg_cwork_im": {
|
|
8
|
+
"schema": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"additionalProperties": true,
|
|
11
|
+
"properties": {}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
},
|
|
6
15
|
"configSchema": {
|
|
7
16
|
"type": "object",
|
|
8
17
|
"additionalProperties": true,
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -46,9 +46,10 @@ function getXgImRuntime(): PluginRuntime {
|
|
|
46
46
|
// ─── 配置 Schema ──────────────────────────────────────────────────────────────
|
|
47
47
|
|
|
48
48
|
const XgImAccountConfigSchema = z.object({
|
|
49
|
-
// 注意:为了兼容 OpenClaw doctor 生成的 accounts.default(仅包含默认项,不含 appKey),这里先放宽为 optional,
|
|
50
|
-
// 然后在 XgImConfigSchema.superRefine 中强制校验:除 default 之外的账号必须提供 appKey。
|
|
51
|
-
|
|
49
|
+
// 注意:为了兼容 OpenClaw doctor 生成的 accounts.default(仅包含默认项,不含 appKey/robotKey),这里先放宽为 optional,
|
|
50
|
+
// 然后在 XgImConfigSchema.superRefine 中强制校验:除 default 之外的账号必须提供 robotKey 或 appKey。
|
|
51
|
+
robotKey: z.string().min(1).optional(),
|
|
52
|
+
appKey: z.string().min(1).optional(),
|
|
52
53
|
agentId: z.string().optional().default("main"),
|
|
53
54
|
name: z.string().optional(),
|
|
54
55
|
groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
|
|
@@ -61,6 +62,7 @@ const XgImAccountConfigSchema = z.object({
|
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
const XgImConfigSchema = z.object({
|
|
65
|
+
robotKey: z.string().optional(),
|
|
64
66
|
appKey: z.string().optional(),
|
|
65
67
|
agentId: z.string().optional().default("main"),
|
|
66
68
|
baseUrl: z.string().url("baseUrl must be a valid URL"),
|
|
@@ -117,11 +119,11 @@ const XgImConfigSchema = z.object({
|
|
|
117
119
|
for (const [key, acc] of Object.entries(accounts)) {
|
|
118
120
|
if (key === "default") continue;
|
|
119
121
|
if (!acc || typeof acc !== "object") continue;
|
|
120
|
-
if (!
|
|
122
|
+
if (!acc.robotKey && !acc.appKey) {
|
|
121
123
|
ctx.addIssue({
|
|
122
124
|
code: z.ZodIssueCode.custom,
|
|
123
|
-
path: ["accounts", key, "
|
|
124
|
-
message: "appKey is required for account entries (except accounts.default)",
|
|
125
|
+
path: ["accounts", key, "robotKey"],
|
|
126
|
+
message: "robotKey (or appKey) is required for account entries (except accounts.default)",
|
|
125
127
|
});
|
|
126
128
|
}
|
|
127
129
|
if (badSubdir(acc.inboundMediaWorkspaceSubdir)) {
|
|
@@ -145,6 +147,16 @@ const XgImConfigSchema = z.object({
|
|
|
145
147
|
|
|
146
148
|
// ─── 辅助函数 ─────────────────────────────────────────────────────────────────
|
|
147
149
|
|
|
150
|
+
/**
|
|
151
|
+
* 规范化 key 字段:优先取 robotKey,没有则取 appKey,统一写入 appKey。
|
|
152
|
+
* 这样下游代码(auth.ts 等)只需读 config.appKey,无需感知 robotKey 的存在。
|
|
153
|
+
*/
|
|
154
|
+
function normalizeRobotKey<T extends { robotKey?: string; appKey?: string }>(config: T): T {
|
|
155
|
+
const resolved = config.robotKey ?? config.appKey;
|
|
156
|
+
if (resolved === config.appKey) return config;
|
|
157
|
+
return { ...config, appKey: resolved };
|
|
158
|
+
}
|
|
159
|
+
|
|
148
160
|
/** 从顶层 cfg 中取出 XgImConfig,支持多账户(README:channels.xg_cwork_im) */
|
|
149
161
|
function getXgImConfig(cfg: OpenClawConfig, accountId?: string | null): XgImConfig {
|
|
150
162
|
const raw = (cfg as Record<string, Record<string, unknown>>)?.channels?.xg_cwork_im as XgImConfig | undefined;
|
|
@@ -160,18 +172,18 @@ function getXgImConfig(cfg: OpenClawConfig, accountId?: string | null): XgImConf
|
|
|
160
172
|
// 指定了具体账户 ID 时,优先合并对应账户配置
|
|
161
173
|
if (accountId && accountId !== "default" && accountMap) {
|
|
162
174
|
const sub = accountMap[accountId];
|
|
163
|
-
if (sub) return { ...raw, ...(defaults ?? {}), ...sub };
|
|
175
|
+
if (sub) return normalizeRobotKey({ ...raw, ...(defaults ?? {}), ...sub });
|
|
164
176
|
}
|
|
165
177
|
|
|
166
178
|
// 没有指定 accountId(如 Cron outbound 场景),自动 fallback 到「第一个账户」
|
|
167
|
-
// 避免顶层 raw 没有 appKey 时 getToken 失败
|
|
168
|
-
if (accountMap && !raw.appKey) {
|
|
179
|
+
// 避免顶层 raw 没有 appKey/robotKey 时 getToken 失败
|
|
180
|
+
if (accountMap && !raw.appKey && !raw.robotKey) {
|
|
169
181
|
const firstKey = Object.keys(accountMap).find((k) => k !== "default");
|
|
170
182
|
const first = firstKey ? accountMap[firstKey] : undefined;
|
|
171
|
-
if (first) return { ...raw, ...(defaults ?? {}), ...first };
|
|
183
|
+
if (first) return normalizeRobotKey({ ...raw, ...(defaults ?? {}), ...first });
|
|
172
184
|
}
|
|
173
185
|
|
|
174
|
-
return raw;
|
|
186
|
+
return normalizeRobotKey(raw);
|
|
175
187
|
}
|
|
176
188
|
|
|
177
189
|
/**
|
|
@@ -420,6 +432,8 @@ async function dispatchMentionedReply(args: {
|
|
|
420
432
|
streamMsgId,
|
|
421
433
|
reply,
|
|
422
434
|
);
|
|
435
|
+
// 标记已发送,让 finally 以 reason=stop 结束,避免后台再显示"AI未响应"
|
|
436
|
+
hasFirstReply = true;
|
|
423
437
|
log.info(
|
|
424
438
|
`${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} text="${timeoutText}"`,
|
|
425
439
|
);
|
|
@@ -430,6 +444,10 @@ async function dispatchMentionedReply(args: {
|
|
|
430
444
|
|
|
431
445
|
let deliverCallCount = 0;
|
|
432
446
|
let deliverSkippedCount = 0;
|
|
447
|
+
let dispatchError: unknown = undefined;
|
|
448
|
+
// 捕获 deliver 抛出后被 dispatcher 静默吞掉的错误:postImMessage 已带 5 次重试,
|
|
449
|
+
// 进到这里说明所有重试均失败(IM 后台不可用 / 4xx / resultCode 业务错误等),需要让 finally 走兜底分支
|
|
450
|
+
let lastDeliverError: unknown = undefined;
|
|
433
451
|
|
|
434
452
|
try {
|
|
435
453
|
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
@@ -446,6 +464,7 @@ async function dispatchMentionedReply(args: {
|
|
|
446
464
|
},
|
|
447
465
|
// Logged when deliver throws and the dispatcher catches it (error would otherwise be silently dropped).
|
|
448
466
|
onError: (err: unknown, meta: { kind: string }) => {
|
|
467
|
+
lastDeliverError = err;
|
|
449
468
|
log.error(
|
|
450
469
|
`${logPrefix} [deliver] Dispatcher caught unhandled error kind=${meta.kind}: ${String(err)}`,
|
|
451
470
|
);
|
|
@@ -477,7 +496,6 @@ async function dispatchMentionedReply(args: {
|
|
|
477
496
|
const atIds = [reply.targetUserId] as string[];
|
|
478
497
|
|
|
479
498
|
if (!hasFirstReply) {
|
|
480
|
-
hasFirstReply = true;
|
|
481
499
|
clearTimeout(firstReplyTimeout);
|
|
482
500
|
await sendReplyDeliverBlock(
|
|
483
501
|
config,
|
|
@@ -489,6 +507,7 @@ async function dispatchMentionedReply(args: {
|
|
|
489
507
|
streamMsgId,
|
|
490
508
|
reply,
|
|
491
509
|
);
|
|
510
|
+
hasFirstReply = true; // set after successful send
|
|
492
511
|
const preview =
|
|
493
512
|
textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
494
513
|
log.info(
|
|
@@ -523,17 +542,59 @@ async function dispatchMentionedReply(args: {
|
|
|
523
542
|
`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}` +
|
|
524
543
|
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount})`,
|
|
525
544
|
);
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
545
|
+
} catch (dispatchErr: unknown) {
|
|
546
|
+
dispatchError = dispatchErr;
|
|
547
|
+
log.error(
|
|
548
|
+
`${logPrefix} [dispatch] Dispatch failed (delivered=${deliverCallCount} skipped=${deliverSkippedCount}): ${String(dispatchErr)}`,
|
|
549
|
+
);
|
|
532
550
|
} finally {
|
|
533
551
|
clearTimeout(firstReplyTimeout);
|
|
552
|
+
|
|
553
|
+
// 兜底原则:
|
|
554
|
+
// - 有"实际错误"(dispatch 自身异常 / deliver 全部重试后仍失败)且尚未发过任何消息
|
|
555
|
+
// → 主动告知用户,以 reason=stop 结束(后台信任已发消息)。
|
|
556
|
+
// - 无错误也无回复(AI 输出为空 / 全部被 normalizer skip / /reset 类静默命令)
|
|
557
|
+
// → 以 reason=no_reply 结束,由后台展示"AI未响应"。
|
|
558
|
+
if (!hasFirstReply) {
|
|
559
|
+
const reportableError = dispatchError ?? lastDeliverError;
|
|
560
|
+
if (reportableError !== undefined) {
|
|
561
|
+
const errSource = dispatchError !== undefined ? "dispatch" : "deliver";
|
|
562
|
+
log.warn(
|
|
563
|
+
`${logPrefix} [dispatch] ${errSource} error with no prior reply` +
|
|
564
|
+
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount}), sending error notice`,
|
|
565
|
+
);
|
|
566
|
+
const replyMeta = buildTargetReplyMeta(params);
|
|
567
|
+
try {
|
|
568
|
+
await sendTextMessage(
|
|
569
|
+
config,
|
|
570
|
+
currentIdentity.token,
|
|
571
|
+
params.groupId,
|
|
572
|
+
`当前请求处理时出现异常,请稍后重试。\n错误信息:${String(reportableError)}`,
|
|
573
|
+
[replyMeta.targetUserId] as string[],
|
|
574
|
+
log,
|
|
575
|
+
streamMsgId,
|
|
576
|
+
replyMeta,
|
|
577
|
+
);
|
|
578
|
+
hasFirstReply = true;
|
|
579
|
+
} catch (fallbackErr: unknown) {
|
|
580
|
+
log.error(
|
|
581
|
+
`${logPrefix} [dispatch] Failed to send error notice (${errSource} error): ${String(fallbackErr)}`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
} else {
|
|
585
|
+
log.warn(
|
|
586
|
+
`${logPrefix} [dispatch] No reply was sent to user` +
|
|
587
|
+
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount}), ending with reason=no_reply`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// reason=stop:已通过 HTTP 发送了至少一条回复(含兜底错误消息),后台信任已有消息。
|
|
593
|
+
// reason=no_reply:本次无任何回复且无异常,后台凭此展示"AI未响应"。
|
|
594
|
+
const endReason = hasFirstReply ? "stop" : "no_reply";
|
|
534
595
|
try {
|
|
535
|
-
await streamClient.end(streamMsgId,
|
|
536
|
-
log.info(`${logPrefix} [stream] END sent: msgId=${streamMsgId}`);
|
|
596
|
+
await streamClient.end(streamMsgId, endReason);
|
|
597
|
+
log.info(`${logPrefix} [stream] END sent: msgId=${streamMsgId} reason=${endReason}`);
|
|
537
598
|
} catch (endErr: unknown) {
|
|
538
599
|
log.error(`${logPrefix} [stream] END failed (msgId=${streamMsgId}): ${String(endErr)}`);
|
|
539
600
|
}
|
package/src/resource-file.ts
CHANGED
|
@@ -1,339 +1,339 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 资源中心整文件上传 + OpenClaw deliver 媒体 URL 拉取
|
|
3
|
-
*
|
|
4
|
-
* 上传地址固定为 `baseUrl + /file/upDownload/uploadWholeFile`,与 IM 同域,使用与发消息相同的 access-token。
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import axios from "axios";
|
|
8
|
-
import { File } from "node:buffer";
|
|
9
|
-
import { readFile } from "node:fs/promises";
|
|
10
|
-
import path from "node:path";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
12
|
-
import type { Log } from "./auth.js";
|
|
13
|
-
|
|
14
|
-
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
15
|
-
|
|
16
|
-
/** 整文件上传接口路径(拼在 channels.xg_cwork_im.baseUrl 上) */
|
|
17
|
-
export const RESOURCE_UPLOAD_PATH = "/file/upDownload/uploadWholeFile";
|
|
18
|
-
|
|
19
|
-
export function resolveResourceUploadUrl(baseUrl: string): string {
|
|
20
|
-
const b = baseUrl.trim();
|
|
21
|
-
if (!b) throw new Error("[cwork_im] baseUrl is required for resource upload");
|
|
22
|
-
return new URL(RESOURCE_UPLOAD_PATH, b.endsWith("/") ? b : `${b}/`).href;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** 从上传接口 JSON 中解析 fileId(兼容常见嵌套 data) */
|
|
26
|
-
export function extractFileIdFromUploadResponse(data: unknown): string | undefined {
|
|
27
|
-
if (data == null) return undefined;
|
|
28
|
-
if (typeof data === "string" && data.trim().length > 0) return data.trim();
|
|
29
|
-
if (typeof data !== "object") return undefined;
|
|
30
|
-
const o = data as Record<string, unknown>;
|
|
31
|
-
for (const key of ["fileId", "id", "resourceId", "file_id"]) {
|
|
32
|
-
const v = o[key];
|
|
33
|
-
if (typeof v === "string" && v.length > 0) return v;
|
|
34
|
-
}
|
|
35
|
-
if (o.data != null) {
|
|
36
|
-
const nested = extractFileIdFromUploadResponse(o.data);
|
|
37
|
-
if (nested) return nested;
|
|
38
|
-
}
|
|
39
|
-
return undefined;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function formatFromFilename(filename: string): string {
|
|
43
|
-
const ext = path.extname(filename).replace(/^\./, "").toLowerCase();
|
|
44
|
-
if (ext && ext.length <= 16 && /^[a-z0-9]+$/i.test(ext)) return ext;
|
|
45
|
-
return "bin";
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 仅当含 %XX 序列时 decodeURIComponent,避免对已解码的 Unicode 再 decode 抛错 */
|
|
49
|
-
export function safeDecodeUriFilenameSegment(segment: string): string {
|
|
50
|
-
const s = segment.trim();
|
|
51
|
-
if (!s) return s;
|
|
52
|
-
if (!/%[0-9A-Fa-f]{2}/.test(s)) return s;
|
|
53
|
-
try {
|
|
54
|
-
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
55
|
-
} catch {
|
|
56
|
-
return s;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* 从 HTTP Content-Disposition 解析文件名(优先 RFC 5987 `filename*=` UTF-8,再普通 filename=)。
|
|
62
|
-
*/
|
|
63
|
-
export function parseFilenameFromContentDisposition(header: string | undefined): string | undefined {
|
|
64
|
-
if (header == null || typeof header !== "string") return undefined;
|
|
65
|
-
const h = header.trim();
|
|
66
|
-
if (!h) return undefined;
|
|
67
|
-
|
|
68
|
-
const star = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;\r\n]+)/i.exec(h);
|
|
69
|
-
if (star?.[1]) {
|
|
70
|
-
const v = star[1].trim();
|
|
71
|
-
const decoded = safeDecodeUriFilenameSegment(v);
|
|
72
|
-
if (decoded) return decoded;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const quoted = /filename\s*=\s*"((?:[^"\\]|\\.)*)"/i.exec(h);
|
|
76
|
-
if (quoted?.[1]) {
|
|
77
|
-
const inner = quoted[1].replace(/\\(.)/g, "$1").trim();
|
|
78
|
-
if (inner) return safeDecodeUriFilenameSegment(inner);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const unquoted = /filename\s*=\s*([^;\r\n]+)/i.exec(h);
|
|
82
|
-
if (unquoted?.[1]) {
|
|
83
|
-
let v = unquoted[1].trim().replace(/^["']|["']$/g, "");
|
|
84
|
-
if (v.toLowerCase().startsWith("utf-8''")) v = v.slice(7);
|
|
85
|
-
if (v) return safeDecodeUriFilenameSegment(v);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return undefined;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function displayNameFromUrl(url: string): string {
|
|
92
|
-
try {
|
|
93
|
-
if (url.startsWith("file://")) {
|
|
94
|
-
return path.basename(fileURLToPath(url)) || "file";
|
|
95
|
-
}
|
|
96
|
-
const u = new URL(url);
|
|
97
|
-
const qName = u.searchParams.get("filename") ?? u.searchParams.get("name");
|
|
98
|
-
if (qName?.trim()) {
|
|
99
|
-
return safeDecodeUriFilenameSegment(qName.trim()) || "attachment";
|
|
100
|
-
}
|
|
101
|
-
const pathname = u.pathname || "/";
|
|
102
|
-
const last = pathname.split("/").filter(Boolean).pop() ?? "";
|
|
103
|
-
const base = safeDecodeUriFilenameSegment(last.split("?")[0] || "");
|
|
104
|
-
return base || "attachment";
|
|
105
|
-
} catch {
|
|
106
|
-
return "attachment";
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** 日志用:截断过长路径/URL */
|
|
111
|
-
export function summarizeMediaRef(ref: string, maxLen = 160): string {
|
|
112
|
-
const t = ref.trim().replace(/\s+/g, " ");
|
|
113
|
-
if (t.length <= maxLen) return t;
|
|
114
|
-
return `${t.slice(0, maxLen)}…(len=${t.length})`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function isHttpOrHttps(ref: string): boolean {
|
|
118
|
-
try {
|
|
119
|
-
const u = new URL(ref.trim());
|
|
120
|
-
return u.protocol === "http:" || u.protocol === "https:";
|
|
121
|
-
} catch {
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* 将字符串解析为可交给 readFile 的本机路径(兼容 Windows / macOS / Linux)。
|
|
128
|
-
* - 使用当前进程的 path.isAbsolute(各 OS 语义正确)。
|
|
129
|
-
* - Windows:Git Bash / MSYS 的 `/c/Users/...`、Cygwin `/cygdrive/c/...`。
|
|
130
|
-
* - 非 Windows:若传入 `D:\...` 形式仍做一次规范化(多用于日志/失败提示,读文件通常会失败)。
|
|
131
|
-
*/
|
|
132
|
-
export function resolveLocalFsPath(ref: string): { fsPath: string; via: string } | null {
|
|
133
|
-
const raw = ref.trim();
|
|
134
|
-
if (!raw) return null;
|
|
135
|
-
|
|
136
|
-
if (process.platform === "win32") {
|
|
137
|
-
// MSYS/Git Bash: /c/Users/foo → C:\Users\foo(要求 /<盘符>/ 后仍有路径,避免误伤 /Users)
|
|
138
|
-
const msys = /^\/([a-zA-Z])\/(.+)$/.exec(raw.replace(/\\/g, "/"));
|
|
139
|
-
if (msys) {
|
|
140
|
-
const letter = msys[1].toUpperCase();
|
|
141
|
-
const rest = msys[2].split("/").join(path.win32.sep);
|
|
142
|
-
const fsPath = path.win32.normalize(`${letter}:${path.win32.sep}${rest}`);
|
|
143
|
-
return { fsPath, via: "git-bash-msys" };
|
|
144
|
-
}
|
|
145
|
-
const cyg = /^\/cygdrive\/([a-zA-Z])\/(.+)$/i.exec(raw.replace(/\\/g, "/"));
|
|
146
|
-
if (cyg) {
|
|
147
|
-
const letter = cyg[1].toUpperCase();
|
|
148
|
-
const rest = cyg[2].split("/").join(path.win32.sep);
|
|
149
|
-
const fsPath = path.win32.normalize(`${letter}:${path.win32.sep}${rest}`);
|
|
150
|
-
return { fsPath, via: "cygwin" };
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (path.isAbsolute(raw)) {
|
|
155
|
-
return { fsPath: path.normalize(raw), via: `native-${process.platform}` };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Linux/macOS 上 WSL 互操作常见:/mnt/c/... 已是 posix 绝对路径,上面已命中
|
|
159
|
-
|
|
160
|
-
if (process.platform !== "win32" && /^[a-zA-Z]:[/\\]/.test(raw)) {
|
|
161
|
-
return { fsPath: path.win32.normalize(raw), via: "win-style-path" };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function readLocalFsIntoBuffer(fsPath: string, maxBytes: number, log?: Log): Promise<{ buffer: Buffer; filename: string }> {
|
|
168
|
-
const buffer = await readFile(fsPath);
|
|
169
|
-
if (buffer.length > maxBytes) {
|
|
170
|
-
throw new Error(`local file size ${buffer.length} exceeds maxAttachmentBytes=${maxBytes}`);
|
|
171
|
-
}
|
|
172
|
-
const filename = path.basename(fsPath) || "file";
|
|
173
|
-
log?.info(`[cwork_im:media] local ok bytes=${buffer.length} name=${filename}`);
|
|
174
|
-
return { buffer, filename };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* 将 OpenClaw 约定的 `/workspace/<rel>` 路径解析为本机绝对路径(跨平台)。
|
|
179
|
-
*
|
|
180
|
-
* OpenClaw 在所有平台启动时均会 chdir 到 workspace 目录,因此
|
|
181
|
-
* `process.cwd()` 即为 workspace 根,/workspace/<rel> 等价于 cwd/<rel>:
|
|
182
|
-
* - Windows : C:\Users\..\.openclaw\workspace\<rel>
|
|
183
|
-
* - macOS : ~/.openclaw/workspace/<rel>
|
|
184
|
-
* - Linux容器: /workspace/<rel>(cwd=/workspace,path.join 结果不变)
|
|
185
|
-
*
|
|
186
|
-
* 返回 null 表示路径不符合 /workspace/ 格式。
|
|
187
|
-
*/
|
|
188
|
-
export function resolveWorkspacePrefixPath(raw: string): string | null {
|
|
189
|
-
if (!raw.startsWith("/workspace/") && raw !== "/workspace") return null;
|
|
190
|
-
const rel = raw.startsWith("/workspace/") ? raw.slice("/workspace/".length) : "";
|
|
191
|
-
const cwd = process.cwd();
|
|
192
|
-
return rel ? path.join(cwd, rel) : cwd;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export async function downloadMediaBuffer(
|
|
196
|
-
url: string,
|
|
197
|
-
maxBytes: number,
|
|
198
|
-
log?: Log,
|
|
199
|
-
): Promise<{ buffer: Buffer; filename: string }> {
|
|
200
|
-
const raw = url.trim();
|
|
201
|
-
log?.info(`[cwork_im:media] resolve ref=${summarizeMediaRef(raw)}`);
|
|
202
|
-
|
|
203
|
-
// Handle OpenClaw /workspace/ convention: map to actual workspace directory.
|
|
204
|
-
const workspaceResolved = resolveWorkspacePrefixPath(raw);
|
|
205
|
-
if (workspaceResolved && workspaceResolved !== raw) {
|
|
206
|
-
log?.info(`[cwork_im:media] /workspace/ -> ${summarizeMediaRef(workspaceResolved)}`);
|
|
207
|
-
try {
|
|
208
|
-
return await readLocalFsIntoBuffer(workspaceResolved, maxBytes, log);
|
|
209
|
-
} catch (err: unknown) {
|
|
210
|
-
log?.error(`[cwork_im:media] readFile failed /workspace/ ${summarizeMediaRef(workspaceResolved)}: ${String(err)}`);
|
|
211
|
-
throw err;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (raw.startsWith("file://")) {
|
|
216
|
-
let fsPath: string;
|
|
217
|
-
try {
|
|
218
|
-
fsPath = fileURLToPath(raw);
|
|
219
|
-
} catch (e) {
|
|
220
|
-
log?.error(`[cwork_im:media] invalid file:// URL: ${summarizeMediaRef(raw)} ${String(e)}`);
|
|
221
|
-
throw e;
|
|
222
|
-
}
|
|
223
|
-
log?.info(`[cwork_im:media] read file:// -> ${summarizeMediaRef(fsPath)}`);
|
|
224
|
-
try {
|
|
225
|
-
return await readLocalFsIntoBuffer(fsPath, maxBytes, log);
|
|
226
|
-
} catch (err: unknown) {
|
|
227
|
-
log?.error(`[cwork_im:media] readFile failed file:// ${summarizeMediaRef(fsPath)}: ${String(err)}`);
|
|
228
|
-
throw err;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (isHttpOrHttps(raw)) {
|
|
233
|
-
log?.info(`[cwork_im:media] GET ${summarizeMediaRef(raw)}`);
|
|
234
|
-
try {
|
|
235
|
-
const res = await axios.get(raw, {
|
|
236
|
-
responseType: "arraybuffer",
|
|
237
|
-
timeout: 120_000,
|
|
238
|
-
maxContentLength: maxBytes,
|
|
239
|
-
maxBodyLength: maxBytes,
|
|
240
|
-
validateStatus: (s) => s >= 200 && s < 300,
|
|
241
|
-
});
|
|
242
|
-
const buffer = Buffer.from(res.data as ArrayBuffer);
|
|
243
|
-
let filename = displayNameFromUrl(raw);
|
|
244
|
-
const cdRaw = res.headers["content-disposition"];
|
|
245
|
-
const cd = Array.isArray(cdRaw) ? cdRaw[0] : cdRaw;
|
|
246
|
-
if (typeof cd === "string") {
|
|
247
|
-
const fromCd = parseFilenameFromContentDisposition(cd);
|
|
248
|
-
if (fromCd) filename = fromCd;
|
|
249
|
-
}
|
|
250
|
-
log?.info(`[cwork_im:media] http ok bytes=${buffer.length} name=${filename}`);
|
|
251
|
-
return { buffer, filename };
|
|
252
|
-
} catch (err: unknown) {
|
|
253
|
-
const detail = axios.isAxiosError(err)
|
|
254
|
-
? `${err.message}${err.code ? ` axiosCode=${err.code}` : ""}${err.response?.status != null ? ` httpStatus=${err.response.status}` : ""}`
|
|
255
|
-
: String(err);
|
|
256
|
-
log?.error(`[cwork_im:media] http failed ref=${summarizeMediaRef(raw)}: ${detail}`);
|
|
257
|
-
throw err;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const resolved = resolveLocalFsPath(raw);
|
|
262
|
-
if (resolved) {
|
|
263
|
-
log?.info(`[cwork_im:media] local via=${resolved.via} -> ${summarizeMediaRef(resolved.fsPath)}`);
|
|
264
|
-
try {
|
|
265
|
-
return await readLocalFsIntoBuffer(resolved.fsPath, maxBytes, log);
|
|
266
|
-
} catch (err: unknown) {
|
|
267
|
-
log?.error(`[cwork_im:media] readFile failed ${summarizeMediaRef(resolved.fsPath)}: ${String(err)}`);
|
|
268
|
-
throw err;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const hint =
|
|
273
|
-
"请使用 http(s)、file://(三系统通用),或本机绝对路径:Windows C:\\\\... / UNC;macOS/Linux /Users、/home、/mnt/c/...(WSL);Windows 下 Git Bash 可用 /c/...";
|
|
274
|
-
log?.error(`[cwork_im:media] unsupported ref=${summarizeMediaRef(raw)} (${hint})`);
|
|
275
|
-
throw new Error(`[cwork_im] 无法读取媒体(${hint}): ${summarizeMediaRef(raw)}`);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* multipart 上传整文件,返回 fileId 与字节数。
|
|
280
|
-
*/
|
|
281
|
-
export async function uploadWholeResourceFile(args: {
|
|
282
|
-
fileUploadUrl: string;
|
|
283
|
-
token: string;
|
|
284
|
-
buffer: Buffer;
|
|
285
|
-
filename: string;
|
|
286
|
-
formField: string;
|
|
287
|
-
log?: Log;
|
|
288
|
-
}): Promise<{ fileId: string; size: number }> {
|
|
289
|
-
const { fileUploadUrl, token, buffer, filename, formField, log } = args;
|
|
290
|
-
const form = new FormData();
|
|
291
|
-
// 使用 File 携带 UTF-8 文件名,避免部分环境下 Blob+第三参在 multipart 里被错误编码导致服务端乱码
|
|
292
|
-
const file = new File([buffer], filename, { type: "application/octet-stream" });
|
|
293
|
-
form.append(formField, file);
|
|
294
|
-
|
|
295
|
-
log?.info(`[cwork_im:upload] POST ${fileUploadUrl} field=${formField} name=${filename} bytes=${buffer.length}`);
|
|
296
|
-
|
|
297
|
-
const res = await fetch(fileUploadUrl, {
|
|
298
|
-
method: "POST",
|
|
299
|
-
headers: {
|
|
300
|
-
"access-token": token,
|
|
301
|
-
},
|
|
302
|
-
body: form,
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
const rawText = await res.text();
|
|
306
|
-
let json: unknown;
|
|
307
|
-
try {
|
|
308
|
-
json = rawText ? JSON.parse(rawText) : undefined;
|
|
309
|
-
} catch {
|
|
310
|
-
json = undefined;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (!res.ok) {
|
|
314
|
-
log?.error(`[cwork_im:upload] HTTP ${res.status} body=${rawText.slice(0, 500)}`);
|
|
315
|
-
throw new Error(`upload HTTP ${res.status}: ${rawText.slice(0, 500)}`);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Unified IM API response: { data, resultCode: 1, resultMsg: "" } — resultCode=1 means success.
|
|
319
|
-
if (json && typeof json === "object") {
|
|
320
|
-
const j = json as Record<string, unknown>;
|
|
321
|
-
if (j.resultCode !== undefined && j.resultCode !== 1) {
|
|
322
|
-
const msg = `upload IM API error resultCode=${j.resultCode}${j.resultMsg ? ` resultMsg=${j.resultMsg}` : ""}`;
|
|
323
|
-
log?.error(`[cwork_im:upload] ${msg} body=${rawText.slice(0, 500)}`);
|
|
324
|
-
throw new Error(msg);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const fileId = extractFileIdFromUploadResponse(json ?? rawText);
|
|
329
|
-
if (!fileId) {
|
|
330
|
-
log?.error(`[cwork_im:upload] missing fileId in body=${rawText.slice(0, 500)}`);
|
|
331
|
-
throw new Error(`upload response missing fileId: ${rawText.slice(0, 500)}`);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
return { fileId, size: buffer.length };
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
export function resolveMaxAttachmentBytes(config: { maxAttachmentBytes?: number }): number {
|
|
338
|
-
return config.maxAttachmentBytes ?? DEFAULT_MAX_BYTES;
|
|
339
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 资源中心整文件上传 + OpenClaw deliver 媒体 URL 拉取
|
|
3
|
+
*
|
|
4
|
+
* 上传地址固定为 `baseUrl + /file/upDownload/uploadWholeFile`,与 IM 同域,使用与发消息相同的 access-token。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import axios from "axios";
|
|
8
|
+
import { File } from "node:buffer";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import type { Log } from "./auth.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/** 整文件上传接口路径(拼在 channels.xg_cwork_im.baseUrl 上) */
|
|
17
|
+
export const RESOURCE_UPLOAD_PATH = "/file/upDownload/uploadWholeFile";
|
|
18
|
+
|
|
19
|
+
export function resolveResourceUploadUrl(baseUrl: string): string {
|
|
20
|
+
const b = baseUrl.trim();
|
|
21
|
+
if (!b) throw new Error("[cwork_im] baseUrl is required for resource upload");
|
|
22
|
+
return new URL(RESOURCE_UPLOAD_PATH, b.endsWith("/") ? b : `${b}/`).href;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 从上传接口 JSON 中解析 fileId(兼容常见嵌套 data) */
|
|
26
|
+
export function extractFileIdFromUploadResponse(data: unknown): string | undefined {
|
|
27
|
+
if (data == null) return undefined;
|
|
28
|
+
if (typeof data === "string" && data.trim().length > 0) return data.trim();
|
|
29
|
+
if (typeof data !== "object") return undefined;
|
|
30
|
+
const o = data as Record<string, unknown>;
|
|
31
|
+
for (const key of ["fileId", "id", "resourceId", "file_id"]) {
|
|
32
|
+
const v = o[key];
|
|
33
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
34
|
+
}
|
|
35
|
+
if (o.data != null) {
|
|
36
|
+
const nested = extractFileIdFromUploadResponse(o.data);
|
|
37
|
+
if (nested) return nested;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatFromFilename(filename: string): string {
|
|
43
|
+
const ext = path.extname(filename).replace(/^\./, "").toLowerCase();
|
|
44
|
+
if (ext && ext.length <= 16 && /^[a-z0-9]+$/i.test(ext)) return ext;
|
|
45
|
+
return "bin";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 仅当含 %XX 序列时 decodeURIComponent,避免对已解码的 Unicode 再 decode 抛错 */
|
|
49
|
+
export function safeDecodeUriFilenameSegment(segment: string): string {
|
|
50
|
+
const s = segment.trim();
|
|
51
|
+
if (!s) return s;
|
|
52
|
+
if (!/%[0-9A-Fa-f]{2}/.test(s)) return s;
|
|
53
|
+
try {
|
|
54
|
+
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
55
|
+
} catch {
|
|
56
|
+
return s;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 从 HTTP Content-Disposition 解析文件名(优先 RFC 5987 `filename*=` UTF-8,再普通 filename=)。
|
|
62
|
+
*/
|
|
63
|
+
export function parseFilenameFromContentDisposition(header: string | undefined): string | undefined {
|
|
64
|
+
if (header == null || typeof header !== "string") return undefined;
|
|
65
|
+
const h = header.trim();
|
|
66
|
+
if (!h) return undefined;
|
|
67
|
+
|
|
68
|
+
const star = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;\r\n]+)/i.exec(h);
|
|
69
|
+
if (star?.[1]) {
|
|
70
|
+
const v = star[1].trim();
|
|
71
|
+
const decoded = safeDecodeUriFilenameSegment(v);
|
|
72
|
+
if (decoded) return decoded;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const quoted = /filename\s*=\s*"((?:[^"\\]|\\.)*)"/i.exec(h);
|
|
76
|
+
if (quoted?.[1]) {
|
|
77
|
+
const inner = quoted[1].replace(/\\(.)/g, "$1").trim();
|
|
78
|
+
if (inner) return safeDecodeUriFilenameSegment(inner);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const unquoted = /filename\s*=\s*([^;\r\n]+)/i.exec(h);
|
|
82
|
+
if (unquoted?.[1]) {
|
|
83
|
+
let v = unquoted[1].trim().replace(/^["']|["']$/g, "");
|
|
84
|
+
if (v.toLowerCase().startsWith("utf-8''")) v = v.slice(7);
|
|
85
|
+
if (v) return safeDecodeUriFilenameSegment(v);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function displayNameFromUrl(url: string): string {
|
|
92
|
+
try {
|
|
93
|
+
if (url.startsWith("file://")) {
|
|
94
|
+
return path.basename(fileURLToPath(url)) || "file";
|
|
95
|
+
}
|
|
96
|
+
const u = new URL(url);
|
|
97
|
+
const qName = u.searchParams.get("filename") ?? u.searchParams.get("name");
|
|
98
|
+
if (qName?.trim()) {
|
|
99
|
+
return safeDecodeUriFilenameSegment(qName.trim()) || "attachment";
|
|
100
|
+
}
|
|
101
|
+
const pathname = u.pathname || "/";
|
|
102
|
+
const last = pathname.split("/").filter(Boolean).pop() ?? "";
|
|
103
|
+
const base = safeDecodeUriFilenameSegment(last.split("?")[0] || "");
|
|
104
|
+
return base || "attachment";
|
|
105
|
+
} catch {
|
|
106
|
+
return "attachment";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 日志用:截断过长路径/URL */
|
|
111
|
+
export function summarizeMediaRef(ref: string, maxLen = 160): string {
|
|
112
|
+
const t = ref.trim().replace(/\s+/g, " ");
|
|
113
|
+
if (t.length <= maxLen) return t;
|
|
114
|
+
return `${t.slice(0, maxLen)}…(len=${t.length})`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isHttpOrHttps(ref: string): boolean {
|
|
118
|
+
try {
|
|
119
|
+
const u = new URL(ref.trim());
|
|
120
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 将字符串解析为可交给 readFile 的本机路径(兼容 Windows / macOS / Linux)。
|
|
128
|
+
* - 使用当前进程的 path.isAbsolute(各 OS 语义正确)。
|
|
129
|
+
* - Windows:Git Bash / MSYS 的 `/c/Users/...`、Cygwin `/cygdrive/c/...`。
|
|
130
|
+
* - 非 Windows:若传入 `D:\...` 形式仍做一次规范化(多用于日志/失败提示,读文件通常会失败)。
|
|
131
|
+
*/
|
|
132
|
+
export function resolveLocalFsPath(ref: string): { fsPath: string; via: string } | null {
|
|
133
|
+
const raw = ref.trim();
|
|
134
|
+
if (!raw) return null;
|
|
135
|
+
|
|
136
|
+
if (process.platform === "win32") {
|
|
137
|
+
// MSYS/Git Bash: /c/Users/foo → C:\Users\foo(要求 /<盘符>/ 后仍有路径,避免误伤 /Users)
|
|
138
|
+
const msys = /^\/([a-zA-Z])\/(.+)$/.exec(raw.replace(/\\/g, "/"));
|
|
139
|
+
if (msys) {
|
|
140
|
+
const letter = msys[1].toUpperCase();
|
|
141
|
+
const rest = msys[2].split("/").join(path.win32.sep);
|
|
142
|
+
const fsPath = path.win32.normalize(`${letter}:${path.win32.sep}${rest}`);
|
|
143
|
+
return { fsPath, via: "git-bash-msys" };
|
|
144
|
+
}
|
|
145
|
+
const cyg = /^\/cygdrive\/([a-zA-Z])\/(.+)$/i.exec(raw.replace(/\\/g, "/"));
|
|
146
|
+
if (cyg) {
|
|
147
|
+
const letter = cyg[1].toUpperCase();
|
|
148
|
+
const rest = cyg[2].split("/").join(path.win32.sep);
|
|
149
|
+
const fsPath = path.win32.normalize(`${letter}:${path.win32.sep}${rest}`);
|
|
150
|
+
return { fsPath, via: "cygwin" };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (path.isAbsolute(raw)) {
|
|
155
|
+
return { fsPath: path.normalize(raw), via: `native-${process.platform}` };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Linux/macOS 上 WSL 互操作常见:/mnt/c/... 已是 posix 绝对路径,上面已命中
|
|
159
|
+
|
|
160
|
+
if (process.platform !== "win32" && /^[a-zA-Z]:[/\\]/.test(raw)) {
|
|
161
|
+
return { fsPath: path.win32.normalize(raw), via: "win-style-path" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readLocalFsIntoBuffer(fsPath: string, maxBytes: number, log?: Log): Promise<{ buffer: Buffer; filename: string }> {
|
|
168
|
+
const buffer = await readFile(fsPath);
|
|
169
|
+
if (buffer.length > maxBytes) {
|
|
170
|
+
throw new Error(`local file size ${buffer.length} exceeds maxAttachmentBytes=${maxBytes}`);
|
|
171
|
+
}
|
|
172
|
+
const filename = path.basename(fsPath) || "file";
|
|
173
|
+
log?.info(`[cwork_im:media] local ok bytes=${buffer.length} name=${filename}`);
|
|
174
|
+
return { buffer, filename };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 将 OpenClaw 约定的 `/workspace/<rel>` 路径解析为本机绝对路径(跨平台)。
|
|
179
|
+
*
|
|
180
|
+
* OpenClaw 在所有平台启动时均会 chdir 到 workspace 目录,因此
|
|
181
|
+
* `process.cwd()` 即为 workspace 根,/workspace/<rel> 等价于 cwd/<rel>:
|
|
182
|
+
* - Windows : C:\Users\..\.openclaw\workspace\<rel>
|
|
183
|
+
* - macOS : ~/.openclaw/workspace/<rel>
|
|
184
|
+
* - Linux容器: /workspace/<rel>(cwd=/workspace,path.join 结果不变)
|
|
185
|
+
*
|
|
186
|
+
* 返回 null 表示路径不符合 /workspace/ 格式。
|
|
187
|
+
*/
|
|
188
|
+
export function resolveWorkspacePrefixPath(raw: string): string | null {
|
|
189
|
+
if (!raw.startsWith("/workspace/") && raw !== "/workspace") return null;
|
|
190
|
+
const rel = raw.startsWith("/workspace/") ? raw.slice("/workspace/".length) : "";
|
|
191
|
+
const cwd = process.cwd();
|
|
192
|
+
return rel ? path.join(cwd, rel) : cwd;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function downloadMediaBuffer(
|
|
196
|
+
url: string,
|
|
197
|
+
maxBytes: number,
|
|
198
|
+
log?: Log,
|
|
199
|
+
): Promise<{ buffer: Buffer; filename: string }> {
|
|
200
|
+
const raw = url.trim();
|
|
201
|
+
log?.info(`[cwork_im:media] resolve ref=${summarizeMediaRef(raw)}`);
|
|
202
|
+
|
|
203
|
+
// Handle OpenClaw /workspace/ convention: map to actual workspace directory.
|
|
204
|
+
const workspaceResolved = resolveWorkspacePrefixPath(raw);
|
|
205
|
+
if (workspaceResolved && workspaceResolved !== raw) {
|
|
206
|
+
log?.info(`[cwork_im:media] /workspace/ -> ${summarizeMediaRef(workspaceResolved)}`);
|
|
207
|
+
try {
|
|
208
|
+
return await readLocalFsIntoBuffer(workspaceResolved, maxBytes, log);
|
|
209
|
+
} catch (err: unknown) {
|
|
210
|
+
log?.error(`[cwork_im:media] readFile failed /workspace/ ${summarizeMediaRef(workspaceResolved)}: ${String(err)}`);
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (raw.startsWith("file://")) {
|
|
216
|
+
let fsPath: string;
|
|
217
|
+
try {
|
|
218
|
+
fsPath = fileURLToPath(raw);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
log?.error(`[cwork_im:media] invalid file:// URL: ${summarizeMediaRef(raw)} ${String(e)}`);
|
|
221
|
+
throw e;
|
|
222
|
+
}
|
|
223
|
+
log?.info(`[cwork_im:media] read file:// -> ${summarizeMediaRef(fsPath)}`);
|
|
224
|
+
try {
|
|
225
|
+
return await readLocalFsIntoBuffer(fsPath, maxBytes, log);
|
|
226
|
+
} catch (err: unknown) {
|
|
227
|
+
log?.error(`[cwork_im:media] readFile failed file:// ${summarizeMediaRef(fsPath)}: ${String(err)}`);
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (isHttpOrHttps(raw)) {
|
|
233
|
+
log?.info(`[cwork_im:media] GET ${summarizeMediaRef(raw)}`);
|
|
234
|
+
try {
|
|
235
|
+
const res = await axios.get(raw, {
|
|
236
|
+
responseType: "arraybuffer",
|
|
237
|
+
timeout: 120_000,
|
|
238
|
+
maxContentLength: maxBytes,
|
|
239
|
+
maxBodyLength: maxBytes,
|
|
240
|
+
validateStatus: (s) => s >= 200 && s < 300,
|
|
241
|
+
});
|
|
242
|
+
const buffer = Buffer.from(res.data as ArrayBuffer);
|
|
243
|
+
let filename = displayNameFromUrl(raw);
|
|
244
|
+
const cdRaw = res.headers["content-disposition"];
|
|
245
|
+
const cd = Array.isArray(cdRaw) ? cdRaw[0] : cdRaw;
|
|
246
|
+
if (typeof cd === "string") {
|
|
247
|
+
const fromCd = parseFilenameFromContentDisposition(cd);
|
|
248
|
+
if (fromCd) filename = fromCd;
|
|
249
|
+
}
|
|
250
|
+
log?.info(`[cwork_im:media] http ok bytes=${buffer.length} name=${filename}`);
|
|
251
|
+
return { buffer, filename };
|
|
252
|
+
} catch (err: unknown) {
|
|
253
|
+
const detail = axios.isAxiosError(err)
|
|
254
|
+
? `${err.message}${err.code ? ` axiosCode=${err.code}` : ""}${err.response?.status != null ? ` httpStatus=${err.response.status}` : ""}`
|
|
255
|
+
: String(err);
|
|
256
|
+
log?.error(`[cwork_im:media] http failed ref=${summarizeMediaRef(raw)}: ${detail}`);
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const resolved = resolveLocalFsPath(raw);
|
|
262
|
+
if (resolved) {
|
|
263
|
+
log?.info(`[cwork_im:media] local via=${resolved.via} -> ${summarizeMediaRef(resolved.fsPath)}`);
|
|
264
|
+
try {
|
|
265
|
+
return await readLocalFsIntoBuffer(resolved.fsPath, maxBytes, log);
|
|
266
|
+
} catch (err: unknown) {
|
|
267
|
+
log?.error(`[cwork_im:media] readFile failed ${summarizeMediaRef(resolved.fsPath)}: ${String(err)}`);
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const hint =
|
|
273
|
+
"请使用 http(s)、file://(三系统通用),或本机绝对路径:Windows C:\\\\... / UNC;macOS/Linux /Users、/home、/mnt/c/...(WSL);Windows 下 Git Bash 可用 /c/...";
|
|
274
|
+
log?.error(`[cwork_im:media] unsupported ref=${summarizeMediaRef(raw)} (${hint})`);
|
|
275
|
+
throw new Error(`[cwork_im] 无法读取媒体(${hint}): ${summarizeMediaRef(raw)}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* multipart 上传整文件,返回 fileId 与字节数。
|
|
280
|
+
*/
|
|
281
|
+
export async function uploadWholeResourceFile(args: {
|
|
282
|
+
fileUploadUrl: string;
|
|
283
|
+
token: string;
|
|
284
|
+
buffer: Buffer;
|
|
285
|
+
filename: string;
|
|
286
|
+
formField: string;
|
|
287
|
+
log?: Log;
|
|
288
|
+
}): Promise<{ fileId: string; size: number }> {
|
|
289
|
+
const { fileUploadUrl, token, buffer, filename, formField, log } = args;
|
|
290
|
+
const form = new FormData();
|
|
291
|
+
// 使用 File 携带 UTF-8 文件名,避免部分环境下 Blob+第三参在 multipart 里被错误编码导致服务端乱码
|
|
292
|
+
const file = new File([buffer], filename, { type: "application/octet-stream" });
|
|
293
|
+
form.append(formField, file);
|
|
294
|
+
|
|
295
|
+
log?.info(`[cwork_im:upload] POST ${fileUploadUrl} field=${formField} name=${filename} bytes=${buffer.length}`);
|
|
296
|
+
|
|
297
|
+
const res = await fetch(fileUploadUrl, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: {
|
|
300
|
+
"access-token": token,
|
|
301
|
+
},
|
|
302
|
+
body: form,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const rawText = await res.text();
|
|
306
|
+
let json: unknown;
|
|
307
|
+
try {
|
|
308
|
+
json = rawText ? JSON.parse(rawText) : undefined;
|
|
309
|
+
} catch {
|
|
310
|
+
json = undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!res.ok) {
|
|
314
|
+
log?.error(`[cwork_im:upload] HTTP ${res.status} body=${rawText.slice(0, 500)}`);
|
|
315
|
+
throw new Error(`upload HTTP ${res.status}: ${rawText.slice(0, 500)}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Unified IM API response: { data, resultCode: 1, resultMsg: "" } — resultCode=1 means success.
|
|
319
|
+
if (json && typeof json === "object") {
|
|
320
|
+
const j = json as Record<string, unknown>;
|
|
321
|
+
if (j.resultCode !== undefined && j.resultCode !== 1) {
|
|
322
|
+
const msg = `upload IM API error resultCode=${j.resultCode}${j.resultMsg ? ` resultMsg=${j.resultMsg}` : ""}`;
|
|
323
|
+
log?.error(`[cwork_im:upload] ${msg} body=${rawText.slice(0, 500)}`);
|
|
324
|
+
throw new Error(msg);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const fileId = extractFileIdFromUploadResponse(json ?? rawText);
|
|
329
|
+
if (!fileId) {
|
|
330
|
+
log?.error(`[cwork_im:upload] missing fileId in body=${rawText.slice(0, 500)}`);
|
|
331
|
+
throw new Error(`upload response missing fileId: ${rawText.slice(0, 500)}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { fileId, size: buffer.length };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function resolveMaxAttachmentBytes(config: { maxAttachmentBytes?: number }): number {
|
|
338
|
+
return config.maxAttachmentBytes ?? DEFAULT_MAX_BYTES;
|
|
339
|
+
}
|
package/src/send-service.ts
CHANGED
|
@@ -27,6 +27,36 @@ function formatDeliverError(err: unknown): string {
|
|
|
27
27
|
return String(err);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* 发消息接口的重试参数。
|
|
32
|
+
*
|
|
33
|
+
* 总尝试次数 = 1(首次) + IM_SEND_MAX_RETRIES(重试) = 6,间隔约 1 秒。
|
|
34
|
+
* 仅对网络层瞬时错误(无响应 / 5xx / 408 / 429)重试;4xx 与业务 resultCode 错误视为永久失败,立即抛出。
|
|
35
|
+
*/
|
|
36
|
+
const IM_SEND_MAX_RETRIES = 5;
|
|
37
|
+
const IM_SEND_RETRY_DELAY_MS = 1_000;
|
|
38
|
+
|
|
39
|
+
function delay(ms: number): Promise<void> {
|
|
40
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 判断错误是否值得重试。
|
|
45
|
+
*
|
|
46
|
+
* - axios 网络错误 / 超时 / 5xx / 408 / 429 → 重试
|
|
47
|
+
* - 4xx(其它)/ 业务 resultCode 错误(普通 Error)→ 永久失败,不重试
|
|
48
|
+
*/
|
|
49
|
+
function isRetryableSendError(err: unknown): boolean {
|
|
50
|
+
if (axios.isAxiosError(err)) {
|
|
51
|
+
const status = err.response?.status;
|
|
52
|
+
if (status === undefined) return true;
|
|
53
|
+
if (status >= 500) return true;
|
|
54
|
+
if (status === 408 || status === 429) return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
30
60
|
async function postImMessage(
|
|
31
61
|
config: XgImConfig,
|
|
32
62
|
token: string,
|
|
@@ -47,37 +77,62 @@ async function postImMessage(
|
|
|
47
77
|
log?.debug?.(`[cwork_im:send] Request body: ${JSON.stringify(full)}`);
|
|
48
78
|
}
|
|
49
79
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
headers: {
|
|
53
|
-
"Content-Type": "application/json",
|
|
54
|
-
"access-token": token,
|
|
55
|
-
},
|
|
56
|
-
timeout: 10_000,
|
|
57
|
-
});
|
|
80
|
+
const totalAttempts = 1 + IM_SEND_MAX_RETRIES;
|
|
81
|
+
let lastErr: unknown = undefined;
|
|
58
82
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
83
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
84
|
+
try {
|
|
85
|
+
const res = await axios.post(url, full, {
|
|
86
|
+
headers: {
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"access-token": token,
|
|
89
|
+
},
|
|
90
|
+
timeout: 10_000,
|
|
91
|
+
});
|
|
63
92
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
log?.info(`[cwork_im:send] Response status=${res.status} (attempt=${attempt}/${totalAttempts})`);
|
|
94
|
+
if (config.debug) {
|
|
95
|
+
log?.debug?.(`[cwork_im:send] Response body: ${JSON.stringify(res.data)}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Detect business-level errors that arrive with HTTP 200.
|
|
99
|
+
// IM API unified response: { data, resultCode: 1, resultMsg: "" } — resultCode=1 means success.
|
|
100
|
+
const d = res.data;
|
|
101
|
+
if (d && typeof d === "object") {
|
|
102
|
+
const resultCode = (d as Record<string, unknown>).resultCode;
|
|
103
|
+
const resultMsg = (d as Record<string, unknown>).resultMsg;
|
|
104
|
+
if (resultCode !== undefined && resultCode !== 1) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`IM API error resultCode=${resultCode}${resultMsg ? ` resultMsg=${resultMsg}` : ""}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
} catch (err: unknown) {
|
|
112
|
+
lastErr = err;
|
|
113
|
+
const msg = formatDeliverError(err);
|
|
114
|
+
const retryable = isRetryableSendError(err);
|
|
115
|
+
const isLast = attempt >= totalAttempts;
|
|
116
|
+
|
|
117
|
+
if (!retryable || isLast) {
|
|
118
|
+
log?.error(
|
|
119
|
+
`[cwork_im:send] Failed to send message to groupId=${groupId} ` +
|
|
120
|
+
`(attempt=${attempt}/${totalAttempts}, retryable=${retryable}): ${msg}`,
|
|
73
121
|
);
|
|
122
|
+
throw err;
|
|
74
123
|
}
|
|
124
|
+
|
|
125
|
+
const warn = log?.warn ?? log?.info;
|
|
126
|
+
warn?.(
|
|
127
|
+
`[cwork_im:send] Send failed (attempt=${attempt}/${totalAttempts}), ` +
|
|
128
|
+
`retrying in ${IM_SEND_RETRY_DELAY_MS}ms: ${msg}`,
|
|
129
|
+
);
|
|
130
|
+
await delay(IM_SEND_RETRY_DELAY_MS);
|
|
75
131
|
}
|
|
76
|
-
} catch (err: unknown) {
|
|
77
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
78
|
-
log?.error(`[cwork_im:send] Failed to send message to groupId=${groupId}: ${msg}`);
|
|
79
|
-
throw err;
|
|
80
132
|
}
|
|
133
|
+
|
|
134
|
+
// 理论上不可达:上面循环里要么 return 要么 throw。这里只是给 TS 一个安全兜底。
|
|
135
|
+
throw lastErr ?? new Error("[cwork_im:send] Send failed after retries");
|
|
81
136
|
}
|
|
82
137
|
|
|
83
138
|
/** OpenClaw dispatch deliver 单块载荷:文本 + 可选媒体 URL(与 plugin-sdk 约定对齐)。 */
|
package/src/types.ts
CHANGED
|
@@ -29,7 +29,9 @@ export type XgCworkImPluginModule = XgImPluginModule;
|
|
|
29
29
|
|
|
30
30
|
/** 单个机器人账户配置 */
|
|
31
31
|
export interface XgImAccountConfig {
|
|
32
|
-
/** 机器人 appKey
|
|
32
|
+
/** 机器人 Key(新字段,优先于 appKey) */
|
|
33
|
+
robotKey?: string;
|
|
34
|
+
/** 机器人 appKey,从 IM 后台注册获取(已改名为 robotKey,此字段保留兼容旧配置) */
|
|
33
35
|
appKey?: string;
|
|
34
36
|
/** 对应 OpenClaw 的 Agent ID,默认为 'main' */
|
|
35
37
|
agentId?: string;
|
|
@@ -65,7 +67,9 @@ export interface XgImConfig extends OpenClawConfig {
|
|
|
65
67
|
/** 多账户列表 */
|
|
66
68
|
accounts?: Record<string, XgImAccountConfig>;
|
|
67
69
|
|
|
68
|
-
/** 机器人 appKey
|
|
70
|
+
/** 机器人 Key(新字段,优先于 appKey,单账户模式) */
|
|
71
|
+
robotKey?: string;
|
|
72
|
+
/** 机器人 appKey(单账户模式,已改名为 robotKey,此字段保留兼容旧配置) */
|
|
69
73
|
appKey?: string;
|
|
70
74
|
/** 对应 OpenClaw 的 Agent ID(单账户模式) */
|
|
71
75
|
agentId?: string;
|