@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 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 login
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
- - 在命令行登录时使用 **OTP**(一次性密码)。
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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgjktech/xg_cwork_im",
3
- "version": "1.11.1",
3
+ "version": "1.11.3",
4
4
  "description": "XG CWork IM channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
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
- appKey: z.string().min(1, "appKey is required").optional(),
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 (!("appKey" in acc) || !acc.appKey) {
122
+ if (!acc.robotKey && !acc.appKey) {
121
123
  ctx.addIssue({
122
124
  code: z.ZodIssueCode.custom,
123
- path: ["accounts", key, "appKey"],
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
- if (!hasFirstReply) {
527
- log.warn(
528
- `${logPrefix} [dispatch] No reply was sent to user after dispatch completed` +
529
- ` (delivered=${deliverCallCount} skipped=${deliverSkippedCount})`,
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, "stop");
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
  }
@@ -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
+ }
@@ -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
- try {
51
- const res = await axios.post(url, full, {
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
- log?.info(`[cwork_im:send] Response status=${res.status}`);
60
- if (config.debug) {
61
- log?.debug?.(`[cwork_im:send] Response body: ${JSON.stringify(res.data)}`);
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
- // Detect business-level errors that arrive with HTTP 200.
65
- // IM API unified response: { data, resultCode: 1, resultMsg: "" } — resultCode=1 means success.
66
- const d = res.data;
67
- if (d && typeof d === "object") {
68
- const resultCode = (d as Record<string, unknown>).resultCode;
69
- const resultMsg = (d as Record<string, unknown>).resultMsg;
70
- if (resultCode !== undefined && resultCode !== 1) {
71
- throw new Error(
72
- `IM API error resultCode=${resultCode}${resultMsg ? ` resultMsg=${resultMsg}` : ""}`,
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,从 IM 后台注册获取 */
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;