@xgjktech/xg_cwork_im 1.0.6 → 1.0.8
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 +23 -17
- package/package.json +1 -1
- package/src/channel.ts +313 -191
- package/src/connection.ts +11 -3
- package/src/group-history-tool.ts +34 -16
- package/src/resource-file.ts +253 -0
- package/src/send-group-message-tool.ts +98 -97
- package/src/send-service.ts +215 -68
- package/src/tool-json-result.ts +24 -0
- package/src/types.ts +287 -206
|
@@ -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 {
|
|
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
|
|
|
@@ -25,7 +25,8 @@ const GroupHistoryParams = Type.Object({
|
|
|
25
25
|
),
|
|
26
26
|
accountId: Type.Optional(
|
|
27
27
|
Type.String({
|
|
28
|
-
description:
|
|
28
|
+
description:
|
|
29
|
+
"(可选)要使用的 xg_cwork_im 账户 ID(即 accounts 的 key,例如 main/orchestrator)。",
|
|
29
30
|
}),
|
|
30
31
|
),
|
|
31
32
|
limit: Type.Optional(
|
|
@@ -54,19 +55,21 @@ export function extractXgCworkImConfig(cfg: unknown): XgImConfig | null {
|
|
|
54
55
|
* - 否则:有 accounts 时默认使用 accounts[0];无 accounts 时使用顶层配置。
|
|
55
56
|
*/
|
|
56
57
|
export function resolveAccountConfig(base: XgImConfig, accountId?: string | null): XgImConfig {
|
|
57
|
-
const accounts = base.accounts
|
|
58
|
-
if (accounts
|
|
58
|
+
const accounts = base.accounts;
|
|
59
|
+
if (!accounts) {
|
|
59
60
|
return base;
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
+
|
|
63
|
+
const map = accounts as Record<string, XgImConfig | undefined>;
|
|
64
|
+
const defaults = map.default ? { ...map.default } : undefined;
|
|
65
|
+
|
|
62
66
|
if (accountId && accountId !== "default") {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
idx = parsed;
|
|
66
|
-
}
|
|
67
|
+
const hit = map[accountId];
|
|
68
|
+
if (hit) return { ...base, ...(defaults ?? {}), ...hit };
|
|
67
69
|
}
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
+
const firstKey = Object.keys(map).find((k) => k !== "default");
|
|
71
|
+
const first = firstKey ? map[firstKey] : undefined;
|
|
72
|
+
return first ? { ...base, ...(defaults ?? {}), ...first } : base;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
/** 调用 IM 接口拉取群聊最近 N 条消息 */
|
|
@@ -140,7 +143,7 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
140
143
|
identity = await getToken(accountConfig);
|
|
141
144
|
console.log(`[cwork_im][tool] token acquired for userId=${identity.userId}`);
|
|
142
145
|
} catch (err: unknown) {
|
|
143
|
-
return
|
|
146
|
+
return toolJsonResult({
|
|
144
147
|
ok: false,
|
|
145
148
|
error: `Failed to obtain access token: ${String(err)}`,
|
|
146
149
|
messages: [],
|
|
@@ -155,7 +158,7 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
155
158
|
`[cwork_im][tool] fetched ${messages.length} messages from group ${groupId} using accountId=${accountId ?? "0"} userId=${userId ?? "N/A"}`,
|
|
156
159
|
);
|
|
157
160
|
} catch (err: unknown) {
|
|
158
|
-
return
|
|
161
|
+
return toolJsonResult({
|
|
159
162
|
ok: false,
|
|
160
163
|
error: `Failed to fetch group history: ${String(err)}`,
|
|
161
164
|
messages: [],
|
|
@@ -164,7 +167,10 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
164
167
|
|
|
165
168
|
// 格式化成对话摘要,方便 AI 理解
|
|
166
169
|
const formatted = messages
|
|
167
|
-
.filter((m) =>
|
|
170
|
+
.filter((m) => {
|
|
171
|
+
const t = m.msgContent?.type;
|
|
172
|
+
return t === "text" || t === "file";
|
|
173
|
+
})
|
|
168
174
|
.map((m) => {
|
|
169
175
|
const senderId = m.userInfo?.id ?? "";
|
|
170
176
|
const senderName = m.userInfo?.name || senderId || "未知用户";
|
|
@@ -172,10 +178,22 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
172
178
|
const sender = isBot ? "[AI]" : senderName;
|
|
173
179
|
const ts = m.timestamp ?? m.msgSendTime ?? 0;
|
|
174
180
|
const time = new Date(ts).toLocaleTimeString("zh-CN", { hour12: false });
|
|
175
|
-
|
|
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}`;
|
|
176
194
|
});
|
|
177
195
|
|
|
178
|
-
return
|
|
196
|
+
return toolJsonResult({
|
|
179
197
|
ok: true,
|
|
180
198
|
groupId,
|
|
181
199
|
count: formatted.length,
|
|
@@ -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,97 +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 {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
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
|
+
}
|