@xgjktech/xg_cwork_im 1.11.0 → 1.11.1

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/index.ts CHANGED
@@ -11,6 +11,8 @@ import { buildGroupHistoryTool, extractXgCworkImConfig } from "./src/group-histo
11
11
  import { buildSendGroupMessageTool } from "./src/send-group-message-tool.js";
12
12
  import type { XgCworkImPluginModule } from "./src/types.js";
13
13
 
14
+ export { XG_IM_RECOMMENDED_CHANNEL_SYSTEM_PROMPT } from "./src/recommended-system-prompt.js";
15
+
14
16
  const plugin: XgCworkImPluginModule = {
15
17
  id: "xg_cwork_im",
16
18
  name: "XG CWork IM Channel",
@@ -24,6 +26,7 @@ const plugin: XgCworkImPluginModule = {
24
26
  if (cworkConfig) {
25
27
  // 工具涉及网络请求 / 副作用,按文档要求标记为 optional,由用户在 Agent 的 tools.allow 中显式启用
26
28
  api.registerTool(buildGroupHistoryTool(cworkConfig), { optional: true });
29
+ // 主动推送(Cron/定时等)向 IM 发纯文本;用户经 IM 对话时不用此工具
27
30
  api.registerTool(buildSendGroupMessageTool(cworkConfig), { optional: true });
28
31
  } else {
29
32
  console.warn("[xg_cwork_im] channel config not found, skipping tool registration");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgjktech/xg_cwork_im",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "XG CWork IM channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
package/src/channel.ts CHANGED
@@ -428,20 +428,43 @@ async function dispatchMentionedReply(args: {
428
428
  }
429
429
  }, firstReplyTimeoutMs);
430
430
 
431
+ let deliverCallCount = 0;
432
+ let deliverSkippedCount = 0;
433
+
431
434
  try {
432
435
  await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
433
436
  ctx: inboundCtx,
434
437
  cfg,
435
438
  dispatcherOptions: {
436
439
  responsePrefix: "",
440
+ // Logged when normalizeReplyPayload decides to skip a payload (e.g. heartbeat, silent token, empty).
441
+ onSkip: (payload: unknown, meta: { kind: string; reason: string }) => {
442
+ deliverSkippedCount++;
443
+ log.info(
444
+ `${logPrefix} [deliver] Payload skipped by normalizer kind=${meta.kind} reason=${meta.reason}`,
445
+ );
446
+ },
447
+ // Logged when deliver throws and the dispatcher catches it (error would otherwise be silently dropped).
448
+ onError: (err: unknown, meta: { kind: string }) => {
449
+ log.error(
450
+ `${logPrefix} [deliver] Dispatcher caught unhandled error kind=${meta.kind}: ${String(err)}`,
451
+ );
452
+ },
437
453
  deliver: async (payload: ReplyDeliverPayload) => {
438
454
  try {
439
455
  const textPart = (payload.markdown || payload.text || "").trim();
440
456
  const hasMedia =
441
457
  Boolean(payload.mediaUrl?.trim()) ||
442
458
  Boolean(payload.mediaUrls?.some((u) => typeof u === "string" && u.trim()));
443
- if (!textPart && !(hasMedia && !payload.isThinking)) return;
459
+ if (!textPart && !(hasMedia && !payload.isThinking)) {
460
+ log.info(
461
+ `${logPrefix} [deliver] Payload has no sendable content, skipping` +
462
+ ` (isThinking=${payload.isThinking ?? false} hasMedia=${hasMedia})`,
463
+ );
464
+ return;
465
+ }
444
466
 
467
+ deliverCallCount++;
445
468
  if (isFirstReply) {
446
469
  const ttfr = Date.now() - dispatchStart;
447
470
  log.info(
@@ -490,13 +513,22 @@ async function dispatchMentionedReply(args: {
490
513
  `${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`,
491
514
  );
492
515
  } catch (err: unknown) {
493
- log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
516
+ log.error(`${logPrefix} [deliver] Reply deliver failed: ${String(err)}`);
494
517
  throw err;
495
518
  }
496
519
  },
497
520
  },
498
521
  });
499
- log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}`);
522
+ log.info(
523
+ `${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}` +
524
+ ` (delivered=${deliverCallCount} skipped=${deliverSkippedCount})`,
525
+ );
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
+ }
500
532
  } finally {
501
533
  clearTimeout(firstReplyTimeout);
502
534
  try {
@@ -0,0 +1,3 @@
1
+ /** 建议复制到 `channels.xg_cwork_im.systemPrompt`。 */
2
+ export const XG_IM_RECOMMENDED_CHANNEL_SYSTEM_PROMPT =
3
+ "发文件给用户时,在回复正文里单独写一行 `MEDIA:<路径>`,例如:MEDIA:/workspace/report.pdf。路径支持 /workspace/…、file://…、http(s)://…。OpenClaw 会自动读取该路径并让通道上传,用户可直接在 IM 中下载。不要把路径当作已发送;也不要上传到外部网站。";
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import axios from "axios";
8
+ import { File } from "node:buffer";
8
9
  import { readFile } from "node:fs/promises";
9
10
  import path from "node:path";
10
11
  import { fileURLToPath } from "node:url";
@@ -44,14 +45,63 @@ export function formatFromFilename(filename: string): string {
44
45
  return "bin";
45
46
  }
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
+
47
91
  export function displayNameFromUrl(url: string): string {
48
92
  try {
49
93
  if (url.startsWith("file://")) {
50
94
  return path.basename(fileURLToPath(url)) || "file";
51
95
  }
52
96
  const u = new URL(url);
53
- const seg = path.basename(u.pathname) || "attachment";
54
- return seg.split("?")[0] || "attachment";
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";
55
105
  } catch {
56
106
  return "attachment";
57
107
  }
@@ -124,6 +174,24 @@ async function readLocalFsIntoBuffer(fsPath: string, maxBytes: number, log?: Log
124
174
  return { buffer, filename };
125
175
  }
126
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
+
127
195
  export async function downloadMediaBuffer(
128
196
  url: string,
129
197
  maxBytes: number,
@@ -132,6 +200,18 @@ export async function downloadMediaBuffer(
132
200
  const raw = url.trim();
133
201
  log?.info(`[cwork_im:media] resolve ref=${summarizeMediaRef(raw)}`);
134
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
+
135
215
  if (raw.startsWith("file://")) {
136
216
  let fsPath: string;
137
217
  try {
@@ -161,16 +241,11 @@ export async function downloadMediaBuffer(
161
241
  });
162
242
  const buffer = Buffer.from(res.data as ArrayBuffer);
163
243
  let filename = displayNameFromUrl(raw);
164
- const cd = res.headers["content-disposition"];
244
+ const cdRaw = res.headers["content-disposition"];
245
+ const cd = Array.isArray(cdRaw) ? cdRaw[0] : cdRaw;
165
246
  if (typeof cd === "string") {
166
- const m = /filename\*?=(?:UTF-8'')?["']?([^"';]+)["']?/i.exec(cd);
167
- if (m?.[1]) {
168
- try {
169
- filename = decodeURIComponent(m[1].trim());
170
- } catch {
171
- filename = m[1].trim();
172
- }
173
- }
247
+ const fromCd = parseFilenameFromContentDisposition(cd);
248
+ if (fromCd) filename = fromCd;
174
249
  }
175
250
  log?.info(`[cwork_im:media] http ok bytes=${buffer.length} name=${filename}`);
176
251
  return { buffer, filename };
@@ -212,9 +287,10 @@ export async function uploadWholeResourceFile(args: {
212
287
  log?: Log;
213
288
  }): Promise<{ fileId: string; size: number }> {
214
289
  const { fileUploadUrl, token, buffer, filename, formField, log } = args;
215
- const blob = new Blob([new Uint8Array(buffer)]);
216
290
  const form = new FormData();
217
- form.append(formField, blob, filename);
291
+ // 使用 File 携带 UTF-8 文件名,避免部分环境下 Blob+第三参在 multipart 里被错误编码导致服务端乱码
292
+ const file = new File([buffer], filename, { type: "application/octet-stream" });
293
+ form.append(formField, file);
218
294
 
219
295
  log?.info(`[cwork_im:upload] POST ${fileUploadUrl} field=${formField} name=${filename} bytes=${buffer.length}`);
220
296
 
@@ -239,6 +315,16 @@ export async function uploadWholeResourceFile(args: {
239
315
  throw new Error(`upload HTTP ${res.status}: ${rawText.slice(0, 500)}`);
240
316
  }
241
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
+
242
328
  const fileId = extractFileIdFromUploadResponse(json ?? rawText);
243
329
  if (!fileId) {
244
330
  log?.error(`[cwork_im:upload] missing fileId in body=${rawText.slice(0, 500)}`);
@@ -1,11 +1,10 @@
1
1
  /**
2
- * XG-IM 发送群消息工具(供 AI 按需调用)
2
+ * XG-IM「主动推消息」工具(供 OpenClaw 在非对话场景下调用)
3
3
  *
4
- * 适用场景:
5
- * - Cron 定时任务主动推送通知
6
- * - AI 分析后需要主动发起提醒(无需用户先 @ 机器人)
4
+ * 用途:定时任务、Cron、后台任务等需要 **经本 channel 主动把结果推送到 IM 群** 时,发一条纯文本。
5
+ * **不用于**用户正通过 IM 与助手聊天时的回合内回复——那种情况走正常助手回复流,用不上本工具。
7
6
  *
8
- * 通过 api.registerTool() 注入,工具内部自动获取机器人 token,AI 无需感知鉴权细节。
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() 里调用,config 在注册时捕获进闭包,
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
- "【仅限 xg_cwork_im 通道】向指定 IM 群聊发送一条消息。",
54
- "适用于定时任务主动推送通知、AI 分析后发起提醒等场景。",
55
- "消息将以机器人身份发出,可选择 @ 特定用户。",
56
- "groupId 为 IM 群组的唯一 ID(不是群名称),通常从当前对话的 group_channel 字段中解析(格式:agent:main:xg_cwork_im:group:<groupId>)。",
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>) {
@@ -60,6 +60,19 @@ async function postImMessage(
60
60
  if (config.debug) {
61
61
  log?.debug?.(`[cwork_im:send] Response body: ${JSON.stringify(res.data)}`);
62
62
  }
63
+
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}` : ""}`,
73
+ );
74
+ }
75
+ }
63
76
  } catch (err: unknown) {
64
77
  const msg = err instanceof Error ? err.message : String(err);
65
78
  log?.error(`[cwork_im:send] Failed to send message to groupId=${groupId}: ${msg}`);
@@ -141,21 +154,21 @@ export async function sendReplyDeliverBlock(
141
154
  return;
142
155
  } catch (err: unknown) {
143
156
  log?.error(`[cwork_im:send] FILE deliver failed, fallback to text if any: ${formatDeliverError(err)}`);
144
- if (textToSend.length > 0) {
145
- await postImMessage(
146
- config,
147
- token,
148
- groupId,
149
- {
150
- type: "RICH_TEXT",
151
- text: `${textToSend}\n\n(媒体未发出:${formatDeliverError(err)})`,
152
- ...(msgId ? { msgId } : {}),
153
- ...(reply ? { reply } : {}),
154
- },
155
- atUserIds,
156
- log,
157
- );
158
- }
157
+ const errNote = `(文件发送失败:${formatDeliverError(err)})`;
158
+ const fallbackText = textToSend.length > 0 ? `${textToSend}\n\n${errNote}` : errNote;
159
+ await postImMessage(
160
+ config,
161
+ token,
162
+ groupId,
163
+ {
164
+ type: "RICH_TEXT",
165
+ text: fallbackText,
166
+ ...(msgId ? { msgId } : {}),
167
+ ...(reply ? { reply } : {}),
168
+ },
169
+ atUserIds,
170
+ log,
171
+ );
159
172
  return;
160
173
  }
161
174
  }
package/src/types.ts CHANGED
@@ -289,6 +289,7 @@ export type SendMessageBody =
289
289
  toUserId?: string;
290
290
  text: string;
291
291
  atUserIds?: string[];
292
+ /** 流式占位消息 ID,用于将回复更新到对应的占位消息上 */
292
293
  msgId?: string;
293
294
  reply?: SendMessageReply;
294
295
  }
@@ -299,6 +300,7 @@ export type SendMessageBody =
299
300
  text: string;
300
301
  attachments: SendMessageFileAttachment[];
301
302
  atUserIds?: string[];
303
+ /** 流式占位消息 ID,用于将回复更新到对应的占位消息上 */
302
304
  msgId?: string;
303
305
  reply?: SendMessageReply;
304
306
  };