@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/channel.ts
CHANGED
|
@@ -1,471 +1,536 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* XG-IM Channel Plugin — 核心定义
|
|
3
|
-
*
|
|
4
|
-
* 遵循 OpenClaw Channel Plugin 架构(参考 openclaw-channel-dingtalk):
|
|
5
|
-
* - config : 账户解析
|
|
6
|
-
* - outbound : AI 回复发送
|
|
7
|
-
* - gateway : 启动 WebSocket 长连接,接收 robotMention 消息后通过
|
|
8
|
-
* PluginRuntime 路由给 OpenClaw 处理
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
13
|
-
import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
14
|
-
import { z } from "zod";
|
|
15
|
-
import { clearTokenCache, getToken } from "./auth.js";
|
|
16
|
-
import { startWebSocket } from "./connection.js";
|
|
17
|
-
import { sendTextMessage } from "./send-service.js";
|
|
18
|
-
import type {
|
|
19
|
-
GatewayStartContext,
|
|
20
|
-
PluginRuntime,
|
|
21
|
-
ResolvedAccount,
|
|
22
|
-
WsMessage,
|
|
23
|
-
WsMessageParams,
|
|
24
|
-
XgImChannelPlugin,
|
|
25
|
-
XgImConfig,
|
|
26
|
-
} from "./types.js";
|
|
27
|
-
|
|
28
|
-
// ─── 全局 Runtime(在 index.ts 的 register 中注入)────────────────────────────
|
|
29
|
-
|
|
30
|
-
let xgImRuntime: PluginRuntime | null = null;
|
|
31
|
-
|
|
32
|
-
export function setXgImRuntime(rt: PluginRuntime): void {
|
|
33
|
-
xgImRuntime = rt;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function getXgImRuntime(): PluginRuntime {
|
|
37
|
-
if (!xgImRuntime) throw new Error("[cwork_im] Plugin runtime not initialized");
|
|
38
|
-
return xgImRuntime;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ─── 配置 Schema ──────────────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
const XgImAccountConfigSchema = z.object({
|
|
44
|
-
appKey: z.string().min(1, "appKey is required"),
|
|
45
|
-
agentId: z.string().optional().default("main"),
|
|
46
|
-
name: z.string().optional(),
|
|
47
|
-
groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
const XgImConfigSchema = z.object({
|
|
51
|
-
appKey: z.string().optional(),
|
|
52
|
-
agentId: z.string().optional().default("main"),
|
|
53
|
-
baseUrl: z.string().url("baseUrl must be a valid URL"),
|
|
54
|
-
wsBaseUrl: z.string().url("wsBaseUrl must be a valid URL").optional(),
|
|
55
|
-
enabled: z.boolean().optional().default(true),
|
|
56
|
-
name: z.string().optional(),
|
|
57
|
-
groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
|
|
58
|
-
allowFrom: z.array(z.string()).optional().default([]),
|
|
59
|
-
debug: z.boolean().optional().default(false),
|
|
60
|
-
maxConnectionAttempts: z.number().int().positive().optional().default(10),
|
|
61
|
-
initialReconnectDelay: z.number().int().positive().optional().default(1_000),
|
|
62
|
-
maxReconnectDelay: z.number().int().positive().optional().default(60_000),
|
|
63
|
-
reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
|
|
64
|
-
accounts: z.array(XgImAccountConfigSchema).optional(),
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
// ─── 辅助函数 ─────────────────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
/** 从顶层 cfg 中取出 XgImConfig,支持多账户(README:channels.xg_cwork_im) */
|
|
70
|
-
function getXgImConfig(cfg: OpenClawConfig, accountId?: string | null): XgImConfig {
|
|
71
|
-
const raw = (cfg as Record<string, Record<string, unknown>>)?.channels?.xg_cwork_im as XgImConfig | undefined;
|
|
72
|
-
if (!raw) throw new Error("[cwork_im] channels.xg_cwork_im config not found");
|
|
73
|
-
|
|
74
|
-
// 指定了具体账户 ID(数组下标)时,合并对应账户配置
|
|
75
|
-
if (accountId && accountId !== "default" && raw.accounts) {
|
|
76
|
-
const accIdx = parseInt(accountId, 10);
|
|
77
|
-
const sub = raw.accounts[accIdx];
|
|
78
|
-
if (sub) {
|
|
79
|
-
return { ...raw, ...sub };
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// 没有指定 accountId(如 Cron outbound 场景),自动 fallback 到 accounts[0]
|
|
84
|
-
// 避免顶层 raw 没有 appKey 时 getToken 失败
|
|
85
|
-
if (raw.accounts && raw.accounts.length > 0 && !raw.appKey) {
|
|
86
|
-
return { ...raw, ...raw.accounts[0] };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return raw;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function isConfigured(cfg: OpenClawConfig): boolean {
|
|
93
|
-
try {
|
|
94
|
-
const c = getXgImConfig(cfg);
|
|
95
|
-
return Boolean(c?.appKey && c?.baseUrl);
|
|
96
|
-
} catch {
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ─── 日志适配器 ───────────────────────────────────────────────────────────────
|
|
102
|
-
// ChannelLogSink 的 info/warn/error 方法接受单个 string
|
|
103
|
-
|
|
104
|
-
interface Logger {
|
|
105
|
-
info: (msg: string) => void;
|
|
106
|
-
warn: (msg: string) => void;
|
|
107
|
-
error: (msg: string) => void;
|
|
108
|
-
debug?: (msg: string) => void;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function toLogger(sink: { info?: (msg: string) => void; warn?: (msg: string) => void; error?: (msg: string) => void; debug?: (msg: string) => void } | undefined): Logger {
|
|
112
|
-
return {
|
|
113
|
-
info: (msg) => sink?.info?.(msg),
|
|
114
|
-
warn: (msg) => sink?.warn?.(msg),
|
|
115
|
-
error: (msg) => sink?.error?.(msg),
|
|
116
|
-
debug: (msg) => sink?.debug?.(msg),
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ─── Channel Plugin 定义 ─────────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
123
|
-
id: "xg_cwork_im",
|
|
124
|
-
meta: {
|
|
125
|
-
id: "xg_cwork_im",
|
|
126
|
-
label: "XG CWork IM",
|
|
127
|
-
selectionLabel: "XG CWork IM (小橙工作台)",
|
|
128
|
-
docsPath: "/channels/xg_cwork_im",
|
|
129
|
-
blurb: "小橙工作台 IM 机器人,通过 WebSocket 长连接接收 @ 消息。",
|
|
130
|
-
aliases: ["xg_cwork_im", "
|
|
131
|
-
},
|
|
132
|
-
// 双重类型转换绕过 zod v3/v4 的 TS 类型不兼容
|
|
133
|
-
// buildChannelConfigSchema 会将 schema 包装成可序列化的形式,避免 DataCloneError
|
|
134
|
-
configSchema: buildChannelConfigSchema(XgImConfigSchema as unknown as Parameters<typeof buildChannelConfigSchema>[0]),
|
|
135
|
-
capabilities: {
|
|
136
|
-
chatTypes: ["group"] as Array<"direct" | "group">,
|
|
137
|
-
reactions: false,
|
|
138
|
-
threads: false,
|
|
139
|
-
media: false,
|
|
140
|
-
nativeCommands: false,
|
|
141
|
-
blockStreaming: false,
|
|
142
|
-
},
|
|
143
|
-
reload: { configPrefixes: ["channels.xg_cwork_im"] },
|
|
144
|
-
|
|
145
|
-
// ── 账户配置 ────────────────────────────────────────────────────────────────
|
|
146
|
-
config: {
|
|
147
|
-
listAccountIds: (cfg: OpenClawConfig): string[] => {
|
|
148
|
-
try {
|
|
149
|
-
const config = getXgImConfig(cfg);
|
|
150
|
-
if (config.accounts && config.accounts.length > 0) {
|
|
151
|
-
// 使用数组下标作为 ID
|
|
152
|
-
return config.accounts.map((_, i) => i.toString());
|
|
153
|
-
}
|
|
154
|
-
return isConfigured(cfg) ? ["default"] : [];
|
|
155
|
-
} catch {
|
|
156
|
-
return [];
|
|
157
|
-
}
|
|
158
|
-
},
|
|
159
|
-
|
|
160
|
-
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedAccount => {
|
|
161
|
-
const id = accountId || "default";
|
|
162
|
-
try {
|
|
163
|
-
const config = getXgImConfig(cfg, id);
|
|
164
|
-
return {
|
|
165
|
-
accountId: id,
|
|
166
|
-
config,
|
|
167
|
-
enabled: config.enabled !== false,
|
|
168
|
-
configured: Boolean(config.appKey && config.baseUrl),
|
|
169
|
-
name: config.name ?? null,
|
|
170
|
-
};
|
|
171
|
-
} catch {
|
|
172
|
-
return {
|
|
173
|
-
accountId: id,
|
|
174
|
-
config: {} as XgImConfig,
|
|
175
|
-
enabled: false,
|
|
176
|
-
configured: false,
|
|
177
|
-
name: null,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
},
|
|
181
|
-
|
|
182
|
-
defaultAccountId: (): string => "default",
|
|
183
|
-
|
|
184
|
-
isConfigured: (account: ResolvedAccount): boolean => account.configured,
|
|
185
|
-
|
|
186
|
-
describeAccount: (account: ResolvedAccount) => ({
|
|
187
|
-
accountId: account.accountId,
|
|
188
|
-
name: account.config?.name ?? "
|
|
189
|
-
enabled: account.enabled,
|
|
190
|
-
configured: account.configured,
|
|
191
|
-
}),
|
|
192
|
-
},
|
|
193
|
-
|
|
194
|
-
// ── 群聊设置 ─────────────────────────────────────────────────────────────────
|
|
195
|
-
groups: {
|
|
196
|
-
resolveRequireMention: (params): boolean => {
|
|
197
|
-
try {
|
|
198
|
-
const config = getXgImConfig(params.cfg, params.accountId);
|
|
199
|
-
return config?.groupPolicy !== "open";
|
|
200
|
-
} catch {
|
|
201
|
-
return true; // 默认需要 @
|
|
202
|
-
}
|
|
203
|
-
},
|
|
204
|
-
},
|
|
205
|
-
|
|
206
|
-
// ── 出站消息(openclaw 主动发送时调用)────────────────────────────────────────
|
|
207
|
-
outbound: {
|
|
208
|
-
deliveryMode: "direct" as const,
|
|
209
|
-
|
|
210
|
-
resolveTarget: (params) => {
|
|
211
|
-
const trimmed = params.to?.trim();
|
|
212
|
-
if (!trimmed) {
|
|
213
|
-
return {
|
|
214
|
-
ok: false as const,
|
|
215
|
-
error: new Error("XG-IM message requires --to <groupId>"),
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
return { ok: true as const, to: trimmed };
|
|
219
|
-
},
|
|
220
|
-
|
|
221
|
-
sendText: async (ctx) => {
|
|
222
|
-
const { cfg, to, text, accountId } = ctx;
|
|
223
|
-
const log
|
|
224
|
-
const config = getXgImConfig(cfg, accountId);
|
|
225
|
-
const identity = await getToken(config, log);
|
|
226
|
-
|
|
227
|
-
await sendTextMessage(config, identity.token, to, text, [], log);
|
|
228
|
-
|
|
229
|
-
return {
|
|
230
|
-
channel: "xg_cwork_im",
|
|
231
|
-
messageId: randomUUID(),
|
|
232
|
-
};
|
|
233
|
-
},
|
|
234
|
-
},
|
|
235
|
-
|
|
236
|
-
// ── 网关(WebSocket 长连接)─────────────────────────────────────────────────
|
|
237
|
-
gateway: {
|
|
238
|
-
startAccount: async (ctx: GatewayStartContext): Promise<void> => {
|
|
239
|
-
const account = ctx.account;
|
|
240
|
-
const config = account.config;
|
|
241
|
-
const log = toLogger(ctx.log);
|
|
242
|
-
const logPrefix = `[${account.accountId}:${config.agentId || "main"}]`;
|
|
243
|
-
|
|
244
|
-
if (!config.appKey || !config.baseUrl) {
|
|
245
|
-
throw new Error(`${logPrefix} appKey and baseUrl are required in config`);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
log.info(`${logPrefix} Starting
|
|
249
|
-
|
|
250
|
-
// 1. 获取机器人 token(有效期一年,缓存后无需重复请求)
|
|
251
|
-
const identity = await getToken(config, log);
|
|
252
|
-
|
|
253
|
-
// 2. 获取 PluginRuntime(用于路由消息给 OpenClaw)
|
|
254
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
255
|
-
const rt = getXgImRuntime() as any;
|
|
256
|
-
|
|
257
|
-
// 3. 若 abort 信号已触发则不启动
|
|
258
|
-
if (ctx.abortSignal?.aborted) {
|
|
259
|
-
throw new Error(`${logPrefix} Connection aborted before start`);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// 4. 消息去重(按账户隔离)
|
|
263
|
-
const processedMsgIds = new Set<string>();
|
|
264
|
-
const MSG_DEDUP_MAX = 1_000;
|
|
265
|
-
|
|
266
|
-
const isDuplicate = (msgId: string): boolean => {
|
|
267
|
-
if (processedMsgIds.has(msgId)) return true;
|
|
268
|
-
if (processedMsgIds.size >= MSG_DEDUP_MAX) {
|
|
269
|
-
const iter = processedMsgIds.values();
|
|
270
|
-
for (let i = 0; i < MSG_DEDUP_MAX / 2; i++) {
|
|
271
|
-
const val = iter.next().value;
|
|
272
|
-
if (val !== undefined) processedMsgIds.delete(val);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
processedMsgIds.add(msgId);
|
|
276
|
-
return false;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
// 5. 收到 WebSocket 消息时的处理逻辑
|
|
280
|
-
const handleMessage = async (msg: WsMessage): Promise<void> => {
|
|
281
|
-
const params = msg.params;
|
|
282
|
-
try {
|
|
283
|
-
if (isDuplicate(params.msgId)) {
|
|
284
|
-
log.debug?.(`${logPrefix} Duplicate msgId=${params.msgId}, skipping`);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const msgContent = params.msgContent;
|
|
289
|
-
const msgType = msgContent?.type ?? "text";
|
|
290
|
-
const msgUrl = msgContent?.url;
|
|
291
|
-
const msgExt = msgContent?.ext;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
(
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* XG-IM Channel Plugin — 核心定义
|
|
3
|
+
*
|
|
4
|
+
* 遵循 OpenClaw Channel Plugin 架构(参考 openclaw-channel-dingtalk):
|
|
5
|
+
* - config : 账户解析
|
|
6
|
+
* - outbound : AI 回复发送
|
|
7
|
+
* - gateway : 启动 WebSocket 长连接,接收 robotMention 消息后通过
|
|
8
|
+
* PluginRuntime 路由给 OpenClaw 处理
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
13
|
+
import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { clearTokenCache, getToken } from "./auth.js";
|
|
16
|
+
import { startWebSocket } from "./connection.js";
|
|
17
|
+
import { sendTextMessage } from "./send-service.js";
|
|
18
|
+
import type {
|
|
19
|
+
GatewayStartContext,
|
|
20
|
+
PluginRuntime,
|
|
21
|
+
ResolvedAccount,
|
|
22
|
+
WsMessage,
|
|
23
|
+
WsMessageParams,
|
|
24
|
+
XgImChannelPlugin,
|
|
25
|
+
XgImConfig,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
|
|
28
|
+
// ─── 全局 Runtime(在 index.ts 的 register 中注入)────────────────────────────
|
|
29
|
+
|
|
30
|
+
let xgImRuntime: PluginRuntime | null = null;
|
|
31
|
+
|
|
32
|
+
export function setXgImRuntime(rt: PluginRuntime): void {
|
|
33
|
+
xgImRuntime = rt;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getXgImRuntime(): PluginRuntime {
|
|
37
|
+
if (!xgImRuntime) throw new Error("[cwork_im] Plugin runtime not initialized");
|
|
38
|
+
return xgImRuntime;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── 配置 Schema ──────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const XgImAccountConfigSchema = z.object({
|
|
44
|
+
appKey: z.string().min(1, "appKey is required"),
|
|
45
|
+
agentId: z.string().optional().default("main"),
|
|
46
|
+
name: z.string().optional(),
|
|
47
|
+
groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const XgImConfigSchema = z.object({
|
|
51
|
+
appKey: z.string().optional(),
|
|
52
|
+
agentId: z.string().optional().default("main"),
|
|
53
|
+
baseUrl: z.string().url("baseUrl must be a valid URL"),
|
|
54
|
+
wsBaseUrl: z.string().url("wsBaseUrl must be a valid URL").optional(),
|
|
55
|
+
enabled: z.boolean().optional().default(true),
|
|
56
|
+
name: z.string().optional(),
|
|
57
|
+
groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
|
|
58
|
+
allowFrom: z.array(z.string()).optional().default([]),
|
|
59
|
+
debug: z.boolean().optional().default(false),
|
|
60
|
+
maxConnectionAttempts: z.number().int().positive().optional().default(10),
|
|
61
|
+
initialReconnectDelay: z.number().int().positive().optional().default(1_000),
|
|
62
|
+
maxReconnectDelay: z.number().int().positive().optional().default(60_000),
|
|
63
|
+
reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
|
|
64
|
+
accounts: z.array(XgImAccountConfigSchema).optional(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ─── 辅助函数 ─────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** 从顶层 cfg 中取出 XgImConfig,支持多账户(README:channels.xg_cwork_im) */
|
|
70
|
+
function getXgImConfig(cfg: OpenClawConfig, accountId?: string | null): XgImConfig {
|
|
71
|
+
const raw = (cfg as Record<string, Record<string, unknown>>)?.channels?.xg_cwork_im as XgImConfig | undefined;
|
|
72
|
+
if (!raw) throw new Error("[cwork_im] channels.xg_cwork_im config not found");
|
|
73
|
+
|
|
74
|
+
// 指定了具体账户 ID(数组下标)时,合并对应账户配置
|
|
75
|
+
if (accountId && accountId !== "default" && raw.accounts) {
|
|
76
|
+
const accIdx = parseInt(accountId, 10);
|
|
77
|
+
const sub = raw.accounts[accIdx];
|
|
78
|
+
if (sub) {
|
|
79
|
+
return { ...raw, ...sub };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 没有指定 accountId(如 Cron outbound 场景),自动 fallback 到 accounts[0]
|
|
84
|
+
// 避免顶层 raw 没有 appKey 时 getToken 失败
|
|
85
|
+
if (raw.accounts && raw.accounts.length > 0 && !raw.appKey) {
|
|
86
|
+
return { ...raw, ...raw.accounts[0] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return raw;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isConfigured(cfg: OpenClawConfig): boolean {
|
|
93
|
+
try {
|
|
94
|
+
const c = getXgImConfig(cfg);
|
|
95
|
+
return Boolean(c?.appKey && c?.baseUrl);
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── 日志适配器 ───────────────────────────────────────────────────────────────
|
|
102
|
+
// ChannelLogSink 的 info/warn/error 方法接受单个 string
|
|
103
|
+
|
|
104
|
+
interface Logger {
|
|
105
|
+
info: (msg: string) => void;
|
|
106
|
+
warn: (msg: string) => void;
|
|
107
|
+
error: (msg: string) => void;
|
|
108
|
+
debug?: (msg: string) => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toLogger(sink: { info?: (msg: string) => void; warn?: (msg: string) => void; error?: (msg: string) => void; debug?: (msg: string) => void } | undefined): Logger {
|
|
112
|
+
return {
|
|
113
|
+
info: (msg) => sink?.info?.(msg),
|
|
114
|
+
warn: (msg) => sink?.warn?.(msg),
|
|
115
|
+
error: (msg) => sink?.error?.(msg),
|
|
116
|
+
debug: (msg) => sink?.debug?.(msg),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Channel Plugin 定义 ─────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
123
|
+
id: "xg_cwork_im",
|
|
124
|
+
meta: {
|
|
125
|
+
id: "xg_cwork_im",
|
|
126
|
+
label: "XG CWork IM",
|
|
127
|
+
selectionLabel: "XG CWork IM (小橙工作台)",
|
|
128
|
+
docsPath: "/channels/xg_cwork_im",
|
|
129
|
+
blurb: "小橙工作台 IM 机器人,通过 WebSocket 长连接接收 @ 消息。",
|
|
130
|
+
aliases: ["xg_cwork_im", "im"],
|
|
131
|
+
},
|
|
132
|
+
// 双重类型转换绕过 zod v3/v4 的 TS 类型不兼容
|
|
133
|
+
// buildChannelConfigSchema 会将 schema 包装成可序列化的形式,避免 DataCloneError
|
|
134
|
+
configSchema: buildChannelConfigSchema(XgImConfigSchema as unknown as Parameters<typeof buildChannelConfigSchema>[0]),
|
|
135
|
+
capabilities: {
|
|
136
|
+
chatTypes: ["group"] as Array<"direct" | "group">,
|
|
137
|
+
reactions: false,
|
|
138
|
+
threads: false,
|
|
139
|
+
media: false,
|
|
140
|
+
nativeCommands: false,
|
|
141
|
+
blockStreaming: false,
|
|
142
|
+
},
|
|
143
|
+
reload: { configPrefixes: ["channels.xg_cwork_im"] },
|
|
144
|
+
|
|
145
|
+
// ── 账户配置 ────────────────────────────────────────────────────────────────
|
|
146
|
+
config: {
|
|
147
|
+
listAccountIds: (cfg: OpenClawConfig): string[] => {
|
|
148
|
+
try {
|
|
149
|
+
const config = getXgImConfig(cfg);
|
|
150
|
+
if (config.accounts && config.accounts.length > 0) {
|
|
151
|
+
// 使用数组下标作为 ID
|
|
152
|
+
return config.accounts.map((_, i) => i.toString());
|
|
153
|
+
}
|
|
154
|
+
return isConfigured(cfg) ? ["default"] : [];
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedAccount => {
|
|
161
|
+
const id = accountId || "default";
|
|
162
|
+
try {
|
|
163
|
+
const config = getXgImConfig(cfg, id);
|
|
164
|
+
return {
|
|
165
|
+
accountId: id,
|
|
166
|
+
config,
|
|
167
|
+
enabled: config.enabled !== false,
|
|
168
|
+
configured: Boolean(config.appKey && config.baseUrl),
|
|
169
|
+
name: config.name ?? null,
|
|
170
|
+
};
|
|
171
|
+
} catch {
|
|
172
|
+
return {
|
|
173
|
+
accountId: id,
|
|
174
|
+
config: {} as XgImConfig,
|
|
175
|
+
enabled: false,
|
|
176
|
+
configured: false,
|
|
177
|
+
name: null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
defaultAccountId: (): string => "default",
|
|
183
|
+
|
|
184
|
+
isConfigured: (account: ResolvedAccount): boolean => account.configured,
|
|
185
|
+
|
|
186
|
+
describeAccount: (account: ResolvedAccount) => ({
|
|
187
|
+
accountId: account.accountId,
|
|
188
|
+
name: account.config?.name ?? "xg_cwork_im",
|
|
189
|
+
enabled: account.enabled,
|
|
190
|
+
configured: account.configured,
|
|
191
|
+
}),
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
// ── 群聊设置 ─────────────────────────────────────────────────────────────────
|
|
195
|
+
groups: {
|
|
196
|
+
resolveRequireMention: (params): boolean => {
|
|
197
|
+
try {
|
|
198
|
+
const config = getXgImConfig(params.cfg, params.accountId);
|
|
199
|
+
return config?.groupPolicy !== "open";
|
|
200
|
+
} catch {
|
|
201
|
+
return true; // 默认需要 @
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// ── 出站消息(openclaw 主动发送时调用)────────────────────────────────────────
|
|
207
|
+
outbound: {
|
|
208
|
+
deliveryMode: "direct" as const,
|
|
209
|
+
|
|
210
|
+
resolveTarget: (params) => {
|
|
211
|
+
const trimmed = params.to?.trim();
|
|
212
|
+
if (!trimmed) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false as const,
|
|
215
|
+
error: new Error("XG-IM message requires --to <groupId>"),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return { ok: true as const, to: trimmed };
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
sendText: async (ctx) => {
|
|
222
|
+
const { cfg, to, text, accountId, log: ctxLog } = ctx as typeof ctx & { log?: Logger };
|
|
223
|
+
const log = toLogger(ctxLog);
|
|
224
|
+
const config = getXgImConfig(cfg, accountId);
|
|
225
|
+
const identity = await getToken(config, log);
|
|
226
|
+
|
|
227
|
+
await sendTextMessage(config, identity.token, to, text, [], log);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
channel: "xg_cwork_im",
|
|
231
|
+
messageId: randomUUID(),
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
// ── 网关(WebSocket 长连接)─────────────────────────────────────────────────
|
|
237
|
+
gateway: {
|
|
238
|
+
startAccount: async (ctx: GatewayStartContext): Promise<void> => {
|
|
239
|
+
const account = ctx.account;
|
|
240
|
+
const config = account.config;
|
|
241
|
+
const log = toLogger(ctx.log);
|
|
242
|
+
const logPrefix = `[${account.accountId}:${config.agentId || "main"}]`;
|
|
243
|
+
|
|
244
|
+
if (!config.appKey || !config.baseUrl) {
|
|
245
|
+
throw new Error(`${logPrefix} appKey and baseUrl are required in config`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
log.info(`${logPrefix} Starting xg-cwork-im channel...`);
|
|
249
|
+
|
|
250
|
+
// 1. 获取机器人 token(有效期一年,缓存后无需重复请求)
|
|
251
|
+
const identity = await getToken(config, log);
|
|
252
|
+
|
|
253
|
+
// 2. 获取 PluginRuntime(用于路由消息给 OpenClaw)
|
|
254
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
255
|
+
const rt = getXgImRuntime() as any;
|
|
256
|
+
|
|
257
|
+
// 3. 若 abort 信号已触发则不启动
|
|
258
|
+
if (ctx.abortSignal?.aborted) {
|
|
259
|
+
throw new Error(`${logPrefix} Connection aborted before start`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 4. 消息去重(按账户隔离)
|
|
263
|
+
const processedMsgIds = new Set<string>();
|
|
264
|
+
const MSG_DEDUP_MAX = 1_000;
|
|
265
|
+
|
|
266
|
+
const isDuplicate = (msgId: string): boolean => {
|
|
267
|
+
if (processedMsgIds.has(msgId)) return true;
|
|
268
|
+
if (processedMsgIds.size >= MSG_DEDUP_MAX) {
|
|
269
|
+
const iter = processedMsgIds.values();
|
|
270
|
+
for (let i = 0; i < MSG_DEDUP_MAX / 2; i++) {
|
|
271
|
+
const val = iter.next().value;
|
|
272
|
+
if (val !== undefined) processedMsgIds.delete(val);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
processedMsgIds.add(msgId);
|
|
276
|
+
return false;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// 5. 收到 WebSocket 消息时的处理逻辑
|
|
280
|
+
const handleMessage = async (msg: WsMessage): Promise<void> => {
|
|
281
|
+
const params = msg.params;
|
|
282
|
+
try {
|
|
283
|
+
if (isDuplicate(params.msgId)) {
|
|
284
|
+
log.debug?.(`${logPrefix} Duplicate msgId=${params.msgId}, skipping`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const msgContent = params.msgContent;
|
|
289
|
+
const msgType = msgContent?.type ?? "text";
|
|
290
|
+
const msgUrl = msgContent?.url;
|
|
291
|
+
const msgExt = msgContent?.ext;
|
|
292
|
+
const senderId = params.userInfo?.id;
|
|
293
|
+
const senderName = params.userInfo?.name || senderId || "";
|
|
294
|
+
const senderBackground = params.userInfo?.background;
|
|
295
|
+
|
|
296
|
+
let rawText = msgContent?.text ?? "";
|
|
297
|
+
// 如果是语音消息且没有文本内容,设为占位符
|
|
298
|
+
if (msgType === "voice" && !rawText) {
|
|
299
|
+
rawText = "[语音消息]";
|
|
300
|
+
}
|
|
301
|
+
const text = rawText;
|
|
302
|
+
|
|
303
|
+
if (!text && !msgUrl) {
|
|
304
|
+
log.debug?.(`${logPrefix} Empty message (no text and no url), skipping`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
log.info(
|
|
309
|
+
`${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// 1. 获取当前机器人身份
|
|
313
|
+
const currentIdentity = await getToken(config, log);
|
|
314
|
+
// 2. 判定是否真正 @ 了当前机器人
|
|
315
|
+
const mentions = params.mentions;
|
|
316
|
+
const isMentioned = Array.isArray(mentions) &&
|
|
317
|
+
(mentions.includes(currentIdentity.userId) || mentions.includes("all"));
|
|
318
|
+
|
|
319
|
+
// (补充逻辑) 如果是 robotMention 指令,但没有 mentions 列表,则降级为老逻辑:文本正则匹配
|
|
320
|
+
const isLegacyMentioned = msg.cmd === "robotMention" &&
|
|
321
|
+
(!params.mentions || params.mentions.length === 0) &&
|
|
322
|
+
new RegExp(`@${currentIdentity.name}\\b`).test(rawText);
|
|
323
|
+
|
|
324
|
+
const actuallyMentioned = isMentioned || isLegacyMentioned;
|
|
325
|
+
|
|
326
|
+
// 3. 构建 OpenClaw 视角的“单条纯净消息”
|
|
327
|
+
|
|
328
|
+
// 通过 PluginRuntime 路由消息到 OpenClaw
|
|
329
|
+
const route = rt.channel.routing.resolveAgentRoute({
|
|
330
|
+
cfg: ctx.cfg,
|
|
331
|
+
channel: "xg_cwork_im",
|
|
332
|
+
accountId: account.accountId,
|
|
333
|
+
agentId: config.agentId || "main",
|
|
334
|
+
peer: { kind: "group", id: params.groupId },
|
|
335
|
+
});
|
|
336
|
+
log.info(`${logPrefix} [route] agentId=${route.agentId} sessionKey=${route.sessionKey}`);
|
|
337
|
+
|
|
338
|
+
const storePath = rt.channel.session.resolveStorePath(ctx.cfg.session?.store, {
|
|
339
|
+
agentId: route.agentId,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(ctx.cfg);
|
|
343
|
+
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
|
|
344
|
+
storePath,
|
|
345
|
+
sessionKey: route.sessionKey,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const msgTime = params.timestamp ?? params.msgSendTime ?? 0;
|
|
349
|
+
const fromLabel = `${params.groupId} - ${senderName}`;
|
|
350
|
+
const body = rt.channel.reply.formatInboundEnvelope({
|
|
351
|
+
channel: "xg_cwork_im",
|
|
352
|
+
from: fromLabel,
|
|
353
|
+
timestamp: msgTime,
|
|
354
|
+
body: text,
|
|
355
|
+
chatType: "group",
|
|
356
|
+
sender: { name: senderName, id: senderId ?? "" },
|
|
357
|
+
previousTimestamp,
|
|
358
|
+
envelope: envelopeOptions,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const inboundCtx = rt.channel.reply.finalizeInboundContext({
|
|
362
|
+
Body: body,
|
|
363
|
+
RawBody: text,
|
|
364
|
+
CommandBody: text,
|
|
365
|
+
From: params.groupId,
|
|
366
|
+
To: params.groupId,
|
|
367
|
+
SessionKey: route.sessionKey,
|
|
368
|
+
AccountId: account.accountId,
|
|
369
|
+
ChatType: "group",
|
|
370
|
+
ConversationLabel: fromLabel,
|
|
371
|
+
GroupSubject: params.groupId,
|
|
372
|
+
SenderName: senderName,
|
|
373
|
+
SenderId: senderId ?? "",
|
|
374
|
+
Provider: "xg_cwork_im",
|
|
375
|
+
Surface: "xg_cwork_im",
|
|
376
|
+
MessageSid: params.msgId,
|
|
377
|
+
Timestamp: msgTime,
|
|
378
|
+
OriginatingChannel: "xg_cwork_im",
|
|
379
|
+
OriginatingTo: params.groupId,
|
|
380
|
+
GroupChannel: route.sessionKey,
|
|
381
|
+
// 透传媒体信息
|
|
382
|
+
MediaUrl: msgUrl,
|
|
383
|
+
MediaType: msgType === "voice" ? "voice" : undefined,
|
|
384
|
+
// 透传扩展字段给 AI (作为 UntrustedContext)
|
|
385
|
+
UntrustedContext: (() => {
|
|
386
|
+
const parts: string[] = [];
|
|
387
|
+
if (msgExt) parts.push(JSON.stringify(msgExt));
|
|
388
|
+
if (senderBackground) parts.push(String(senderBackground));
|
|
389
|
+
return parts.length > 0 ? parts : undefined;
|
|
390
|
+
})(),
|
|
391
|
+
// 同时保留原始 ext 供可能的后续逻辑使用
|
|
392
|
+
XgImExt: msgExt,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// 若消息内容为 /reset,则在将其转交给 OpenClaw 之前完整打印一次入站上下文,便于排查 reset 行为
|
|
396
|
+
if (text.trim() === "/reset") {
|
|
397
|
+
try {
|
|
398
|
+
log.info?.(
|
|
399
|
+
`${logPrefix} [reset] inboundCtx payload before dispatch: ${JSON.stringify(inboundCtx)}`,
|
|
400
|
+
);
|
|
401
|
+
} catch {
|
|
402
|
+
// 忽略 JSON 序列化异常,避免影响正常流程
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 【所有消息都必须做】记录到数据库!让 AI 产生“记忆”
|
|
407
|
+
log.info(`${logPrefix} [session] Recording inbound session sessionKey=${inboundCtx.SessionKey || route.sessionKey}`);
|
|
408
|
+
await rt.channel.session.recordInboundSession({
|
|
409
|
+
storePath,
|
|
410
|
+
sessionKey: inboundCtx.SessionKey || route.sessionKey,
|
|
411
|
+
ctx: inboundCtx,
|
|
412
|
+
updateLastRoute: {
|
|
413
|
+
sessionKey: route.mainSessionKey,
|
|
414
|
+
channel: "xg_cwork_im",
|
|
415
|
+
to: params.groupId,
|
|
416
|
+
accountId: account.accountId,
|
|
417
|
+
},
|
|
418
|
+
onRecordError: (err: unknown) => {
|
|
419
|
+
log.error(`${logPrefix} Failed to record session: ${String(err)}`);
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// 【只有真 @ 我的消息才做】呼叫 AI 激活推理
|
|
424
|
+
if (actuallyMentioned) {
|
|
425
|
+
log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
|
|
426
|
+
let isFirstReply = true;
|
|
427
|
+
const dispatchStart = Date.now();
|
|
428
|
+
|
|
429
|
+
// 1. 先向 IM 声明“开始流式消息”,拿到 msgId
|
|
430
|
+
const { msgId } = await wsHandle.streamClient.start({
|
|
431
|
+
groupId: params.groupId,
|
|
432
|
+
});
|
|
433
|
+
log.info(`${logPrefix} [stream] START acknowledged: msgId=${msgId}`);
|
|
434
|
+
|
|
435
|
+
// 2. 分发消息给 AI,deliver 回调负责把增量文本通过 CHUNK 推给 IM
|
|
436
|
+
let fullText = "";
|
|
437
|
+
// think 与 answer 阶段各自维护 seq,便于集群/跨服务按序重排
|
|
438
|
+
let thinkSeq = 0;
|
|
439
|
+
let answerSeq = 0;
|
|
440
|
+
|
|
441
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
442
|
+
ctx: inboundCtx,
|
|
443
|
+
cfg: ctx.cfg,
|
|
444
|
+
dispatcherOptions: {
|
|
445
|
+
responsePrefix: "",
|
|
446
|
+
deliver: async (payload: { markdown?: string; text?: string; isThinking?: boolean }) => {
|
|
447
|
+
try {
|
|
448
|
+
const textToSend = payload.markdown || payload.text;
|
|
449
|
+
if (!textToSend) return;
|
|
450
|
+
|
|
451
|
+
const isThinking = payload.isThinking === true;
|
|
452
|
+
const seq = isThinking ? thinkSeq++ : answerSeq++;
|
|
453
|
+
|
|
454
|
+
if (isFirstReply) {
|
|
455
|
+
const ttfr = Date.now() - dispatchStart;
|
|
456
|
+
log.info(`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`);
|
|
457
|
+
isFirstReply = false;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
fullText += textToSend;
|
|
461
|
+
|
|
462
|
+
// 将增量 block 以 CHUNK 形式推给 IM,带 seq 便于服务端按序
|
|
463
|
+
await wsHandle.streamClient.chunk(msgId, {
|
|
464
|
+
isThinking,
|
|
465
|
+
content: textToSend,
|
|
466
|
+
seq,
|
|
467
|
+
});
|
|
468
|
+
log.info(
|
|
469
|
+
`${logPrefix} [stream] CHUNK sent: msgId=${msgId} isThinking=${isThinking} seq=${seq} length=${textToSend.length}`,
|
|
470
|
+
);
|
|
471
|
+
} catch (err: unknown) {
|
|
472
|
+
log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
|
|
473
|
+
throw err;
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// 3. AI 推理完成,发送 END,并用 msgId 覆盖最终消息内容
|
|
480
|
+
await wsHandle.streamClient.end(msgId, "stop");
|
|
481
|
+
log.info(`${logPrefix} [stream] END sent: msgId=${msgId}`);
|
|
482
|
+
|
|
483
|
+
if (fullText) {
|
|
484
|
+
log.info(
|
|
485
|
+
`${logPrefix} [send] Updating final message via HTTP: groupId=${params.groupId} msgId=${msgId}`,
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
// 构造 reply 信息:回复用户刚发的这条消息
|
|
489
|
+
const reply = {
|
|
490
|
+
targetMsgId: params.msgId,
|
|
491
|
+
targetUserId: senderId ?? "",
|
|
492
|
+
targetUserName: senderName,
|
|
493
|
+
previewText: text,
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
await sendTextMessage(
|
|
497
|
+
config,
|
|
498
|
+
currentIdentity.token,
|
|
499
|
+
params.groupId,
|
|
500
|
+
fullText,
|
|
501
|
+
[senderId ?? ""] as string[],
|
|
502
|
+
log,
|
|
503
|
+
msgId,
|
|
504
|
+
reply,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}`);
|
|
509
|
+
} else {
|
|
510
|
+
// 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
|
|
511
|
+
log.debug?.(`${logPrefix} Not mentioned in group, quietly memorized the message context.`);
|
|
512
|
+
}
|
|
513
|
+
} catch (err: unknown) {
|
|
514
|
+
log.error(`${logPrefix} handleMessage error: ${String(err)}`);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// 5. 启动 WebSocket 连接
|
|
519
|
+
const wsHandle = startWebSocket(config, identity.token, handleMessage, log);
|
|
520
|
+
|
|
521
|
+
// 6. 阻塞到 abortSignal 触发(若存在);若无 abortSignal,则交由进程生命周期管理
|
|
522
|
+
if (ctx.abortSignal) {
|
|
523
|
+
await new Promise<void>((resolve) => {
|
|
524
|
+
const abortHandler = (): void => {
|
|
525
|
+
log.info(`${logPrefix} Abort signal received, stopping XG-IM channel...`);
|
|
526
|
+
wsHandle.stop();
|
|
527
|
+
clearTokenCache(config);
|
|
528
|
+
resolve();
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
ctx.abortSignal?.addEventListener("abort", abortHandler, { once: true });
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
};
|