@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 +3 -0
- package/package.json +3 -2
- package/src/channel.ts +74 -3
- package/src/recommended-system-prompt.ts +3 -0
- package/src/resource-file.ts +99 -13
- package/src/send-group-message-tool.ts +12 -14
- package/src/send-service.ts +28 -15
- package/src/types.ts +12 -0
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.
|
|
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))
|
|
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(
|
|
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",
|
package/src/resource-file.ts
CHANGED
|
@@ -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
|
|
54
|
-
|
|
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
|
|
244
|
+
const cdRaw = res.headers["content-disposition"];
|
|
245
|
+
const cd = Array.isArray(cdRaw) ? cdRaw[0] : cdRaw;
|
|
165
246
|
if (typeof cd === "string") {
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
},
|
|
155
|
-
|
|
156
|
-
|
|
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
|
};
|