@xgjktech/xg_cwork_im 1.0.7 → 1.0.9

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.
@@ -8,7 +8,7 @@
8
8
  import axios from "axios";
9
9
  import { Type, type Static } from "@sinclair/typebox";
10
10
  import type { AnyAgentTool } from "openclaw/plugin-sdk";
11
- import { jsonResult } from "openclaw/plugin-sdk";
11
+ import { toolJsonResult } from "./tool-json-result.js";
12
12
  import { getToken } from "./auth.js";
13
13
  import type { BotIdentity, WsMessageParams, XgImConfig } from "./types.js";
14
14
 
@@ -143,7 +143,7 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
143
143
  identity = await getToken(accountConfig);
144
144
  console.log(`[cwork_im][tool] token acquired for userId=${identity.userId}`);
145
145
  } catch (err: unknown) {
146
- return jsonResult({
146
+ return toolJsonResult({
147
147
  ok: false,
148
148
  error: `Failed to obtain access token: ${String(err)}`,
149
149
  messages: [],
@@ -158,7 +158,7 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
158
158
  `[cwork_im][tool] fetched ${messages.length} messages from group ${groupId} using accountId=${accountId ?? "0"} userId=${userId ?? "N/A"}`,
159
159
  );
160
160
  } catch (err: unknown) {
161
- return jsonResult({
161
+ return toolJsonResult({
162
162
  ok: false,
163
163
  error: `Failed to fetch group history: ${String(err)}`,
164
164
  messages: [],
@@ -167,7 +167,10 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
167
167
 
168
168
  // 格式化成对话摘要,方便 AI 理解
169
169
  const formatted = messages
170
- .filter((m) => m.msgContent?.type === "text")
170
+ .filter((m) => {
171
+ const t = m.msgContent?.type;
172
+ return t === "text" || t === "file";
173
+ })
171
174
  .map((m) => {
172
175
  const senderId = m.userInfo?.id ?? "";
173
176
  const senderName = m.userInfo?.name || senderId || "未知用户";
@@ -175,10 +178,22 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
175
178
  const sender = isBot ? "[AI]" : senderName;
176
179
  const ts = m.timestamp ?? m.msgSendTime ?? 0;
177
180
  const time = new Date(ts).toLocaleTimeString("zh-CN", { hour12: false });
178
- return `[${time}] ${sender}: ${m.msgContent.text}`;
181
+ const c = m.msgContent;
182
+ const body =
183
+ c?.type === "file" && c.files?.length
184
+ ? [
185
+ c.text?.trim(),
186
+ `[附件 ${c.files.length} 个: ${c.files
187
+ .map((f) => f.name?.trim() || f.fileId || "文件")
188
+ .join("、")}]`,
189
+ ]
190
+ .filter(Boolean)
191
+ .join(" ")
192
+ : (c?.text ?? "");
193
+ return `[${time}] ${sender}: ${body}`;
179
194
  });
180
195
 
181
- return jsonResult({
196
+ return toolJsonResult({
182
197
  ok: true,
183
198
  groupId,
184
199
  count: formatted.length,
@@ -0,0 +1,159 @@
1
+ /**
2
+ * 入站附件:可选落盘到当前 Agent workspace 下指定子目录(相对路径)。
3
+ *
4
+ * 目录约定:{subdir}/{userId}/{YYYY-MM-DD}/{stem}_{HHmmssmmm}{ext}
5
+ * 时间取写入时的本地时区。
6
+ */
7
+
8
+ import { mkdir, writeFile } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
12
+ import type { Log } from "./auth.js";
13
+ import { downloadMediaBuffer, resolveMaxAttachmentBytes } from "./resource-file.js";
14
+ import type { MsgFileVO, XgImConfig } from "./types.js";
15
+
16
+ /** 校验:非空、相对路径、不含 `..` 段 */
17
+ export function normalizeInboundWorkspaceSubdir(subdir: string): string | null {
18
+ const t = subdir.trim().replace(/\\/g, "/");
19
+ if (!t || t === "." || t === "..") return null;
20
+ const parts = t.split("/").filter(Boolean);
21
+ for (const p of parts) {
22
+ if (p === "..") return null;
23
+ }
24
+ if (path.isAbsolute(t)) return null;
25
+ return parts.join("/");
26
+ }
27
+
28
+ export function resolveAgentWorkspaceDirFromRuntime(rt: unknown, cfg: OpenClawConfig): string | undefined {
29
+ const agent = (rt as { agent?: { resolveAgentWorkspaceDir?: (c: OpenClawConfig) => unknown } })?.agent;
30
+ const fn = agent?.resolveAgentWorkspaceDir;
31
+ if (typeof fn !== "function") return undefined;
32
+ try {
33
+ const out = fn(cfg);
34
+ if (typeof out === "string" && out.trim()) return path.resolve(out.trim());
35
+ } catch {
36
+ /* ignore */
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ export async function ensureAgentWorkspaceFromRuntime(rt: unknown, cfg: OpenClawConfig): Promise<void> {
42
+ const agent = (rt as { agent?: { ensureAgentWorkspace?: (c: OpenClawConfig) => Promise<void> } })?.agent;
43
+ const fn = agent?.ensureAgentWorkspace;
44
+ if (typeof fn !== "function") return;
45
+ try {
46
+ await fn(cfg);
47
+ } catch {
48
+ /* 落盘前仍会 mkdir,不阻断 */
49
+ }
50
+ }
51
+
52
+ function sanitizeUserIdForPath(id: string): string {
53
+ const t = id.trim();
54
+ if (/^-?\d+$/.test(t) && t.length <= 32) return t;
55
+ const safe = t.replace(/[^\w.-]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
56
+ return safe.slice(0, 64) || "unknown";
57
+ }
58
+
59
+ function formatDateFolderLocal(d: Date): string {
60
+ const y = d.getFullYear();
61
+ const m = String(d.getMonth() + 1).padStart(2, "0");
62
+ const day = String(d.getDate()).padStart(2, "0");
63
+ return `${y}-${m}-${day}`;
64
+ }
65
+
66
+ /** `HHmmss` + 三位毫秒,如 `120459237` */
67
+ export function formatInboundTimeSuffix(d: Date): string {
68
+ const h = String(d.getHours()).padStart(2, "0");
69
+ const min = String(d.getMinutes()).padStart(2, "0");
70
+ const s = String(d.getSeconds()).padStart(2, "0");
71
+ const ms = String(d.getMilliseconds()).padStart(3, "0");
72
+ return `${h}${min}${s}${ms}`;
73
+ }
74
+
75
+ const MAX_STEM_LEN = 120;
76
+
77
+ function sanitizeStem(stem: string): string {
78
+ const s = stem
79
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
80
+ .replace(/_+/g, "_")
81
+ .replace(/^\.+|\.+$/g, "")
82
+ .trim();
83
+ const out = s || "file";
84
+ return out.length > MAX_STEM_LEN ? out.slice(0, MAX_STEM_LEN) : out;
85
+ }
86
+
87
+ /** `report_120459237.pdf` */
88
+ export function buildInboundStoredFilename(originalFilename: string, at: Date): string {
89
+ const base = path.basename(originalFilename) || "file";
90
+ const ext = path.extname(base);
91
+ const stem = ext ? base.slice(0, -ext.length) : base;
92
+ const suffix = formatInboundTimeSuffix(at);
93
+ return `${sanitizeStem(stem)}_${suffix}${ext.toLowerCase()}`;
94
+ }
95
+
96
+ /**
97
+ * 将入站附件下载到 workspace 下子目录;失败项保留原 URL。
98
+ */
99
+ export async function saveInboundFilesToWorkspace(args: {
100
+ rt: unknown;
101
+ cfg: OpenClawConfig;
102
+ config: XgImConfig;
103
+ workspaceSubdir: string;
104
+ senderUserId: string | undefined;
105
+ fileItems: MsgFileVO[];
106
+ log?: Log;
107
+ }): Promise<MsgFileVO[]> {
108
+ const { rt, cfg, config, workspaceSubdir, senderUserId, fileItems, log } = args;
109
+ const normalized = normalizeInboundWorkspaceSubdir(workspaceSubdir);
110
+ if (!normalized || fileItems.length === 0) return fileItems;
111
+
112
+ const workspaceRoot = resolveAgentWorkspaceDirFromRuntime(rt, cfg);
113
+ if (!workspaceRoot) {
114
+ log?.warn?.(
115
+ "[cwork_im:inbound-local] resolveAgentWorkspaceDir unavailable, skip inbound download; keep remote URLs",
116
+ );
117
+ return fileItems;
118
+ }
119
+
120
+ await ensureAgentWorkspaceFromRuntime(rt, cfg);
121
+
122
+ const maxBytes = resolveMaxAttachmentBytes(config);
123
+ const userSeg = sanitizeUserIdForPath(senderUserId ?? "unknown");
124
+ const relParts = normalized.split("/");
125
+
126
+ const out: MsgFileVO[] = [];
127
+ for (const item of fileItems) {
128
+ const srcUrl = item.url?.trim();
129
+ if (!srcUrl) {
130
+ out.push(item);
131
+ continue;
132
+ }
133
+ const now = new Date();
134
+ const dateFolder = formatDateFolderLocal(now);
135
+ const localName = buildInboundStoredFilename(item.name?.trim() || "file", now);
136
+ const dir = path.join(workspaceRoot, ...relParts, userSeg, dateFolder);
137
+ const absPath = path.join(dir, localName);
138
+
139
+ try {
140
+ await mkdir(dir, { recursive: true });
141
+ const { buffer } = await downloadMediaBuffer(srcUrl, maxBytes, log);
142
+ await writeFile(absPath, buffer);
143
+ const fileUrl = pathToFileURL(absPath).href;
144
+ log?.info?.(`[cwork_im:inbound-local] saved ${localName} bytes=${buffer.length} -> ${absPath}`);
145
+ out.push({
146
+ ...item,
147
+ url: fileUrl,
148
+ size: buffer.length,
149
+ name: localName,
150
+ });
151
+ } catch (e: unknown) {
152
+ log?.warn?.(
153
+ `[cwork_im:inbound-local] download/save failed, keep original URL: ${String(e)} ref=${srcUrl.slice(0, 80)}`,
154
+ );
155
+ out.push(item);
156
+ }
157
+ }
158
+ return out;
159
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * 资源中心整文件上传 + OpenClaw deliver 媒体 URL 拉取
3
+ *
4
+ * 上传地址固定为 `baseUrl + /file/upDownload/uploadWholeFile`,与 IM 同域,使用与发消息相同的 access-token。
5
+ */
6
+
7
+ import axios from "axios";
8
+ import { readFile } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import type { Log } from "./auth.js";
12
+
13
+ const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
14
+
15
+ /** 整文件上传接口路径(拼在 channels.xg_cwork_im.baseUrl 上) */
16
+ export const RESOURCE_UPLOAD_PATH = "/file/upDownload/uploadWholeFile";
17
+
18
+ export function resolveResourceUploadUrl(baseUrl: string): string {
19
+ const b = baseUrl.trim();
20
+ if (!b) throw new Error("[cwork_im] baseUrl is required for resource upload");
21
+ return new URL(RESOURCE_UPLOAD_PATH, b.endsWith("/") ? b : `${b}/`).href;
22
+ }
23
+
24
+ /** 从上传接口 JSON 中解析 fileId(兼容常见嵌套 data) */
25
+ export function extractFileIdFromUploadResponse(data: unknown): string | undefined {
26
+ if (data == null) return undefined;
27
+ if (typeof data === "string" && data.trim().length > 0) return data.trim();
28
+ if (typeof data !== "object") return undefined;
29
+ const o = data as Record<string, unknown>;
30
+ for (const key of ["fileId", "id", "resourceId", "file_id"]) {
31
+ const v = o[key];
32
+ if (typeof v === "string" && v.length > 0) return v;
33
+ }
34
+ if (o.data != null) {
35
+ const nested = extractFileIdFromUploadResponse(o.data);
36
+ if (nested) return nested;
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ export function formatFromFilename(filename: string): string {
42
+ const ext = path.extname(filename).replace(/^\./, "").toLowerCase();
43
+ if (ext && ext.length <= 16 && /^[a-z0-9]+$/i.test(ext)) return ext;
44
+ return "bin";
45
+ }
46
+
47
+ export function displayNameFromUrl(url: string): string {
48
+ try {
49
+ if (url.startsWith("file://")) {
50
+ return path.basename(fileURLToPath(url)) || "file";
51
+ }
52
+ const u = new URL(url);
53
+ const seg = path.basename(u.pathname) || "attachment";
54
+ return seg.split("?")[0] || "attachment";
55
+ } catch {
56
+ return "attachment";
57
+ }
58
+ }
59
+
60
+ /** 日志用:截断过长路径/URL */
61
+ export function summarizeMediaRef(ref: string, maxLen = 160): string {
62
+ const t = ref.trim().replace(/\s+/g, " ");
63
+ if (t.length <= maxLen) return t;
64
+ return `${t.slice(0, maxLen)}…(len=${t.length})`;
65
+ }
66
+
67
+ function isHttpOrHttps(ref: string): boolean {
68
+ try {
69
+ const u = new URL(ref.trim());
70
+ return u.protocol === "http:" || u.protocol === "https:";
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 将字符串解析为可交给 readFile 的本机路径(兼容 Windows / macOS / Linux)。
78
+ * - 使用当前进程的 path.isAbsolute(各 OS 语义正确)。
79
+ * - Windows:Git Bash / MSYS 的 `/c/Users/...`、Cygwin `/cygdrive/c/...`。
80
+ * - 非 Windows:若传入 `D:\...` 形式仍做一次规范化(多用于日志/失败提示,读文件通常会失败)。
81
+ */
82
+ export function resolveLocalFsPath(ref: string): { fsPath: string; via: string } | null {
83
+ const raw = ref.trim();
84
+ if (!raw) return null;
85
+
86
+ if (process.platform === "win32") {
87
+ // MSYS/Git Bash: /c/Users/foo → C:\Users\foo(要求 /<盘符>/ 后仍有路径,避免误伤 /Users)
88
+ const msys = /^\/([a-zA-Z])\/(.+)$/.exec(raw.replace(/\\/g, "/"));
89
+ if (msys) {
90
+ const letter = msys[1].toUpperCase();
91
+ const rest = msys[2].split("/").join(path.win32.sep);
92
+ const fsPath = path.win32.normalize(`${letter}:${path.win32.sep}${rest}`);
93
+ return { fsPath, via: "git-bash-msys" };
94
+ }
95
+ const cyg = /^\/cygdrive\/([a-zA-Z])\/(.+)$/i.exec(raw.replace(/\\/g, "/"));
96
+ if (cyg) {
97
+ const letter = cyg[1].toUpperCase();
98
+ const rest = cyg[2].split("/").join(path.win32.sep);
99
+ const fsPath = path.win32.normalize(`${letter}:${path.win32.sep}${rest}`);
100
+ return { fsPath, via: "cygwin" };
101
+ }
102
+ }
103
+
104
+ if (path.isAbsolute(raw)) {
105
+ return { fsPath: path.normalize(raw), via: `native-${process.platform}` };
106
+ }
107
+
108
+ // Linux/macOS 上 WSL 互操作常见:/mnt/c/... 已是 posix 绝对路径,上面已命中
109
+
110
+ if (process.platform !== "win32" && /^[a-zA-Z]:[/\\]/.test(raw)) {
111
+ return { fsPath: path.win32.normalize(raw), via: "win-style-path" };
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ async function readLocalFsIntoBuffer(fsPath: string, maxBytes: number, log?: Log): Promise<{ buffer: Buffer; filename: string }> {
118
+ const buffer = await readFile(fsPath);
119
+ if (buffer.length > maxBytes) {
120
+ throw new Error(`local file size ${buffer.length} exceeds maxAttachmentBytes=${maxBytes}`);
121
+ }
122
+ const filename = path.basename(fsPath) || "file";
123
+ log?.info(`[cwork_im:media] local ok bytes=${buffer.length} name=${filename}`);
124
+ return { buffer, filename };
125
+ }
126
+
127
+ export async function downloadMediaBuffer(
128
+ url: string,
129
+ maxBytes: number,
130
+ log?: Log,
131
+ ): Promise<{ buffer: Buffer; filename: string }> {
132
+ const raw = url.trim();
133
+ log?.info(`[cwork_im:media] resolve ref=${summarizeMediaRef(raw)}`);
134
+
135
+ if (raw.startsWith("file://")) {
136
+ let fsPath: string;
137
+ try {
138
+ fsPath = fileURLToPath(raw);
139
+ } catch (e) {
140
+ log?.error(`[cwork_im:media] invalid file:// URL: ${summarizeMediaRef(raw)} ${String(e)}`);
141
+ throw e;
142
+ }
143
+ log?.info(`[cwork_im:media] read file:// -> ${summarizeMediaRef(fsPath)}`);
144
+ try {
145
+ return await readLocalFsIntoBuffer(fsPath, maxBytes, log);
146
+ } catch (err: unknown) {
147
+ log?.error(`[cwork_im:media] readFile failed file:// ${summarizeMediaRef(fsPath)}: ${String(err)}`);
148
+ throw err;
149
+ }
150
+ }
151
+
152
+ if (isHttpOrHttps(raw)) {
153
+ log?.info(`[cwork_im:media] GET ${summarizeMediaRef(raw)}`);
154
+ try {
155
+ const res = await axios.get(raw, {
156
+ responseType: "arraybuffer",
157
+ timeout: 120_000,
158
+ maxContentLength: maxBytes,
159
+ maxBodyLength: maxBytes,
160
+ validateStatus: (s) => s >= 200 && s < 300,
161
+ });
162
+ const buffer = Buffer.from(res.data as ArrayBuffer);
163
+ let filename = displayNameFromUrl(raw);
164
+ const cd = res.headers["content-disposition"];
165
+ 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
+ }
174
+ }
175
+ log?.info(`[cwork_im:media] http ok bytes=${buffer.length} name=${filename}`);
176
+ return { buffer, filename };
177
+ } catch (err: unknown) {
178
+ const detail = axios.isAxiosError(err)
179
+ ? `${err.message}${err.code ? ` axiosCode=${err.code}` : ""}${err.response?.status != null ? ` httpStatus=${err.response.status}` : ""}`
180
+ : String(err);
181
+ log?.error(`[cwork_im:media] http failed ref=${summarizeMediaRef(raw)}: ${detail}`);
182
+ throw err;
183
+ }
184
+ }
185
+
186
+ const resolved = resolveLocalFsPath(raw);
187
+ if (resolved) {
188
+ log?.info(`[cwork_im:media] local via=${resolved.via} -> ${summarizeMediaRef(resolved.fsPath)}`);
189
+ try {
190
+ return await readLocalFsIntoBuffer(resolved.fsPath, maxBytes, log);
191
+ } catch (err: unknown) {
192
+ log?.error(`[cwork_im:media] readFile failed ${summarizeMediaRef(resolved.fsPath)}: ${String(err)}`);
193
+ throw err;
194
+ }
195
+ }
196
+
197
+ const hint =
198
+ "请使用 http(s)、file://(三系统通用),或本机绝对路径:Windows C:\\\\... / UNC;macOS/Linux /Users、/home、/mnt/c/...(WSL);Windows 下 Git Bash 可用 /c/...";
199
+ log?.error(`[cwork_im:media] unsupported ref=${summarizeMediaRef(raw)} (${hint})`);
200
+ throw new Error(`[cwork_im] 无法读取媒体(${hint}): ${summarizeMediaRef(raw)}`);
201
+ }
202
+
203
+ /**
204
+ * multipart 上传整文件,返回 fileId 与字节数。
205
+ */
206
+ export async function uploadWholeResourceFile(args: {
207
+ fileUploadUrl: string;
208
+ token: string;
209
+ buffer: Buffer;
210
+ filename: string;
211
+ formField: string;
212
+ log?: Log;
213
+ }): Promise<{ fileId: string; size: number }> {
214
+ const { fileUploadUrl, token, buffer, filename, formField, log } = args;
215
+ const blob = new Blob([new Uint8Array(buffer)]);
216
+ const form = new FormData();
217
+ form.append(formField, blob, filename);
218
+
219
+ log?.info(`[cwork_im:upload] POST ${fileUploadUrl} field=${formField} name=${filename} bytes=${buffer.length}`);
220
+
221
+ const res = await fetch(fileUploadUrl, {
222
+ method: "POST",
223
+ headers: {
224
+ "access-token": token,
225
+ },
226
+ body: form,
227
+ });
228
+
229
+ const rawText = await res.text();
230
+ let json: unknown;
231
+ try {
232
+ json = rawText ? JSON.parse(rawText) : undefined;
233
+ } catch {
234
+ json = undefined;
235
+ }
236
+
237
+ if (!res.ok) {
238
+ log?.error(`[cwork_im:upload] HTTP ${res.status} body=${rawText.slice(0, 500)}`);
239
+ throw new Error(`upload HTTP ${res.status}: ${rawText.slice(0, 500)}`);
240
+ }
241
+
242
+ const fileId = extractFileIdFromUploadResponse(json ?? rawText);
243
+ if (!fileId) {
244
+ log?.error(`[cwork_im:upload] missing fileId in body=${rawText.slice(0, 500)}`);
245
+ throw new Error(`upload response missing fileId: ${rawText.slice(0, 500)}`);
246
+ }
247
+
248
+ return { fileId, size: buffer.length };
249
+ }
250
+
251
+ export function resolveMaxAttachmentBytes(config: { maxAttachmentBytes?: number }): number {
252
+ return config.maxAttachmentBytes ?? DEFAULT_MAX_BYTES;
253
+ }
@@ -1,98 +1,98 @@
1
- /**
2
- * XG-IM 发送群消息工具(供 AI 按需调用)
3
- *
4
- * 适用场景:
5
- * - Cron 定时任务主动推送通知
6
- * - AI 分析后需要主动发起提醒(无需用户先 @ 机器人)
7
- *
8
- * 通过 api.registerTool() 注入,工具内部自动获取机器人 token,AI 无需感知鉴权细节。
9
- */
10
-
11
- import { Type, type Static } from "@sinclair/typebox";
12
- import type { AnyAgentTool } from "openclaw/plugin-sdk";
13
- import { jsonResult } from "openclaw/plugin-sdk";
14
- import { getToken } from "./auth.js";
15
- import { sendTextMessage } from "./send-service.js";
16
- import type { BotIdentity, XgImConfig } from "./types.js";
17
- import { resolveAccountConfig } from "./group-history-tool.js";
18
-
19
- // ─── 工具参数 Schema ─────────────────────────────────────────────────────────
20
-
21
- const SendGroupMessageParams = Type.Object({
22
- groupId: Type.String({
23
- description: "目标群聊 ID(IM 系统的群组唯一标识符,即 groupId / gid)",
24
- }),
25
- accountId: Type.Optional(
26
- Type.String({
27
- description:
28
- "(可选)要使用的 xg_cwork_im 账户 ID(即 accounts 的 key,例如 main/orchestrator)。",
29
- }),
30
- ),
31
- text: Type.String({
32
- description: "要发送的消息内容(纯文本)",
33
- }),
34
- atUserIds: Type.Optional(
35
- Type.Array(Type.String(), {
36
- description: "需要 @ 的用户 ID 列表,可为空数组",
37
- }),
38
- ),
39
- });
40
-
41
- // ─── 工具构建函数 ─────────────────────────────────────────────────────────────
42
-
43
- /**
44
- * 构建发送群消息工具对象。
45
- * 由 index.ts 在 register() 里调用,config 在注册时捕获进闭包,
46
- * 工具执行时自动调用 getToken(config) 获取机器人 token。
47
- */
48
- export function buildSendGroupMessageTool(config: XgImConfig): AnyAgentTool {
49
- return {
50
- name: "xg_cwork_im_send_group_message",
51
- label: "【xg_cwork_im】发送群聊消息",
52
- description: [
53
- "【仅限 xg_cwork_im 通道】向指定 IM 群聊发送一条消息。",
54
- "适用于定时任务主动推送通知、AI 分析后发起提醒等场景。",
55
- "消息将以机器人身份发出,可选择 @ 特定用户。",
56
- "groupId 为 IM 群组的唯一 ID(不是群名称),通常从当前对话的 group_channel 字段中解析(格式:agent:main:xg_cwork_im:group:<groupId>)。",
57
- ].join("\n"),
58
- parameters: SendGroupMessageParams,
59
- async execute(_toolCallId: string, params: Static<typeof SendGroupMessageParams>) {
60
- const { groupId, text, atUserIds = [], accountId } = params;
61
-
62
- console.log(`[cwork_im][tool] xg_cwork_im_send_group_message called: groupId=${groupId} atUsers=${JSON.stringify(atUserIds)}`);
63
-
64
- let identity: BotIdentity;
65
- try {
66
- const accountConfig = resolveAccountConfig(config, accountId);
67
- identity = await getToken(accountConfig);
68
- console.log(
69
- `[cwork_im][tool] xg_cwork_im_send_group_message token acquired for userId=${identity.userId} using accountId=${accountId ?? "0"}`,
70
- );
71
- } catch (err: unknown) {
72
- return jsonResult({
73
- ok: false,
74
- error: `Failed to obtain access token: ${String(err)}`,
75
- });
76
- }
77
-
78
- try {
79
- const accountConfig = resolveAccountConfig(config, accountId);
80
- await sendTextMessage(accountConfig, identity.token, groupId, text, atUserIds);
81
- console.log(
82
- `[cwork_im][tool] xg_cwork_im_send_group_message success: groupId=${groupId} using accountId=${accountId ?? "0"}`,
83
- );
84
- } catch (err: unknown) {
85
- return jsonResult({
86
- ok: false,
87
- error: `Failed to send message to group ${groupId}: ${String(err)}`,
88
- });
89
- }
90
-
91
- return jsonResult({
92
- ok: true,
93
- groupId,
94
- sentBy: identity.userId,
95
- });
96
- },
97
- };
98
- }
1
+ /**
2
+ * XG-IM 发送群消息工具(供 AI 按需调用)
3
+ *
4
+ * 适用场景:
5
+ * - Cron 定时任务主动推送通知
6
+ * - AI 分析后需要主动发起提醒(无需用户先 @ 机器人)
7
+ *
8
+ * 通过 api.registerTool() 注入,工具内部自动获取机器人 token,AI 无需感知鉴权细节。
9
+ */
10
+
11
+ import { Type, type Static } from "@sinclair/typebox";
12
+ import type { AnyAgentTool } from "openclaw/plugin-sdk";
13
+ import { toolJsonResult } from "./tool-json-result.js";
14
+ import { getToken } from "./auth.js";
15
+ import { sendTextMessage } from "./send-service.js";
16
+ import type { BotIdentity, XgImConfig } from "./types.js";
17
+ import { resolveAccountConfig } from "./group-history-tool.js";
18
+
19
+ // ─── 工具参数 Schema ─────────────────────────────────────────────────────────
20
+
21
+ const SendGroupMessageParams = Type.Object({
22
+ groupId: Type.String({
23
+ description: "目标群聊 ID(IM 系统的群组唯一标识符,即 groupId / gid)",
24
+ }),
25
+ accountId: Type.Optional(
26
+ Type.String({
27
+ description:
28
+ "(可选)要使用的 xg_cwork_im 账户 ID(即 accounts 的 key,例如 main/orchestrator)。",
29
+ }),
30
+ ),
31
+ text: Type.String({
32
+ description: "要发送的消息内容(纯文本)",
33
+ }),
34
+ atUserIds: Type.Optional(
35
+ Type.Array(Type.String(), {
36
+ description: "需要 @ 的用户 ID 列表,可为空数组",
37
+ }),
38
+ ),
39
+ });
40
+
41
+ // ─── 工具构建函数 ─────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * 构建发送群消息工具对象。
45
+ * 由 index.ts 在 register() 里调用,config 在注册时捕获进闭包,
46
+ * 工具执行时自动调用 getToken(config) 获取机器人 token。
47
+ */
48
+ export function buildSendGroupMessageTool(config: XgImConfig): AnyAgentTool {
49
+ return {
50
+ name: "xg_cwork_im_send_group_message",
51
+ label: "【xg_cwork_im】发送群聊消息",
52
+ description: [
53
+ "【仅限 xg_cwork_im 通道】向指定 IM 群聊发送一条消息。",
54
+ "适用于定时任务主动推送通知、AI 分析后发起提醒等场景。",
55
+ "消息将以机器人身份发出,可选择 @ 特定用户。",
56
+ "groupId 为 IM 群组的唯一 ID(不是群名称),通常从当前对话的 group_channel 字段中解析(格式:agent:main:xg_cwork_im:group:<groupId>)。",
57
+ ].join("\n"),
58
+ parameters: SendGroupMessageParams,
59
+ async execute(_toolCallId: string, params: Static<typeof SendGroupMessageParams>) {
60
+ const { groupId, text, atUserIds = [], accountId } = params;
61
+
62
+ console.log(`[cwork_im][tool] xg_cwork_im_send_group_message called: groupId=${groupId} atUsers=${JSON.stringify(atUserIds)}`);
63
+
64
+ let identity: BotIdentity;
65
+ try {
66
+ const accountConfig = resolveAccountConfig(config, accountId);
67
+ identity = await getToken(accountConfig);
68
+ console.log(
69
+ `[cwork_im][tool] xg_cwork_im_send_group_message token acquired for userId=${identity.userId} using accountId=${accountId ?? "0"}`,
70
+ );
71
+ } catch (err: unknown) {
72
+ return toolJsonResult({
73
+ ok: false,
74
+ error: `Failed to obtain access token: ${String(err)}`,
75
+ });
76
+ }
77
+
78
+ try {
79
+ const accountConfig = resolveAccountConfig(config, accountId);
80
+ await sendTextMessage(accountConfig, identity.token, groupId, text, atUserIds);
81
+ console.log(
82
+ `[cwork_im][tool] xg_cwork_im_send_group_message success: groupId=${groupId} using accountId=${accountId ?? "0"}`,
83
+ );
84
+ } catch (err: unknown) {
85
+ return toolJsonResult({
86
+ ok: false,
87
+ error: `Failed to send message to group ${groupId}: ${String(err)}`,
88
+ });
89
+ }
90
+
91
+ return toolJsonResult({
92
+ ok: true,
93
+ groupId,
94
+ sentBy: identity.userId,
95
+ });
96
+ },
97
+ };
98
+ }