@xgjktech/xg_cwork_im 1.0.0 → 1.0.3
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 +87 -27
- package/index.ts +3 -2
- package/package.json +2 -3
- package/src/channel.ts +536 -471
- package/src/connection.ts +150 -11
- package/src/group-history-tool.ts +57 -16
- package/src/send-group-message-tool.ts +20 -8
- package/src/send-service.ts +6 -0
- package/src/types.ts +38 -2
package/src/connection.ts
CHANGED
|
@@ -4,18 +4,45 @@
|
|
|
4
4
|
* 功能:
|
|
5
5
|
* - 建立 wss://<baseUrl>/ws-notify/websocket?accessToken=<token> 长连接
|
|
6
6
|
* - 接收 robotMention 消息并回调
|
|
7
|
+
* - 支持基于 WebSocket 的 AI 回复流式推送(START/CHUNK/END 协议)
|
|
7
8
|
* - 指数退避 + 抖动的自动重连机制
|
|
8
9
|
* - 主动发送 WebSocket ping 保活,超时自动重连
|
|
9
10
|
*/
|
|
10
11
|
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
11
13
|
import WebSocket from "ws";
|
|
12
14
|
import type { Log } from "./auth.js";
|
|
13
|
-
import type { WsMessage,
|
|
15
|
+
import type { WsMessage, XgImConfig } from "./types.js";
|
|
14
16
|
|
|
15
17
|
export type OnMessageCallback = (msg: WsMessage) => void;
|
|
16
18
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
+
/**
|
|
20
|
+
* CHUNK 载荷:发送端带 seq,便于集群/跨服务按序重排。
|
|
21
|
+
* - isThinking=true 为思考阶段,seq 从 0 递增(thinkSeq);
|
|
22
|
+
* - isThinking=false 为回答阶段,seq 从 0 递增(answerSeq)。
|
|
23
|
+
*/
|
|
24
|
+
export interface ImStreamChunkData {
|
|
25
|
+
isThinking: boolean;
|
|
26
|
+
content: string;
|
|
27
|
+
/** 当前阶段内的序号,think 与 answer 各自从 0 递增 */
|
|
28
|
+
seq: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 基于 WebSocket 的流式消息客户端。
|
|
33
|
+
*
|
|
34
|
+
* - start(): 发送 START,等待 ack,返回 msgId。
|
|
35
|
+
* - chunk(): 基于 msgId 发送增量文本,data 中带 seq(think/answer 阶段分开)。
|
|
36
|
+
* - end(): 发送 END,通知服务端结束本次流式消息。
|
|
37
|
+
*/
|
|
38
|
+
export interface ImStreamClient {
|
|
39
|
+
start: (opts: { groupId?: string; toUserId?: string }) => Promise<{ msgId: string }>;
|
|
40
|
+
chunk: (msgId: string, data: ImStreamChunkData) => Promise<void>;
|
|
41
|
+
end: (msgId: string, reason: string) => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 心跳间隔(ms):每 20 秒发一次 ping,防止连接被后台剔除 */
|
|
45
|
+
const PING_INTERVAL_MS = 20_000;
|
|
19
46
|
|
|
20
47
|
/** 计算指数退避延迟(带随机抖动) */
|
|
21
48
|
function calcDelay(
|
|
@@ -39,8 +66,8 @@ export function startWebSocket(
|
|
|
39
66
|
token: string,
|
|
40
67
|
onMessage: OnMessageCallback,
|
|
41
68
|
log: Log,
|
|
42
|
-
): { stop: () => void } {
|
|
43
|
-
const maxAttempts = config.maxConnectionAttempts ??
|
|
69
|
+
): { stop: () => void; streamClient: ImStreamClient } {
|
|
70
|
+
const maxAttempts = config.maxConnectionAttempts ?? 200;
|
|
44
71
|
const initialDelay = config.initialReconnectDelay ?? 1_000;
|
|
45
72
|
const maxDelay = config.maxReconnectDelay ?? 60_000;
|
|
46
73
|
const jitter = config.reconnectJitter ?? 0.3;
|
|
@@ -64,6 +91,14 @@ export function startWebSocket(
|
|
|
64
91
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
65
92
|
let attempt = 0;
|
|
66
93
|
|
|
94
|
+
/**
|
|
95
|
+
* clientMsgId -> resolver(msgId)
|
|
96
|
+
*
|
|
97
|
+
* - 当发送 START 时,会生成一个 clientMsgId,并在此处挂一个 resolver。
|
|
98
|
+
* - 收到 im_stream_msg_ack 后,根据 clientMsgId 触发 resolver,返回服务端生成的 msgId。
|
|
99
|
+
*/
|
|
100
|
+
const pendingAcks = new Map<string, (msgId: string) => void>();
|
|
101
|
+
|
|
67
102
|
/** 清除心跳定时器 */
|
|
68
103
|
function clearHeartbeat(): void {
|
|
69
104
|
if (pingTimer !== null) {
|
|
@@ -97,6 +132,17 @@ export function startWebSocket(
|
|
|
97
132
|
}, PING_INTERVAL_MS);
|
|
98
133
|
}
|
|
99
134
|
|
|
135
|
+
/**
|
|
136
|
+
* 确保当前存在可用的 WebSocket 连接。
|
|
137
|
+
* 若不存在或未处于 OPEN 状态,则抛出错误,由上层决定是否回退为非流式模式。
|
|
138
|
+
*/
|
|
139
|
+
function ensureSocketReady(): WebSocket {
|
|
140
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
141
|
+
throw new Error(`${logPrefix} WebSocket is not ready for sending stream message`);
|
|
142
|
+
}
|
|
143
|
+
return ws;
|
|
144
|
+
}
|
|
145
|
+
|
|
100
146
|
function connect(): void {
|
|
101
147
|
if (stopped) return;
|
|
102
148
|
|
|
@@ -119,9 +165,9 @@ export function startWebSocket(
|
|
|
119
165
|
return;
|
|
120
166
|
}
|
|
121
167
|
|
|
122
|
-
let parsed:
|
|
168
|
+
let parsed: any;
|
|
123
169
|
try {
|
|
124
|
-
parsed = JSON.parse(rawString) as
|
|
170
|
+
parsed = JSON.parse(rawString) as unknown;
|
|
125
171
|
} catch {
|
|
126
172
|
// 如果不是 JSON,说明是心跳或者其他内容,忽略即可
|
|
127
173
|
// 如果开启 debug 还是可以打印日志
|
|
@@ -133,17 +179,33 @@ export function startWebSocket(
|
|
|
133
179
|
|
|
134
180
|
log.info(`${logPrefix} ⇐ WS message received: cmd=${parsed.cmd}`);
|
|
135
181
|
|
|
136
|
-
//
|
|
137
|
-
|
|
182
|
+
// 处理流式消息 ack:im_stream_msg_ack
|
|
183
|
+
if (parsed.cmd === "im_stream_msg_ack" && parsed.params) {
|
|
184
|
+
const clientMsgId: string | undefined = parsed.params.clientMsgId;
|
|
185
|
+
const msgId: string | undefined = parsed.params.msgId;
|
|
186
|
+
if (clientMsgId && msgId) {
|
|
187
|
+
const resolver = pendingAcks.get(clientMsgId);
|
|
188
|
+
if (resolver) {
|
|
189
|
+
pendingAcks.delete(clientMsgId);
|
|
190
|
+
resolver(msgId);
|
|
191
|
+
log.info(`${logPrefix} ⇐ im_stream_msg_ack received: clientMsgId=${clientMsgId} msgId=${msgId}`);
|
|
192
|
+
} else if (config.debug) {
|
|
193
|
+
log.debug?.(`${logPrefix} im_stream_msg_ack with unknown clientMsgId=${clientMsgId}`);
|
|
194
|
+
}
|
|
195
|
+
} else if (config.debug) {
|
|
196
|
+
log.debug?.(`${logPrefix} im_stream_msg_ack missing clientMsgId or msgId`);
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
138
200
|
|
|
139
|
-
// 处理包含 params
|
|
201
|
+
// 处理包含 params 的事件(robotMention / groupMessage)
|
|
140
202
|
if (parsed.params && (parsed.cmd === "robotMention" || parsed.cmd === "groupMessage")) {
|
|
141
203
|
if (parsed.cmd === "robotMention") {
|
|
142
204
|
log.info(`${logPrefix} 📨 robotMention received: groupId=${parsed.params.groupId} msgId=${parsed.params.msgId}`);
|
|
143
205
|
}
|
|
144
206
|
// onMessage 是 async 函数,必须用 .catch() 捕获异步错误
|
|
145
207
|
// 否则 await 之后的错误会变成未处理的 Promise rejection 被静默丢弃
|
|
146
|
-
Promise.resolve(onMessage(parsed)).catch((err: unknown) => {
|
|
208
|
+
Promise.resolve(onMessage(parsed as WsMessage)).catch((err: unknown) => {
|
|
147
209
|
log.error(`${logPrefix} onMessage async error: ${String(err)}`);
|
|
148
210
|
});
|
|
149
211
|
}
|
|
@@ -190,6 +252,82 @@ export function startWebSocket(
|
|
|
190
252
|
// 首次连接
|
|
191
253
|
connect();
|
|
192
254
|
|
|
255
|
+
const streamClient: ImStreamClient = {
|
|
256
|
+
async start(opts: { groupId?: string; toUserId?: string }): Promise<{ msgId: string }> {
|
|
257
|
+
const socket = ensureSocketReady();
|
|
258
|
+
const clientMsgId = randomUUID();
|
|
259
|
+
|
|
260
|
+
const payload = {
|
|
261
|
+
cmd: "im_stream_msg",
|
|
262
|
+
params: {
|
|
263
|
+
event: "START",
|
|
264
|
+
clientMsgId,
|
|
265
|
+
data: {
|
|
266
|
+
groupId: opts.groupId,
|
|
267
|
+
toUserId: opts.toUserId,
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
log.info(`${logPrefix} ⇒ WS START stream: clientMsgId=${clientMsgId} groupId=${opts.groupId ?? ""} toUserId=${opts.toUserId ?? ""}`);
|
|
273
|
+
|
|
274
|
+
const msgId = await new Promise<string>((resolve, reject) => {
|
|
275
|
+
pendingAcks.set(clientMsgId, resolve);
|
|
276
|
+
|
|
277
|
+
socket.send(JSON.stringify(payload), (err) => {
|
|
278
|
+
if (err) {
|
|
279
|
+
pendingAcks.delete(clientMsgId);
|
|
280
|
+
reject(err);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// 简单超时保护,避免 ack 永远不到导致 Promise 悬挂
|
|
285
|
+
setTimeout(() => {
|
|
286
|
+
if (pendingAcks.has(clientMsgId)) {
|
|
287
|
+
pendingAcks.delete(clientMsgId);
|
|
288
|
+
reject(new Error(`${logPrefix} START stream ack timeout for clientMsgId=${clientMsgId}`));
|
|
289
|
+
}
|
|
290
|
+
}, 10_000);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return { msgId };
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
async chunk(msgId: string, data: ImStreamChunkData): Promise<void> {
|
|
297
|
+
const socket = ensureSocketReady();
|
|
298
|
+
const payload = {
|
|
299
|
+
cmd: "im_stream_msg",
|
|
300
|
+
params: {
|
|
301
|
+
event: "CHUNK",
|
|
302
|
+
msgId,
|
|
303
|
+
data: {
|
|
304
|
+
isThinking: data.isThinking,
|
|
305
|
+
content: data.content,
|
|
306
|
+
seq: data.seq,
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
if (config.debug) {
|
|
311
|
+
log.debug?.(`${logPrefix} ⇒ WS CHUNK stream: msgId=${msgId} isThinking=${data.isThinking} seq=${data.seq} length=${data.content.length}`);
|
|
312
|
+
}
|
|
313
|
+
socket.send(JSON.stringify(payload));
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
async end(msgId: string, reason: string): Promise<void> {
|
|
317
|
+
const socket = ensureSocketReady();
|
|
318
|
+
const payload = {
|
|
319
|
+
cmd: "im_stream_msg",
|
|
320
|
+
params: {
|
|
321
|
+
event: "END",
|
|
322
|
+
msgId,
|
|
323
|
+
data: { reason },
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
log.info(`${logPrefix} ⇒ WS END stream: msgId=${msgId} reason=${reason}`);
|
|
327
|
+
socket.send(JSON.stringify(payload));
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
193
331
|
return {
|
|
194
332
|
stop(): void {
|
|
195
333
|
if (stopped) return;
|
|
@@ -208,5 +346,6 @@ export function startWebSocket(
|
|
|
208
346
|
}
|
|
209
347
|
ws = null;
|
|
210
348
|
},
|
|
349
|
+
streamClient,
|
|
211
350
|
};
|
|
212
351
|
}
|
|
@@ -18,6 +18,16 @@ const GroupHistoryParams = Type.Object({
|
|
|
18
18
|
groupId: Type.String({
|
|
19
19
|
description: "群聊 ID,即当前消息来自的群组唯一标识符",
|
|
20
20
|
}),
|
|
21
|
+
userId: Type.Optional(
|
|
22
|
+
Type.String({
|
|
23
|
+
description: "(可选)以哪个用户身份拉取历史消息的 userId,普通对话场景建议传当前发消息用户 ID;若为空则按后台默认身份查询",
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
accountId: Type.Optional(
|
|
27
|
+
Type.String({
|
|
28
|
+
description: "(可选)要使用的 xg_cwork_im 账户下标(数字字符串,默认 0)",
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
21
31
|
limit: Type.Optional(
|
|
22
32
|
Type.Number({
|
|
23
33
|
description: "需要获取的消息条数(5~30),默认 10",
|
|
@@ -30,17 +40,33 @@ const GroupHistoryParams = Type.Object({
|
|
|
30
40
|
|
|
31
41
|
// ─── 内部 HTTP 工具 ──────────────────────────────────────────────────────────
|
|
32
42
|
|
|
33
|
-
/** 从 OpenClaw 配置中提取 xg_cwork_im
|
|
43
|
+
/** 从 OpenClaw 配置中提取 xg_cwork_im 顶层配置(保留 accounts 以便多账户路由) */
|
|
34
44
|
export function extractXgCworkImConfig(cfg: unknown): XgImConfig | null {
|
|
35
45
|
const raw = (cfg as Record<string, Record<string, unknown>>)?.channels?.xg_cwork_im as XgImConfig | undefined;
|
|
36
46
|
if (!raw?.baseUrl) return null;
|
|
47
|
+
return raw;
|
|
48
|
+
}
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
/**
|
|
51
|
+
* 根据 accountId 解析具体账号配置。
|
|
52
|
+
*
|
|
53
|
+
* - 若传入 accountId 且能解析为有效下标,则优先使用对应 accounts[accountId]。
|
|
54
|
+
* - 否则:有 accounts 时默认使用 accounts[0];无 accounts 时使用顶层配置。
|
|
55
|
+
*/
|
|
56
|
+
export function resolveAccountConfig(base: XgImConfig, accountId?: string | null): XgImConfig {
|
|
57
|
+
const accounts = base.accounts ?? [];
|
|
58
|
+
if (accounts.length === 0) {
|
|
59
|
+
return base;
|
|
42
60
|
}
|
|
43
|
-
|
|
61
|
+
let idx = 0;
|
|
62
|
+
if (accountId && accountId !== "default") {
|
|
63
|
+
const parsed = Number.parseInt(accountId, 10);
|
|
64
|
+
if (!Number.isNaN(parsed) && parsed >= 0 && parsed < accounts.length) {
|
|
65
|
+
idx = parsed;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const acc = accounts[idx] ?? accounts[0];
|
|
69
|
+
return { ...base, ...acc };
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
/** 调用 IM 接口拉取群聊最近 N 条消息 */
|
|
@@ -49,15 +75,24 @@ async function fetchGroupHistory(
|
|
|
49
75
|
token: string,
|
|
50
76
|
groupId: string,
|
|
51
77
|
limit: number,
|
|
78
|
+
userId?: string,
|
|
52
79
|
): Promise<WsMessageParams[]> {
|
|
53
80
|
const url = `${config.baseUrl}/im/message/getLatestMsgListForAI`;
|
|
54
81
|
|
|
82
|
+
const params: Record<string, unknown> = {
|
|
83
|
+
groupId,
|
|
84
|
+
msgCount: limit,
|
|
85
|
+
};
|
|
86
|
+
if (userId) {
|
|
87
|
+
params.userId = userId;
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
const res = await axios.get<{
|
|
56
91
|
resultCode: number;
|
|
57
92
|
message?: string;
|
|
58
93
|
data?: WsMessageParams[];
|
|
59
94
|
}>(url, {
|
|
60
|
-
params
|
|
95
|
+
params,
|
|
61
96
|
headers: { "access-token": token },
|
|
62
97
|
timeout: 8_000,
|
|
63
98
|
});
|
|
@@ -78,9 +113,9 @@ async function fetchGroupHistory(
|
|
|
78
113
|
export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
79
114
|
return {
|
|
80
115
|
name: "xg_cwork_im_get_group_chat_history",
|
|
81
|
-
label: "
|
|
116
|
+
label: "【xg_cwork_im】获取群聊历史记录",
|
|
82
117
|
description: [
|
|
83
|
-
"
|
|
118
|
+
"【仅限 xg_cwork_im 通道】获取当前 IM 群聊的最近消息历史,用于理解对话上下文背景。",
|
|
84
119
|
"",
|
|
85
120
|
"以下情况你必须先调用此工具,不得直接向用户反问或要求澄清:",
|
|
86
121
|
"- 用户使用了指代词却没说清楚指的是什么(如'这'、'那'、'上面说的'、'刚才提到的'、'这两家公司'等)",
|
|
@@ -88,20 +123,21 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
88
123
|
"- 你不确定用户问题的背景或主语时",
|
|
89
124
|
"",
|
|
90
125
|
"调用此工具后你会获得最近 N 条消息,用于还原上下文再给出回答。",
|
|
91
|
-
"groupId
|
|
126
|
+
"groupId 必须从会话元数据 group_channel 字段中解析,当且仅当其前缀为 agent:main:xg_cwork_im:group:<groupId> 时才可以调用本工具。",
|
|
92
127
|
"",
|
|
93
128
|
"【只有在以下情况才可以不调用】:用户的问题明确且自洽,不依赖任何历史对话。",
|
|
94
129
|
].join("\n"),
|
|
95
130
|
parameters: GroupHistoryParams,
|
|
96
131
|
async execute(_toolCallId: string, params: Static<typeof GroupHistoryParams>) {
|
|
97
132
|
const limit = params.limit ?? 10;
|
|
98
|
-
const { groupId } = params;
|
|
133
|
+
const { groupId, accountId, userId } = params;
|
|
99
134
|
|
|
100
135
|
console.log(`[cwork_im][tool] xg_cwork_im_get_group_chat_history called: groupId=${groupId} limit=${limit}`);
|
|
101
136
|
|
|
102
137
|
let identity: BotIdentity;
|
|
103
138
|
try {
|
|
104
|
-
|
|
139
|
+
const accountConfig = resolveAccountConfig(config, accountId);
|
|
140
|
+
identity = await getToken(accountConfig);
|
|
105
141
|
console.log(`[cwork_im][tool] token acquired for userId=${identity.userId}`);
|
|
106
142
|
} catch (err: unknown) {
|
|
107
143
|
return jsonResult({
|
|
@@ -113,8 +149,11 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
113
149
|
|
|
114
150
|
let messages: WsMessageParams[];
|
|
115
151
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
152
|
+
const accountConfig = resolveAccountConfig(config, accountId);
|
|
153
|
+
messages = await fetchGroupHistory(accountConfig, identity.token, groupId, limit, userId);
|
|
154
|
+
console.log(
|
|
155
|
+
`[cwork_im][tool] fetched ${messages.length} messages from group ${groupId} using accountId=${accountId ?? "0"} userId=${userId ?? "N/A"}`,
|
|
156
|
+
);
|
|
118
157
|
} catch (err: unknown) {
|
|
119
158
|
return jsonResult({
|
|
120
159
|
ok: false,
|
|
@@ -127,8 +166,10 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
127
166
|
const formatted = messages
|
|
128
167
|
.filter((m) => m.msgContent?.type === "text")
|
|
129
168
|
.map((m) => {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
169
|
+
const senderId = m.userInfo?.id ?? "";
|
|
170
|
+
const senderName = m.userInfo?.name || senderId || "未知用户";
|
|
171
|
+
const isBot = senderId === identity.userId;
|
|
172
|
+
const sender = isBot ? "[AI]" : senderName;
|
|
132
173
|
const ts = m.timestamp ?? m.msgSendTime ?? 0;
|
|
133
174
|
const time = new Date(ts).toLocaleTimeString("zh-CN", { hour12: false });
|
|
134
175
|
return `[${time}] ${sender}: ${m.msgContent.text}`;
|
|
@@ -14,6 +14,7 @@ import { jsonResult } from "openclaw/plugin-sdk";
|
|
|
14
14
|
import { getToken } from "./auth.js";
|
|
15
15
|
import { sendTextMessage } from "./send-service.js";
|
|
16
16
|
import type { BotIdentity, XgImConfig } from "./types.js";
|
|
17
|
+
import { resolveAccountConfig } from "./group-history-tool.js";
|
|
17
18
|
|
|
18
19
|
// ─── 工具参数 Schema ─────────────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -21,6 +22,11 @@ const SendGroupMessageParams = Type.Object({
|
|
|
21
22
|
groupId: Type.String({
|
|
22
23
|
description: "目标群聊 ID(IM 系统的群组唯一标识符,即 groupId / gid)",
|
|
23
24
|
}),
|
|
25
|
+
accountId: Type.Optional(
|
|
26
|
+
Type.String({
|
|
27
|
+
description: "(可选)要使用的 xg_cwork_im 账户下标(数字字符串,默认 0)",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
24
30
|
text: Type.String({
|
|
25
31
|
description: "要发送的消息内容(纯文本)",
|
|
26
32
|
}),
|
|
@@ -41,23 +47,26 @@ const SendGroupMessageParams = Type.Object({
|
|
|
41
47
|
export function buildSendGroupMessageTool(config: XgImConfig): AnyAgentTool {
|
|
42
48
|
return {
|
|
43
49
|
name: "xg_cwork_im_send_group_message",
|
|
44
|
-
label: "
|
|
50
|
+
label: "【xg_cwork_im】发送群聊消息",
|
|
45
51
|
description: [
|
|
46
|
-
"
|
|
52
|
+
"【仅限 xg_cwork_im 通道】向指定 IM 群聊发送一条消息。",
|
|
47
53
|
"适用于定时任务主动推送通知、AI 分析后发起提醒等场景。",
|
|
48
54
|
"消息将以机器人身份发出,可选择 @ 特定用户。",
|
|
49
|
-
"groupId 为 IM 群组的唯一 ID
|
|
55
|
+
"groupId 为 IM 群组的唯一 ID(不是群名称),通常从当前对话的 group_channel 字段中解析(格式:agent:main:xg_cwork_im:group:<groupId>)。",
|
|
50
56
|
].join("\n"),
|
|
51
57
|
parameters: SendGroupMessageParams,
|
|
52
58
|
async execute(_toolCallId: string, params: Static<typeof SendGroupMessageParams>) {
|
|
53
|
-
const { groupId, text, atUserIds = [] } = params;
|
|
59
|
+
const { groupId, text, atUserIds = [], accountId } = params;
|
|
54
60
|
|
|
55
61
|
console.log(`[cwork_im][tool] xg_cwork_im_send_group_message called: groupId=${groupId} atUsers=${JSON.stringify(atUserIds)}`);
|
|
56
62
|
|
|
57
63
|
let identity: BotIdentity;
|
|
58
64
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
const accountConfig = resolveAccountConfig(config, accountId);
|
|
66
|
+
identity = await getToken(accountConfig);
|
|
67
|
+
console.log(
|
|
68
|
+
`[cwork_im][tool] xg_cwork_im_send_group_message token acquired for userId=${identity.userId} using accountId=${accountId ?? "0"}`,
|
|
69
|
+
);
|
|
61
70
|
} catch (err: unknown) {
|
|
62
71
|
return jsonResult({
|
|
63
72
|
ok: false,
|
|
@@ -66,8 +75,11 @@ export function buildSendGroupMessageTool(config: XgImConfig): AnyAgentTool {
|
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
const accountConfig = resolveAccountConfig(config, accountId);
|
|
79
|
+
await sendTextMessage(accountConfig, identity.token, groupId, text, atUserIds);
|
|
80
|
+
console.log(
|
|
81
|
+
`[cwork_im][tool] xg_cwork_im_send_group_message success: groupId=${groupId} using accountId=${accountId ?? "0"}`,
|
|
82
|
+
);
|
|
71
83
|
} catch (err: unknown) {
|
|
72
84
|
return jsonResult({
|
|
73
85
|
ok: false,
|
package/src/send-service.ts
CHANGED
|
@@ -17,6 +17,8 @@ import type { GetLatestMsgListResponse, SendMessageBody, WsMessageParams, XgImCo
|
|
|
17
17
|
* @param content 消息内容文本
|
|
18
18
|
* @param atUserIds 需要 @ 的用户 ID 列表(可为空)
|
|
19
19
|
* @param log 日志接口
|
|
20
|
+
* @param msgId 可选的业务消息 ID(用于覆盖/更新已有消息)
|
|
21
|
+
* @param reply 可选的被回复消息信息(用于在 IM 中建立“回复某条消息”的关联)
|
|
20
22
|
*/
|
|
21
23
|
export async function sendTextMessage(
|
|
22
24
|
config: XgImConfig,
|
|
@@ -25,6 +27,8 @@ export async function sendTextMessage(
|
|
|
25
27
|
content: string,
|
|
26
28
|
atUserIds: string[] = [],
|
|
27
29
|
log?: Log,
|
|
30
|
+
msgId?: string,
|
|
31
|
+
reply?: SendMessageBody["reply"],
|
|
28
32
|
): Promise<void> {
|
|
29
33
|
const url = `${config.baseUrl}/im/message/send`;
|
|
30
34
|
|
|
@@ -33,6 +37,8 @@ export async function sendTextMessage(
|
|
|
33
37
|
groupId,
|
|
34
38
|
text: content,
|
|
35
39
|
...(atUserIds.length > 0 ? { atUserIds } : {}),
|
|
40
|
+
...(msgId ? { msgId } : {}),
|
|
41
|
+
...(reply ? { reply } : {}),
|
|
36
42
|
};
|
|
37
43
|
|
|
38
44
|
log?.info(`[cwork_im:send] POST ${url} groupId=${groupId} atUsers=${JSON.stringify(atUserIds)}`);
|
package/src/types.ts
CHANGED
|
@@ -110,8 +110,18 @@ export interface WsMessage {
|
|
|
110
110
|
export interface WsMessageParams {
|
|
111
111
|
msgId: string;
|
|
112
112
|
groupId: string;
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
/** 发送人信息 */
|
|
114
|
+
userInfo: {
|
|
115
|
+
/** 发送人 ID */
|
|
116
|
+
id: string;
|
|
117
|
+
/** 发送人显示名 */
|
|
118
|
+
name: string;
|
|
119
|
+
/**
|
|
120
|
+
* 用户背景信息。
|
|
121
|
+
* - 若非空,需要透传给 AI(可能是一段 JSON 字符串)。
|
|
122
|
+
*/
|
|
123
|
+
background?: string;
|
|
124
|
+
};
|
|
115
125
|
msgContent: {
|
|
116
126
|
text: string;
|
|
117
127
|
type: string;
|
|
@@ -128,12 +138,38 @@ export interface WsMessageParams {
|
|
|
128
138
|
// ─── IM 发送消息 ─────────────────────────────────────────────────────────────
|
|
129
139
|
|
|
130
140
|
/** POST /im/message/send 请求体 */
|
|
141
|
+
export interface SendMessageReply {
|
|
142
|
+
/** 被回复消息ID */
|
|
143
|
+
targetMsgId: string;
|
|
144
|
+
/** 被回复消息发送者ID */
|
|
145
|
+
targetUserId: string;
|
|
146
|
+
/** 被回复消息发送者显示名 */
|
|
147
|
+
targetUserName: string;
|
|
148
|
+
/** 被回复消息摘要 */
|
|
149
|
+
previewText: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
131
152
|
export interface SendMessageBody {
|
|
132
153
|
type: "TEXT" | "RICH_TEXT" | "VOICE";
|
|
133
154
|
groupId?: string;
|
|
134
155
|
toUserId?: string;
|
|
135
156
|
text: string;
|
|
136
157
|
atUserIds?: string[];
|
|
158
|
+
/**
|
|
159
|
+
* 业务侧生成的消息 ID。
|
|
160
|
+
*
|
|
161
|
+
* - 若不传:服务端按普通“新消息”处理。
|
|
162
|
+
* - 若传入:服务端可根据 msgId 更新已有的流式占位消息内容。
|
|
163
|
+
*/
|
|
164
|
+
msgId?: string;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 被回复消息信息。
|
|
168
|
+
*
|
|
169
|
+
* - 若不传:视为普通消息。
|
|
170
|
+
* - 若传入:服务端可按 targetMsgId 建立“回复某条消息”的关联。
|
|
171
|
+
*/
|
|
172
|
+
reply?: SendMessageReply;
|
|
137
173
|
}
|
|
138
174
|
|
|
139
175
|
/** GET /im/message/getLatestMsgListForAI 响应 */
|