@xgjktech/xg_cwork_im 1.0.0 → 1.0.2
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 +519 -471
- package/src/connection.ts +133 -10
- package/src/group-history-tool.ts +42 -15
- 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,33 @@
|
|
|
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
|
+
* 基于 WebSocket 的流式消息客户端。
|
|
21
|
+
*
|
|
22
|
+
* - start(): 发送 START,等待 ack,返回 msgId。
|
|
23
|
+
* - chunk(): 基于 msgId 发送增量文本。
|
|
24
|
+
* - end(): 发送 END,通知服务端结束本次流式消息。
|
|
25
|
+
*/
|
|
26
|
+
export interface ImStreamClient {
|
|
27
|
+
start: (opts: { groupId?: string; toUserId?: string }) => Promise<{ msgId: string }>;
|
|
28
|
+
chunk: (msgId: string, data: { isThinking: boolean; content: string }) => Promise<void>;
|
|
29
|
+
end: (msgId: string, reason: string) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 心跳间隔(ms):每 20 秒发一次 ping,防止连接被后台剔除 */
|
|
33
|
+
const PING_INTERVAL_MS = 20_000;
|
|
19
34
|
|
|
20
35
|
/** 计算指数退避延迟(带随机抖动) */
|
|
21
36
|
function calcDelay(
|
|
@@ -39,8 +54,8 @@ export function startWebSocket(
|
|
|
39
54
|
token: string,
|
|
40
55
|
onMessage: OnMessageCallback,
|
|
41
56
|
log: Log,
|
|
42
|
-
): { stop: () => void } {
|
|
43
|
-
const maxAttempts = config.maxConnectionAttempts ??
|
|
57
|
+
): { stop: () => void; streamClient: ImStreamClient } {
|
|
58
|
+
const maxAttempts = config.maxConnectionAttempts ?? 200;
|
|
44
59
|
const initialDelay = config.initialReconnectDelay ?? 1_000;
|
|
45
60
|
const maxDelay = config.maxReconnectDelay ?? 60_000;
|
|
46
61
|
const jitter = config.reconnectJitter ?? 0.3;
|
|
@@ -64,6 +79,14 @@ export function startWebSocket(
|
|
|
64
79
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
65
80
|
let attempt = 0;
|
|
66
81
|
|
|
82
|
+
/**
|
|
83
|
+
* clientMsgId -> resolver(msgId)
|
|
84
|
+
*
|
|
85
|
+
* - 当发送 START 时,会生成一个 clientMsgId,并在此处挂一个 resolver。
|
|
86
|
+
* - 收到 im_stream_msg_ack 后,根据 clientMsgId 触发 resolver,返回服务端生成的 msgId。
|
|
87
|
+
*/
|
|
88
|
+
const pendingAcks = new Map<string, (msgId: string) => void>();
|
|
89
|
+
|
|
67
90
|
/** 清除心跳定时器 */
|
|
68
91
|
function clearHeartbeat(): void {
|
|
69
92
|
if (pingTimer !== null) {
|
|
@@ -97,6 +120,17 @@ export function startWebSocket(
|
|
|
97
120
|
}, PING_INTERVAL_MS);
|
|
98
121
|
}
|
|
99
122
|
|
|
123
|
+
/**
|
|
124
|
+
* 确保当前存在可用的 WebSocket 连接。
|
|
125
|
+
* 若不存在或未处于 OPEN 状态,则抛出错误,由上层决定是否回退为非流式模式。
|
|
126
|
+
*/
|
|
127
|
+
function ensureSocketReady(): WebSocket {
|
|
128
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
129
|
+
throw new Error(`${logPrefix} WebSocket is not ready for sending stream message`);
|
|
130
|
+
}
|
|
131
|
+
return ws;
|
|
132
|
+
}
|
|
133
|
+
|
|
100
134
|
function connect(): void {
|
|
101
135
|
if (stopped) return;
|
|
102
136
|
|
|
@@ -119,9 +153,9 @@ export function startWebSocket(
|
|
|
119
153
|
return;
|
|
120
154
|
}
|
|
121
155
|
|
|
122
|
-
let parsed:
|
|
156
|
+
let parsed: any;
|
|
123
157
|
try {
|
|
124
|
-
parsed = JSON.parse(rawString) as
|
|
158
|
+
parsed = JSON.parse(rawString) as unknown;
|
|
125
159
|
} catch {
|
|
126
160
|
// 如果不是 JSON,说明是心跳或者其他内容,忽略即可
|
|
127
161
|
// 如果开启 debug 还是可以打印日志
|
|
@@ -133,8 +167,24 @@ export function startWebSocket(
|
|
|
133
167
|
|
|
134
168
|
log.info(`${logPrefix} ⇐ WS message received: cmd=${parsed.cmd}`);
|
|
135
169
|
|
|
136
|
-
//
|
|
137
|
-
|
|
170
|
+
// 处理流式消息 ack:im_stream_msg_ack
|
|
171
|
+
if (parsed.cmd === "im_stream_msg_ack" && parsed.params) {
|
|
172
|
+
const clientMsgId: string | undefined = parsed.params.clientMsgId;
|
|
173
|
+
const msgId: string | undefined = parsed.params.msgId;
|
|
174
|
+
if (clientMsgId && msgId) {
|
|
175
|
+
const resolver = pendingAcks.get(clientMsgId);
|
|
176
|
+
if (resolver) {
|
|
177
|
+
pendingAcks.delete(clientMsgId);
|
|
178
|
+
resolver(msgId);
|
|
179
|
+
log.info(`${logPrefix} ⇐ im_stream_msg_ack received: clientMsgId=${clientMsgId} msgId=${msgId}`);
|
|
180
|
+
} else if (config.debug) {
|
|
181
|
+
log.debug?.(`${logPrefix} im_stream_msg_ack with unknown clientMsgId=${clientMsgId}`);
|
|
182
|
+
}
|
|
183
|
+
} else if (config.debug) {
|
|
184
|
+
log.debug?.(`${logPrefix} im_stream_msg_ack missing clientMsgId or msgId`);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
138
188
|
|
|
139
189
|
// 处理包含 params 的事件(无论是 robotMention 还是普通 message)
|
|
140
190
|
if (parsed.params && (parsed.cmd === "robotMention" || parsed.cmd === "groupMessage")) {
|
|
@@ -143,7 +193,7 @@ export function startWebSocket(
|
|
|
143
193
|
}
|
|
144
194
|
// onMessage 是 async 函数,必须用 .catch() 捕获异步错误
|
|
145
195
|
// 否则 await 之后的错误会变成未处理的 Promise rejection 被静默丢弃
|
|
146
|
-
Promise.resolve(onMessage(parsed)).catch((err: unknown) => {
|
|
196
|
+
Promise.resolve(onMessage(parsed as WsMessage)).catch((err: unknown) => {
|
|
147
197
|
log.error(`${logPrefix} onMessage async error: ${String(err)}`);
|
|
148
198
|
});
|
|
149
199
|
}
|
|
@@ -190,6 +240,78 @@ export function startWebSocket(
|
|
|
190
240
|
// 首次连接
|
|
191
241
|
connect();
|
|
192
242
|
|
|
243
|
+
const streamClient: ImStreamClient = {
|
|
244
|
+
async start(opts: { groupId?: string; toUserId?: string }): Promise<{ msgId: string }> {
|
|
245
|
+
const socket = ensureSocketReady();
|
|
246
|
+
const clientMsgId = randomUUID();
|
|
247
|
+
|
|
248
|
+
const payload = {
|
|
249
|
+
cmd: "im_stream_msg",
|
|
250
|
+
params: {
|
|
251
|
+
event: "START",
|
|
252
|
+
clientMsgId,
|
|
253
|
+
data: {
|
|
254
|
+
groupId: opts.groupId,
|
|
255
|
+
toUserId: opts.toUserId,
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
log.info(`${logPrefix} ⇒ WS START stream: clientMsgId=${clientMsgId} groupId=${opts.groupId ?? ""} toUserId=${opts.toUserId ?? ""}`);
|
|
261
|
+
|
|
262
|
+
const msgId = await new Promise<string>((resolve, reject) => {
|
|
263
|
+
pendingAcks.set(clientMsgId, resolve);
|
|
264
|
+
|
|
265
|
+
socket.send(JSON.stringify(payload), (err) => {
|
|
266
|
+
if (err) {
|
|
267
|
+
pendingAcks.delete(clientMsgId);
|
|
268
|
+
reject(err);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// 简单超时保护,避免 ack 永远不到导致 Promise 悬挂
|
|
273
|
+
setTimeout(() => {
|
|
274
|
+
if (pendingAcks.has(clientMsgId)) {
|
|
275
|
+
pendingAcks.delete(clientMsgId);
|
|
276
|
+
reject(new Error(`${logPrefix} START stream ack timeout for clientMsgId=${clientMsgId}`));
|
|
277
|
+
}
|
|
278
|
+
}, 10_000);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return { msgId };
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async chunk(msgId: string, data: { isThinking: boolean; content: string }): Promise<void> {
|
|
285
|
+
const socket = ensureSocketReady();
|
|
286
|
+
const payload = {
|
|
287
|
+
cmd: "im_stream_msg",
|
|
288
|
+
params: {
|
|
289
|
+
event: "CHUNK",
|
|
290
|
+
msgId,
|
|
291
|
+
data,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
if (config.debug) {
|
|
295
|
+
log.debug?.(`${logPrefix} ⇒ WS CHUNK stream: msgId=${msgId} isThinking=${data.isThinking} length=${data.content.length}`);
|
|
296
|
+
}
|
|
297
|
+
socket.send(JSON.stringify(payload));
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
async end(msgId: string, reason: string): Promise<void> {
|
|
301
|
+
const socket = ensureSocketReady();
|
|
302
|
+
const payload = {
|
|
303
|
+
cmd: "im_stream_msg",
|
|
304
|
+
params: {
|
|
305
|
+
event: "END",
|
|
306
|
+
msgId,
|
|
307
|
+
data: { reason },
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
log.info(`${logPrefix} ⇒ WS END stream: msgId=${msgId} reason=${reason}`);
|
|
311
|
+
socket.send(JSON.stringify(payload));
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
|
|
193
315
|
return {
|
|
194
316
|
stop(): void {
|
|
195
317
|
if (stopped) return;
|
|
@@ -208,5 +330,6 @@ export function startWebSocket(
|
|
|
208
330
|
}
|
|
209
331
|
ws = null;
|
|
210
332
|
},
|
|
333
|
+
streamClient,
|
|
211
334
|
};
|
|
212
335
|
}
|
|
@@ -18,6 +18,11 @@ const GroupHistoryParams = Type.Object({
|
|
|
18
18
|
groupId: Type.String({
|
|
19
19
|
description: "群聊 ID,即当前消息来自的群组唯一标识符",
|
|
20
20
|
}),
|
|
21
|
+
accountId: Type.Optional(
|
|
22
|
+
Type.String({
|
|
23
|
+
description: "(可选)要使用的 xg_cwork_im 账户下标(数字字符串,默认 0)",
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
21
26
|
limit: Type.Optional(
|
|
22
27
|
Type.Number({
|
|
23
28
|
description: "需要获取的消息条数(5~30),默认 10",
|
|
@@ -30,17 +35,33 @@ const GroupHistoryParams = Type.Object({
|
|
|
30
35
|
|
|
31
36
|
// ─── 内部 HTTP 工具 ──────────────────────────────────────────────────────────
|
|
32
37
|
|
|
33
|
-
/** 从 OpenClaw 配置中提取 xg_cwork_im
|
|
38
|
+
/** 从 OpenClaw 配置中提取 xg_cwork_im 顶层配置(保留 accounts 以便多账户路由) */
|
|
34
39
|
export function extractXgCworkImConfig(cfg: unknown): XgImConfig | null {
|
|
35
40
|
const raw = (cfg as Record<string, Record<string, unknown>>)?.channels?.xg_cwork_im as XgImConfig | undefined;
|
|
36
41
|
if (!raw?.baseUrl) return null;
|
|
42
|
+
return raw;
|
|
43
|
+
}
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
/**
|
|
46
|
+
* 根据 accountId 解析具体账号配置。
|
|
47
|
+
*
|
|
48
|
+
* - 若传入 accountId 且能解析为有效下标,则优先使用对应 accounts[accountId]。
|
|
49
|
+
* - 否则:有 accounts 时默认使用 accounts[0];无 accounts 时使用顶层配置。
|
|
50
|
+
*/
|
|
51
|
+
export function resolveAccountConfig(base: XgImConfig, accountId?: string | null): XgImConfig {
|
|
52
|
+
const accounts = base.accounts ?? [];
|
|
53
|
+
if (accounts.length === 0) {
|
|
54
|
+
return base;
|
|
42
55
|
}
|
|
43
|
-
|
|
56
|
+
let idx = 0;
|
|
57
|
+
if (accountId && accountId !== "default") {
|
|
58
|
+
const parsed = Number.parseInt(accountId, 10);
|
|
59
|
+
if (!Number.isNaN(parsed) && parsed >= 0 && parsed < accounts.length) {
|
|
60
|
+
idx = parsed;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const acc = accounts[idx] ?? accounts[0];
|
|
64
|
+
return { ...base, ...acc };
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
/** 调用 IM 接口拉取群聊最近 N 条消息 */
|
|
@@ -78,9 +99,9 @@ async function fetchGroupHistory(
|
|
|
78
99
|
export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
79
100
|
return {
|
|
80
101
|
name: "xg_cwork_im_get_group_chat_history",
|
|
81
|
-
label: "
|
|
102
|
+
label: "【xg_cwork_im】获取群聊历史记录",
|
|
82
103
|
description: [
|
|
83
|
-
"
|
|
104
|
+
"【仅限 xg_cwork_im 通道】获取当前 IM 群聊的最近消息历史,用于理解对话上下文背景。",
|
|
84
105
|
"",
|
|
85
106
|
"以下情况你必须先调用此工具,不得直接向用户反问或要求澄清:",
|
|
86
107
|
"- 用户使用了指代词却没说清楚指的是什么(如'这'、'那'、'上面说的'、'刚才提到的'、'这两家公司'等)",
|
|
@@ -88,20 +109,21 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
88
109
|
"- 你不确定用户问题的背景或主语时",
|
|
89
110
|
"",
|
|
90
111
|
"调用此工具后你会获得最近 N 条消息,用于还原上下文再给出回答。",
|
|
91
|
-
"groupId
|
|
112
|
+
"groupId 必须从会话元数据 group_channel 字段中解析,当且仅当其前缀为 agent:main:xg_cwork_im:group:<groupId> 时才可以调用本工具。",
|
|
92
113
|
"",
|
|
93
114
|
"【只有在以下情况才可以不调用】:用户的问题明确且自洽,不依赖任何历史对话。",
|
|
94
115
|
].join("\n"),
|
|
95
116
|
parameters: GroupHistoryParams,
|
|
96
117
|
async execute(_toolCallId: string, params: Static<typeof GroupHistoryParams>) {
|
|
97
118
|
const limit = params.limit ?? 10;
|
|
98
|
-
const { groupId } = params;
|
|
119
|
+
const { groupId, accountId } = params;
|
|
99
120
|
|
|
100
121
|
console.log(`[cwork_im][tool] xg_cwork_im_get_group_chat_history called: groupId=${groupId} limit=${limit}`);
|
|
101
122
|
|
|
102
123
|
let identity: BotIdentity;
|
|
103
124
|
try {
|
|
104
|
-
|
|
125
|
+
const accountConfig = resolveAccountConfig(config, accountId);
|
|
126
|
+
identity = await getToken(accountConfig);
|
|
105
127
|
console.log(`[cwork_im][tool] token acquired for userId=${identity.userId}`);
|
|
106
128
|
} catch (err: unknown) {
|
|
107
129
|
return jsonResult({
|
|
@@ -113,8 +135,11 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
113
135
|
|
|
114
136
|
let messages: WsMessageParams[];
|
|
115
137
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
138
|
+
const accountConfig = resolveAccountConfig(config, accountId);
|
|
139
|
+
messages = await fetchGroupHistory(accountConfig, identity.token, groupId, limit);
|
|
140
|
+
console.log(
|
|
141
|
+
`[cwork_im][tool] fetched ${messages.length} messages from group ${groupId} using accountId=${accountId ?? "0"}`,
|
|
142
|
+
);
|
|
118
143
|
} catch (err: unknown) {
|
|
119
144
|
return jsonResult({
|
|
120
145
|
ok: false,
|
|
@@ -127,8 +152,10 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
127
152
|
const formatted = messages
|
|
128
153
|
.filter((m) => m.msgContent?.type === "text")
|
|
129
154
|
.map((m) => {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
155
|
+
const senderId = m.userInfo?.id ?? "";
|
|
156
|
+
const senderName = m.userInfo?.name || senderId || "未知用户";
|
|
157
|
+
const isBot = senderId === identity.userId;
|
|
158
|
+
const sender = isBot ? "[AI]" : senderName;
|
|
132
159
|
const ts = m.timestamp ?? m.msgSendTime ?? 0;
|
|
133
160
|
const time = new Date(ts).toLocaleTimeString("zh-CN", { hour12: false });
|
|
134
161
|
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 响应 */
|