@xgjktech/xg_cwork_im 1.10.3 → 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.10.3",
3
+ "version": "1.11.1",
4
4
  "description": "XG CWork IM channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -21,7 +21,8 @@
21
21
  "scripts": {
22
22
  "type-check": "tsc -p tsconfig.json --noEmit",
23
23
  "lint": "tsc -p tsconfig.json --noEmit",
24
- "install:prod": "npm install --omit=dev"
24
+ "install:prod": "npm install --omit=dev",
25
+ "prepublishOnly": "npm run type-check"
25
26
  },
26
27
  "dependencies": {
27
28
  "@sinclair/typebox": "^0.32.0",
package/src/channel.ts CHANGED
@@ -57,6 +57,7 @@ const XgImAccountConfigSchema = z.object({
57
57
  inboundMediaWorkspaceSubdir: z.string().optional(),
58
58
  inboundMediaOpenClawPath: z.enum(["workspaceRelative", "absoluteFileUrl"]).optional(),
59
59
  inboundMediaSandboxRootPrefix: z.string().optional(),
60
+ systemPrompt: z.string().optional(),
60
61
  });
61
62
 
62
63
  const XgImConfigSchema = z.object({
@@ -83,6 +84,8 @@ const XgImConfigSchema = z.object({
83
84
  inboundMediaOpenClawPath: z.enum(["workspaceRelative", "absoluteFileUrl"]).optional(),
84
85
  /** 沙箱内 workspace 挂载根,如 `/workspace`;与 workspaceRelative 组合生成绝对引用 */
85
86
  inboundMediaSandboxRootPrefix: z.string().optional(),
87
+ /** 群聊入站时写入 OpenClaw `GroupSystemPrompt`(与 accounts 内字段合并规则见 getXgImConfig) */
88
+ systemPrompt: z.string().optional(),
86
89
  // 多账户:对象 map(key 为 accountId)
87
90
  accounts: z.record(z.string(), XgImAccountConfigSchema).optional(),
88
91
  }).superRefine((val, ctx) => {
@@ -171,6 +174,31 @@ function getXgImConfig(cfg: OpenClawConfig, accountId?: string | null): XgImConf
171
174
  return raw;
172
175
  }
173
176
 
177
+ /**
178
+ * 合并 `channels.xg_cwork_im` 顶层、`accounts.default`、以及 `accounts.<accountId>` 后的 `systemPrompt`,
179
+ * 写入 OpenClaw 入站 `GroupSystemPrompt`(与 getXgImConfig 的非 default 账户合并方式一致;对 default 账户会合并 default 片段)。
180
+ */
181
+ function resolveXgImGroupSystemPrompt(cfg: OpenClawConfig, accountId: string): string | undefined {
182
+ try {
183
+ const raw = (cfg as Record<string, Record<string, unknown>>)?.channels?.xg_cwork_im as XgImConfig | undefined;
184
+ if (!raw) return undefined;
185
+ const accountMap =
186
+ raw.accounts && typeof raw.accounts === "object" && !Array.isArray(raw.accounts)
187
+ ? (raw.accounts as Record<string, Partial<XgImConfig> | undefined>)
188
+ : undefined;
189
+ const defaults = accountMap?.default ? { ...accountMap.default } : {};
190
+ const base: XgImConfig = { ...raw, ...defaults };
191
+ let merged: XgImConfig = base;
192
+ if (accountId && accountId !== "default" && accountMap?.[accountId]) {
193
+ merged = { ...base, ...accountMap[accountId] };
194
+ }
195
+ const t = merged.systemPrompt?.trim();
196
+ return t || undefined;
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
174
202
  function isConfigured(cfg: OpenClawConfig): boolean {
175
203
  try {
176
204
  const c = getXgImConfig(cfg);
@@ -400,20 +428,43 @@ async function dispatchMentionedReply(args: {
400
428
  }
401
429
  }, firstReplyTimeoutMs);
402
430
 
431
+ let deliverCallCount = 0;
432
+ let deliverSkippedCount = 0;
433
+
403
434
  try {
404
435
  await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
405
436
  ctx: inboundCtx,
406
437
  cfg,
407
438
  dispatcherOptions: {
408
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
+ },
409
453
  deliver: async (payload: ReplyDeliverPayload) => {
410
454
  try {
411
455
  const textPart = (payload.markdown || payload.text || "").trim();
412
456
  const hasMedia =
413
457
  Boolean(payload.mediaUrl?.trim()) ||
414
458
  Boolean(payload.mediaUrls?.some((u) => typeof u === "string" && u.trim()));
415
- 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
+ }
416
466
 
467
+ deliverCallCount++;
417
468
  if (isFirstReply) {
418
469
  const ttfr = Date.now() - dispatchStart;
419
470
  log.info(
@@ -462,13 +513,22 @@ async function dispatchMentionedReply(args: {
462
513
  `${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`,
463
514
  );
464
515
  } catch (err: unknown) {
465
- log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
516
+ log.error(`${logPrefix} [deliver] Reply deliver failed: ${String(err)}`);
466
517
  throw err;
467
518
  }
468
519
  },
469
520
  },
470
521
  });
471
- 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
+ }
472
532
  } finally {
473
533
  clearTimeout(firstReplyTimeout);
474
534
  try {
@@ -759,6 +819,16 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
759
819
  config,
760
820
  });
761
821
 
822
+ const groupSystemPromptResolved = resolveXgImGroupSystemPrompt(ctx.cfg, account.accountId);
823
+ if (config.debug) {
824
+ log.info(
825
+ `${logPrefix} [GroupSystemPrompt] ` +
826
+ (groupSystemPromptResolved
827
+ ? `active len=${groupSystemPromptResolved.length} preview=${JSON.stringify(groupSystemPromptResolved.slice(0, 96))}`
828
+ : "(empty — 若已配置 systemPrompt:检查网关是否加载了含本字段的插件构建,或把 systemPrompt 写到 accounts.<id> 再试)"),
829
+ );
830
+ }
831
+
762
832
  const inboundCtx = rt.channel.reply.finalizeInboundContext({
763
833
  Body: body,
764
834
  RawBody: text,
@@ -771,6 +841,7 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
771
841
  ChatType: "group",
772
842
  ConversationLabel: fromLabel,
773
843
  GroupSubject: params.groupId,
844
+ GroupSystemPrompt: groupSystemPromptResolved,
774
845
  SenderName: senderName,
775
846
  SenderId: senderId ?? "",
776
847
  Provider: "xg_cwork_im",
@@ -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
@@ -54,6 +54,11 @@ export interface XgImAccountConfig {
54
54
  * `{inboundMediaSandboxRootPrefix}/xg_im_inbound/...`,避免 `./` 被工具按 cwd 解析而报「不可访问」。
55
55
  */
56
56
  inboundMediaSandboxRootPrefix?: string;
57
+ /**
58
+ * 可选:本账户在群聊中追加给 Agent 的补充说明(映射为 OpenClaw 入站 `GroupSystemPrompt`)。
59
+ * 与顶层 `systemPrompt` 的合并规则见 `getXgImConfig`(`accounts.default` 与具体账户覆盖)。
60
+ */
61
+ systemPrompt?: string;
57
62
  }
58
63
 
59
64
  export interface XgImConfig extends OpenClawConfig {
@@ -115,6 +120,11 @@ export interface XgImConfig extends OpenClawConfig {
115
120
  inboundMediaOpenClawPath?: "workspaceRelative" | "absoluteFileUrl";
116
121
  /** 见 {@link XgImAccountConfig.inboundMediaSandboxRootPrefix} */
117
122
  inboundMediaSandboxRootPrefix?: string;
123
+ /**
124
+ * 可选:本通道在群聊中追加给 Agent 的补充说明(映射为 OpenClaw 入站 `GroupSystemPrompt`)。
125
+ * 多账户时可与 `accounts.<id>.systemPrompt` 及 `accounts.default.systemPrompt` 组合覆盖,见插件内 `getXgImConfig`。
126
+ */
127
+ systemPrompt?: string;
118
128
  }
119
129
 
120
130
  // ─── IM 接口 Request / Response ─────────────────────────────────────────────
@@ -279,6 +289,7 @@ export type SendMessageBody =
279
289
  toUserId?: string;
280
290
  text: string;
281
291
  atUserIds?: string[];
292
+ /** 流式占位消息 ID,用于将回复更新到对应的占位消息上 */
282
293
  msgId?: string;
283
294
  reply?: SendMessageReply;
284
295
  }
@@ -289,6 +300,7 @@ export type SendMessageBody =
289
300
  text: string;
290
301
  attachments: SendMessageFileAttachment[];
291
302
  atUserIds?: string[];
303
+ /** 流式占位消息 ID,用于将回复更新到对应的占位消息上 */
292
304
  msgId?: string;
293
305
  reply?: SendMessageReply;
294
306
  };