@xgjktech/xg_cwork_im 1.11.0 → 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 -34
- package/openclaw.plugin.json +9 -0
- package/package.json +1 -1
- package/src/channel.ts +110 -17
- package/src/recommended-system-prompt.ts +3 -0
- package/src/resource-file.ts +339 -253
- package/src/send-group-message-tool.ts +12 -14
- package/src/send-service.ts +98 -30
- package/src/types.ts +8 -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,34 +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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
);
|
|
@@ -428,20 +442,48 @@ async function dispatchMentionedReply(args: {
|
|
|
428
442
|
}
|
|
429
443
|
}, firstReplyTimeoutMs);
|
|
430
444
|
|
|
445
|
+
let deliverCallCount = 0;
|
|
446
|
+
let deliverSkippedCount = 0;
|
|
447
|
+
let dispatchError: unknown = undefined;
|
|
448
|
+
// 捕获 deliver 抛出后被 dispatcher 静默吞掉的错误:postImMessage 已带 5 次重试,
|
|
449
|
+
// 进到这里说明所有重试均失败(IM 后台不可用 / 4xx / resultCode 业务错误等),需要让 finally 走兜底分支
|
|
450
|
+
let lastDeliverError: unknown = undefined;
|
|
451
|
+
|
|
431
452
|
try {
|
|
432
453
|
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
433
454
|
ctx: inboundCtx,
|
|
434
455
|
cfg,
|
|
435
456
|
dispatcherOptions: {
|
|
436
457
|
responsePrefix: "",
|
|
458
|
+
// Logged when normalizeReplyPayload decides to skip a payload (e.g. heartbeat, silent token, empty).
|
|
459
|
+
onSkip: (payload: unknown, meta: { kind: string; reason: string }) => {
|
|
460
|
+
deliverSkippedCount++;
|
|
461
|
+
log.info(
|
|
462
|
+
`${logPrefix} [deliver] Payload skipped by normalizer kind=${meta.kind} reason=${meta.reason}`,
|
|
463
|
+
);
|
|
464
|
+
},
|
|
465
|
+
// Logged when deliver throws and the dispatcher catches it (error would otherwise be silently dropped).
|
|
466
|
+
onError: (err: unknown, meta: { kind: string }) => {
|
|
467
|
+
lastDeliverError = err;
|
|
468
|
+
log.error(
|
|
469
|
+
`${logPrefix} [deliver] Dispatcher caught unhandled error kind=${meta.kind}: ${String(err)}`,
|
|
470
|
+
);
|
|
471
|
+
},
|
|
437
472
|
deliver: async (payload: ReplyDeliverPayload) => {
|
|
438
473
|
try {
|
|
439
474
|
const textPart = (payload.markdown || payload.text || "").trim();
|
|
440
475
|
const hasMedia =
|
|
441
476
|
Boolean(payload.mediaUrl?.trim()) ||
|
|
442
477
|
Boolean(payload.mediaUrls?.some((u) => typeof u === "string" && u.trim()));
|
|
443
|
-
if (!textPart && !(hasMedia && !payload.isThinking))
|
|
478
|
+
if (!textPart && !(hasMedia && !payload.isThinking)) {
|
|
479
|
+
log.info(
|
|
480
|
+
`${logPrefix} [deliver] Payload has no sendable content, skipping` +
|
|
481
|
+
` (isThinking=${payload.isThinking ?? false} hasMedia=${hasMedia})`,
|
|
482
|
+
);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
444
485
|
|
|
486
|
+
deliverCallCount++;
|
|
445
487
|
if (isFirstReply) {
|
|
446
488
|
const ttfr = Date.now() - dispatchStart;
|
|
447
489
|
log.info(
|
|
@@ -454,7 +496,6 @@ async function dispatchMentionedReply(args: {
|
|
|
454
496
|
const atIds = [reply.targetUserId] as string[];
|
|
455
497
|
|
|
456
498
|
if (!hasFirstReply) {
|
|
457
|
-
hasFirstReply = true;
|
|
458
499
|
clearTimeout(firstReplyTimeout);
|
|
459
500
|
await sendReplyDeliverBlock(
|
|
460
501
|
config,
|
|
@@ -466,6 +507,7 @@ async function dispatchMentionedReply(args: {
|
|
|
466
507
|
streamMsgId,
|
|
467
508
|
reply,
|
|
468
509
|
);
|
|
510
|
+
hasFirstReply = true; // set after successful send
|
|
469
511
|
const preview =
|
|
470
512
|
textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
471
513
|
log.info(
|
|
@@ -490,18 +532,69 @@ async function dispatchMentionedReply(args: {
|
|
|
490
532
|
`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`,
|
|
491
533
|
);
|
|
492
534
|
} catch (err: unknown) {
|
|
493
|
-
log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
|
|
535
|
+
log.error(`${logPrefix} [deliver] Reply deliver failed: ${String(err)}`);
|
|
494
536
|
throw err;
|
|
495
537
|
}
|
|
496
538
|
},
|
|
497
539
|
},
|
|
498
540
|
});
|
|
499
|
-
log.info(
|
|
541
|
+
log.info(
|
|
542
|
+
`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}` +
|
|
543
|
+
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount})`,
|
|
544
|
+
);
|
|
545
|
+
} catch (dispatchErr: unknown) {
|
|
546
|
+
dispatchError = dispatchErr;
|
|
547
|
+
log.error(
|
|
548
|
+
`${logPrefix} [dispatch] Dispatch failed (delivered=${deliverCallCount} skipped=${deliverSkippedCount}): ${String(dispatchErr)}`,
|
|
549
|
+
);
|
|
500
550
|
} finally {
|
|
501
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";
|
|
502
595
|
try {
|
|
503
|
-
await streamClient.end(streamMsgId,
|
|
504
|
-
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}`);
|
|
505
598
|
} catch (endErr: unknown) {
|
|
506
599
|
log.error(`${logPrefix} [stream] END failed (msgId=${streamMsgId}): ${String(endErr)}`);
|
|
507
600
|
}
|
package/src/resource-file.ts
CHANGED
|
@@ -1,253 +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 {
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
if (typeof data
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return
|
|
55
|
-
} catch {
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* XG-IM
|
|
2
|
+
* XG-IM「主动推消息」工具(供 OpenClaw 在非对话场景下调用)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - AI 分析后需要主动发起提醒(无需用户先 @ 机器人)
|
|
4
|
+
* 用途:定时任务、Cron、后台任务等需要 **经本 channel 主动把结果推送到 IM 群** 时,发一条纯文本。
|
|
5
|
+
* **不用于**用户正通过 IM 与助手聊天时的回合内回复——那种情况走正常助手回复流,用不上本工具。
|
|
7
6
|
*
|
|
8
|
-
* 通过 api.registerTool() 注入,工具内部自动获取机器人 token
|
|
7
|
+
* 通过 api.registerTool() 注入,工具内部自动获取机器人 token。
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { Type, type Static } from "@sinclair/typebox";
|
|
@@ -29,7 +28,7 @@ const SendGroupMessageParams = Type.Object({
|
|
|
29
28
|
}),
|
|
30
29
|
),
|
|
31
30
|
text: Type.String({
|
|
32
|
-
description: "
|
|
31
|
+
description: "要发送的消息内容(仅纯文本,不支持附件/文件)",
|
|
33
32
|
}),
|
|
34
33
|
atUserIds: Type.Optional(
|
|
35
34
|
Type.Array(Type.String(), {
|
|
@@ -41,19 +40,18 @@ const SendGroupMessageParams = Type.Object({
|
|
|
41
40
|
// ─── 工具构建函数 ─────────────────────────────────────────────────────────────
|
|
42
41
|
|
|
43
42
|
/**
|
|
44
|
-
*
|
|
45
|
-
* 由 index.ts 在 register()
|
|
46
|
-
* 工具执行时自动调用 getToken(config) 获取机器人 token。
|
|
43
|
+
* 构建「主动推送群消息」工具(纯文本)。
|
|
44
|
+
* 由 index.ts 在 register() 里调用。
|
|
47
45
|
*/
|
|
48
46
|
export function buildSendGroupMessageTool(config: XgImConfig): AnyAgentTool {
|
|
49
47
|
return {
|
|
50
48
|
name: "xg_cwork_im_send_group_message",
|
|
51
|
-
label: "【xg_cwork_im
|
|
49
|
+
label: "【xg_cwork_im】主动推送群消息(纯文本)",
|
|
52
50
|
description: [
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"groupId 为 IM
|
|
51
|
+
"【用途】仅用于 OpenClaw **主动推消息**:例如定时任务 / Cron / 后台任务完成后,经 xg_cwork_im 向群里发一条**纯文字**结果或通知。",
|
|
52
|
+
"【不要用错】用户正在 IM 里和助手**对话**时,**不要**用本工具回话——对话回复由 OpenClaw 正常走 channel,**用不上**本工具。",
|
|
53
|
+
"【发文件】本工具不能发附件。用户要「发报告/发文件」时,在**对用户的助手回复**里用 **mediaUrl(s)** 指向 workspace 或 file://;**禁止**为发文件上传到 tmpfiles 等外站。",
|
|
54
|
+
"【参数】groupId 为 IM 群唯一 ID;主动推送时若需告知某人,可填 atUserIds。",
|
|
57
55
|
].join("\n"),
|
|
58
56
|
parameters: SendGroupMessageParams,
|
|
59
57
|
async execute(_toolCallId: string, params: Static<typeof SendGroupMessageParams>) {
|
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,24 +77,62 @@ async function postImMessage(
|
|
|
47
77
|
log?.debug?.(`[cwork_im:send] Request body: ${JSON.stringify(full)}`);
|
|
48
78
|
}
|
|
49
79
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
const totalAttempts = 1 + IM_SEND_MAX_RETRIES;
|
|
81
|
+
let lastErr: unknown = undefined;
|
|
82
|
+
|
|
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
|
+
});
|
|
58
92
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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}`,
|
|
121
|
+
);
|
|
122
|
+
throw err;
|
|
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);
|
|
62
131
|
}
|
|
63
|
-
} catch (err: unknown) {
|
|
64
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
65
|
-
log?.error(`[cwork_im:send] Failed to send message to groupId=${groupId}: ${msg}`);
|
|
66
|
-
throw err;
|
|
67
132
|
}
|
|
133
|
+
|
|
134
|
+
// 理论上不可达:上面循环里要么 return 要么 throw。这里只是给 TS 一个安全兜底。
|
|
135
|
+
throw lastErr ?? new Error("[cwork_im:send] Send failed after retries");
|
|
68
136
|
}
|
|
69
137
|
|
|
70
138
|
/** OpenClaw dispatch deliver 单块载荷:文本 + 可选媒体 URL(与 plugin-sdk 约定对齐)。 */
|
|
@@ -141,21 +209,21 @@ export async function sendReplyDeliverBlock(
|
|
|
141
209
|
return;
|
|
142
210
|
} catch (err: unknown) {
|
|
143
211
|
log?.error(`[cwork_im:send] FILE deliver failed, fallback to text if any: ${formatDeliverError(err)}`);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
},
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
212
|
+
const errNote = `(文件发送失败:${formatDeliverError(err)})`;
|
|
213
|
+
const fallbackText = textToSend.length > 0 ? `${textToSend}\n\n${errNote}` : errNote;
|
|
214
|
+
await postImMessage(
|
|
215
|
+
config,
|
|
216
|
+
token,
|
|
217
|
+
groupId,
|
|
218
|
+
{
|
|
219
|
+
type: "RICH_TEXT",
|
|
220
|
+
text: fallbackText,
|
|
221
|
+
...(msgId ? { msgId } : {}),
|
|
222
|
+
...(reply ? { reply } : {}),
|
|
223
|
+
},
|
|
224
|
+
atUserIds,
|
|
225
|
+
log,
|
|
226
|
+
);
|
|
159
227
|
return;
|
|
160
228
|
}
|
|
161
229
|
}
|
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;
|
|
@@ -289,6 +293,7 @@ export type SendMessageBody =
|
|
|
289
293
|
toUserId?: string;
|
|
290
294
|
text: string;
|
|
291
295
|
atUserIds?: string[];
|
|
296
|
+
/** 流式占位消息 ID,用于将回复更新到对应的占位消息上 */
|
|
292
297
|
msgId?: string;
|
|
293
298
|
reply?: SendMessageReply;
|
|
294
299
|
}
|
|
@@ -299,6 +304,7 @@ export type SendMessageBody =
|
|
|
299
304
|
text: string;
|
|
300
305
|
attachments: SendMessageFileAttachment[];
|
|
301
306
|
atUserIds?: string[];
|
|
307
|
+
/** 流式占位消息 ID,用于将回复更新到对应的占位消息上 */
|
|
302
308
|
msgId?: string;
|
|
303
309
|
reply?: SendMessageReply;
|
|
304
310
|
};
|