@xgjktech/xg_cwork_im 1.0.0
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 +284 -0
- package/index.ts +33 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +63 -0
- package/src/auth.ts +79 -0
- package/src/channel.ts +471 -0
- package/src/connection.ts +212 -0
- package/src/group-history-tool.ts +145 -0
- package/src/send-group-message-tool.ts +85 -0
- package/src/send-service.ts +62 -0
- package/src/types.ts +163 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,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", "xgim", "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-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 } = ctx;
|
|
223
|
+
const log: Logger = { info: () => { }, warn: () => { }, error: () => { }, debug: () => { } };
|
|
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-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
|
+
|
|
293
|
+
let rawText = msgContent?.text ?? "";
|
|
294
|
+
// 如果是语音消息且没有文本内容,设为占位符
|
|
295
|
+
if (msgType === "voice" && !rawText) {
|
|
296
|
+
rawText = "[语音消息]";
|
|
297
|
+
}
|
|
298
|
+
const text = rawText;
|
|
299
|
+
|
|
300
|
+
if (!text && !msgUrl) {
|
|
301
|
+
log.debug?.(`${logPrefix} Empty message (no text and no url), skipping`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
log.info(
|
|
306
|
+
`${logPrefix} Message from ${params.fromUserName}(${params.fromUserId}) in group=${params.groupId}: ${text}`,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// 1. 获取当前机器人身份
|
|
310
|
+
const currentIdentity = await getToken(config, log);
|
|
311
|
+
// 2. 判定是否真正 @ 了当前机器人
|
|
312
|
+
const isMentioned = Array.isArray(params.mentions) &&
|
|
313
|
+
params.mentions.includes(currentIdentity.userId);
|
|
314
|
+
|
|
315
|
+
// (补充逻辑) 如果是 robotMention 指令,但没有 mentions 列表,则降级为老逻辑:文本正则匹配
|
|
316
|
+
const isLegacyMentioned = msg.cmd === "robotMention" &&
|
|
317
|
+
(!params.mentions || params.mentions.length === 0) &&
|
|
318
|
+
new RegExp(`@${currentIdentity.name}\\b`).test(rawText);
|
|
319
|
+
|
|
320
|
+
const actuallyMentioned = isMentioned || isLegacyMentioned;
|
|
321
|
+
|
|
322
|
+
// 3. 构建 OpenClaw 视角的“单条纯净消息”
|
|
323
|
+
|
|
324
|
+
// 通过 PluginRuntime 路由消息到 OpenClaw
|
|
325
|
+
const route = rt.channel.routing.resolveAgentRoute({
|
|
326
|
+
cfg: ctx.cfg,
|
|
327
|
+
channel: "xg_cwork_im",
|
|
328
|
+
accountId: account.accountId,
|
|
329
|
+
agentId: config.agentId || "main",
|
|
330
|
+
peer: { kind: "group", id: params.groupId },
|
|
331
|
+
});
|
|
332
|
+
log.info(`${logPrefix} [route] agentId=${route.agentId} sessionKey=${route.sessionKey}`);
|
|
333
|
+
|
|
334
|
+
const storePath = rt.channel.session.resolveStorePath(ctx.cfg.session?.store, {
|
|
335
|
+
agentId: route.agentId,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(ctx.cfg);
|
|
339
|
+
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
|
|
340
|
+
storePath,
|
|
341
|
+
sessionKey: route.sessionKey,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const msgTime = params.timestamp ?? params.msgSendTime ?? 0;
|
|
345
|
+
const fromLabel = `${params.groupId} - ${params.fromUserName}`;
|
|
346
|
+
const body = rt.channel.reply.formatInboundEnvelope({
|
|
347
|
+
channel: "XG-IM",
|
|
348
|
+
from: fromLabel,
|
|
349
|
+
timestamp: msgTime,
|
|
350
|
+
body: text,
|
|
351
|
+
chatType: "group",
|
|
352
|
+
sender: { name: params.fromUserName, id: params.fromUserId },
|
|
353
|
+
previousTimestamp,
|
|
354
|
+
envelope: envelopeOptions,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const inboundCtx = rt.channel.reply.finalizeInboundContext({
|
|
358
|
+
Body: body,
|
|
359
|
+
RawBody: text,
|
|
360
|
+
CommandBody: text,
|
|
361
|
+
From: params.groupId,
|
|
362
|
+
To: params.groupId,
|
|
363
|
+
SessionKey: route.sessionKey,
|
|
364
|
+
AccountId: account.accountId,
|
|
365
|
+
ChatType: "group",
|
|
366
|
+
ConversationLabel: fromLabel,
|
|
367
|
+
GroupSubject: params.groupId,
|
|
368
|
+
SenderName: params.fromUserName,
|
|
369
|
+
SenderId: params.fromUserId,
|
|
370
|
+
Provider: "xg_cwork_im",
|
|
371
|
+
Surface: "xg_cwork_im",
|
|
372
|
+
MessageSid: params.msgId,
|
|
373
|
+
Timestamp: msgTime,
|
|
374
|
+
OriginatingChannel: "xg_cwork_im",
|
|
375
|
+
OriginatingTo: params.groupId,
|
|
376
|
+
GroupChannel: route.sessionKey,
|
|
377
|
+
// 透传媒体信息
|
|
378
|
+
MediaUrl: msgUrl,
|
|
379
|
+
MediaType: msgType === "voice" ? "voice" : undefined,
|
|
380
|
+
// 透传扩展字段给 AI (作为 UntrustedContext)
|
|
381
|
+
UntrustedContext: msgExt ? [JSON.stringify(msgExt)] : undefined,
|
|
382
|
+
// 同时保留原始 ext 供可能的后续逻辑使用
|
|
383
|
+
XgImExt: msgExt,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// 【所有消息都必须做】记录到数据库!让 AI 产生“记忆”
|
|
387
|
+
log.info(`${logPrefix} [session] Recording inbound session sessionKey=${inboundCtx.SessionKey || route.sessionKey}`);
|
|
388
|
+
await rt.channel.session.recordInboundSession({
|
|
389
|
+
storePath,
|
|
390
|
+
sessionKey: inboundCtx.SessionKey || route.sessionKey,
|
|
391
|
+
ctx: inboundCtx,
|
|
392
|
+
updateLastRoute: {
|
|
393
|
+
sessionKey: route.mainSessionKey,
|
|
394
|
+
channel: "xg_cwork_im",
|
|
395
|
+
to: params.groupId,
|
|
396
|
+
accountId: account.accountId,
|
|
397
|
+
},
|
|
398
|
+
onRecordError: (err: unknown) => {
|
|
399
|
+
log.error(`${logPrefix} Failed to record session: ${String(err)}`);
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// 【只有真 @ 我的消息才做】呼叫 AI 激活推理
|
|
404
|
+
if (actuallyMentioned) {
|
|
405
|
+
log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
|
|
406
|
+
let isFirstReply = true;
|
|
407
|
+
const dispatchStart = Date.now();
|
|
408
|
+
|
|
409
|
+
// 分发消息给 AI,deliver 回调负责发送回复
|
|
410
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
411
|
+
ctx: inboundCtx,
|
|
412
|
+
cfg: ctx.cfg,
|
|
413
|
+
dispatcherOptions: {
|
|
414
|
+
responsePrefix: "",
|
|
415
|
+
deliver: async (payload: { markdown?: string; text?: string }) => {
|
|
416
|
+
try {
|
|
417
|
+
const textToSend = payload.markdown || payload.text;
|
|
418
|
+
if (!textToSend) return;
|
|
419
|
+
|
|
420
|
+
if (isFirstReply) {
|
|
421
|
+
const ttfr = Date.now() - dispatchStart;
|
|
422
|
+
log.info(`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`);
|
|
423
|
+
isFirstReply = false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
log.info(`${logPrefix} [deliver] Sending block to groupId=${params.groupId} length=${textToSend.length}`);
|
|
427
|
+
await sendTextMessage(
|
|
428
|
+
config,
|
|
429
|
+
currentIdentity.token,
|
|
430
|
+
params.groupId,
|
|
431
|
+
textToSend,
|
|
432
|
+
[params.fromUserId] as string[],
|
|
433
|
+
log,
|
|
434
|
+
);
|
|
435
|
+
log.info(`${logPrefix} [deliver] Block sent successfully to groupId=${params.groupId}`);
|
|
436
|
+
} catch (err: unknown) {
|
|
437
|
+
log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}`);
|
|
444
|
+
} else {
|
|
445
|
+
// 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
|
|
446
|
+
log.debug?.(`${logPrefix} Not mentioned in group, quietly memorized the message context.`);
|
|
447
|
+
}
|
|
448
|
+
} catch (err: unknown) {
|
|
449
|
+
log.error(`${logPrefix} handleMessage error: ${String(err)}`);
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// 5. 启动 WebSocket 连接
|
|
454
|
+
const wsHandle = startWebSocket(config, identity.token, handleMessage, log);
|
|
455
|
+
|
|
456
|
+
// 6. 阻塞到 abortSignal 触发
|
|
457
|
+
await new Promise<void>((resolve) => {
|
|
458
|
+
const abortHandler = (): void => {
|
|
459
|
+
log.info(`${logPrefix} Abort signal received, stopping XG-IM channel...`);
|
|
460
|
+
wsHandle.stop();
|
|
461
|
+
clearTokenCache(config);
|
|
462
|
+
resolve();
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
if (ctx.abortSignal) {
|
|
466
|
+
ctx.abortSignal.addEventListener("abort", abortHandler, { once: true });
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XG-IM WebSocket 连接管理模块
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* - 建立 wss://<baseUrl>/ws-notify/websocket?accessToken=<token> 长连接
|
|
6
|
+
* - 接收 robotMention 消息并回调
|
|
7
|
+
* - 指数退避 + 抖动的自动重连机制
|
|
8
|
+
* - 主动发送 WebSocket ping 保活,超时自动重连
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import WebSocket from "ws";
|
|
12
|
+
import type { Log } from "./auth.js";
|
|
13
|
+
import type { WsMessage, WsMessageParams, XgImConfig } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export type OnMessageCallback = (msg: WsMessage) => void;
|
|
16
|
+
|
|
17
|
+
/** 心跳间隔(ms):每 30 秒发一次 ping,防止连接被后台剔除 */
|
|
18
|
+
const PING_INTERVAL_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
/** 计算指数退避延迟(带随机抖动) */
|
|
21
|
+
function calcDelay(
|
|
22
|
+
attempt: number,
|
|
23
|
+
initial: number,
|
|
24
|
+
max: number,
|
|
25
|
+
jitter: number,
|
|
26
|
+
): number {
|
|
27
|
+
const base = Math.min(initial * 2 ** attempt, max);
|
|
28
|
+
const jitterMs = base * jitter * Math.random();
|
|
29
|
+
return Math.round(base + jitterMs);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 启动 WebSocket 连接并监听消息。
|
|
34
|
+
*
|
|
35
|
+
* @returns 一个 `stop()` 函数,调用后永久断开连接,不再重连。
|
|
36
|
+
*/
|
|
37
|
+
export function startWebSocket(
|
|
38
|
+
config: XgImConfig,
|
|
39
|
+
token: string,
|
|
40
|
+
onMessage: OnMessageCallback,
|
|
41
|
+
log: Log,
|
|
42
|
+
): { stop: () => void } {
|
|
43
|
+
const maxAttempts = config.maxConnectionAttempts ?? 10;
|
|
44
|
+
const initialDelay = config.initialReconnectDelay ?? 1_000;
|
|
45
|
+
const maxDelay = config.maxReconnectDelay ?? 60_000;
|
|
46
|
+
const jitter = config.reconnectJitter ?? 0.3;
|
|
47
|
+
const logPrefix = `[cwork_im:${config.agentId || "main"}]`;
|
|
48
|
+
|
|
49
|
+
// 将 baseUrl 的 http/https 协议替换为 ws/wss,并根据业务规则处理独立域名
|
|
50
|
+
let wsBase = config.wsBaseUrl;
|
|
51
|
+
if (!wsBase) {
|
|
52
|
+
// 自动映射逻辑:
|
|
53
|
+
// https://test.xgjktech.com.cn -> wss://websocket.xgjktech.com.cn
|
|
54
|
+
// https://xg.mediportal.com.cn -> wss://websocket.mediportal.com.cn
|
|
55
|
+
wsBase = config.baseUrl
|
|
56
|
+
.replace(/^http/, "ws");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const wsUrl = `${wsBase}/ws-notify/websocket?accessToken=${encodeURIComponent(token)}`;
|
|
60
|
+
|
|
61
|
+
let stopped = false;
|
|
62
|
+
let ws: WebSocket | null = null;
|
|
63
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
64
|
+
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
65
|
+
let attempt = 0;
|
|
66
|
+
|
|
67
|
+
/** 清除心跳定时器 */
|
|
68
|
+
function clearHeartbeat(): void {
|
|
69
|
+
if (pingTimer !== null) {
|
|
70
|
+
clearInterval(pingTimer);
|
|
71
|
+
pingTimer = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 启动心跳:每 30s 发一次 ping */
|
|
76
|
+
function startHeartbeat(socket: WebSocket): void {
|
|
77
|
+
clearHeartbeat();
|
|
78
|
+
|
|
79
|
+
pingTimer = setInterval(() => {
|
|
80
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
81
|
+
clearHeartbeat();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const now = new Date().toISOString();
|
|
86
|
+
log.info(`${logPrefix} ♥ Ping sent at ${now}`);
|
|
87
|
+
|
|
88
|
+
// 发送标准 WebSocket ping 帧
|
|
89
|
+
socket.ping((err: Error | null) => {
|
|
90
|
+
if (err) {
|
|
91
|
+
log.error(`${logPrefix} Ping frame error: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// 发送业务层文本 ping(更新服务端活跃时间)
|
|
96
|
+
socket.send("ping");
|
|
97
|
+
}, PING_INTERVAL_MS);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function connect(): void {
|
|
101
|
+
if (stopped) return;
|
|
102
|
+
|
|
103
|
+
log.info(`${logPrefix} Connecting to WebSocket (attempt ${attempt + 1})...`);
|
|
104
|
+
|
|
105
|
+
ws = new WebSocket(wsUrl);
|
|
106
|
+
|
|
107
|
+
ws.on("open", () => {
|
|
108
|
+
attempt = 0; // 连接成功后重置重试计数
|
|
109
|
+
log.info(`${logPrefix} WebSocket connected successfully.`);
|
|
110
|
+
startHeartbeat(ws!);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// 处理消息
|
|
114
|
+
ws.on("message", (raw: WebSocket.RawData) => {
|
|
115
|
+
const rawString = raw.toString();
|
|
116
|
+
|
|
117
|
+
if (rawString === "pong" || rawString === "ping") {
|
|
118
|
+
if (config.debug) log.debug?.(`${logPrefix} Ignored plain text ${rawString}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let parsed: WsMessage;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(rawString) as WsMessage;
|
|
125
|
+
} catch {
|
|
126
|
+
// 如果不是 JSON,说明是心跳或者其他内容,忽略即可
|
|
127
|
+
// 如果开启 debug 还是可以打印日志
|
|
128
|
+
if (config.debug) {
|
|
129
|
+
log.debug?.(`${logPrefix} Received non-JSON message: ${rawString.slice(0, 50)}`);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
log.info(`${logPrefix} ⇐ WS message received: cmd=${parsed.cmd}`);
|
|
135
|
+
|
|
136
|
+
// 这块不需要了,因为上面已经处理了纯文本 pong
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
// 处理包含 params 的事件(无论是 robotMention 还是普通 message)
|
|
140
|
+
if (parsed.params && (parsed.cmd === "robotMention" || parsed.cmd === "groupMessage")) {
|
|
141
|
+
if (parsed.cmd === "robotMention") {
|
|
142
|
+
log.info(`${logPrefix} 📨 robotMention received: groupId=${parsed.params.groupId} msgId=${parsed.params.msgId}`);
|
|
143
|
+
}
|
|
144
|
+
// onMessage 是 async 函数,必须用 .catch() 捕获异步错误
|
|
145
|
+
// 否则 await 之后的错误会变成未处理的 Promise rejection 被静默丢弃
|
|
146
|
+
Promise.resolve(onMessage(parsed)).catch((err: unknown) => {
|
|
147
|
+
log.error(`${logPrefix} onMessage async error: ${String(err)}`);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
ws.on("close", (code: number, reason: Buffer) => {
|
|
153
|
+
clearHeartbeat();
|
|
154
|
+
if (stopped) {
|
|
155
|
+
log.info(`${logPrefix} WebSocket closed (intentional stop).`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const reasonStr = (reason && reason.length > 0) ? reason.toString() : "(none)";
|
|
159
|
+
|
|
160
|
+
// 使用 log.info 输出警告,避免 log.warn 不存在导致漏打日志
|
|
161
|
+
log.info(`${logPrefix} [WARN] WebSocket closed unexpectedly: code=${code}, reason=${reasonStr}. Scheduling reconnect...`);
|
|
162
|
+
scheduleReconnect();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
ws.on("error", (err: Error) => {
|
|
166
|
+
if (stopped) return;
|
|
167
|
+
log.error(`${logPrefix} WebSocket error: ${err.message}`);
|
|
168
|
+
// 关闭后会触发 close 事件,由 close 处理重连
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function scheduleReconnect(): void {
|
|
173
|
+
if (stopped) return;
|
|
174
|
+
|
|
175
|
+
if (attempt >= maxAttempts) {
|
|
176
|
+
log.error(`${logPrefix} Max reconnect attempts (${maxAttempts}) reached. Giving up.`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const delay = calcDelay(attempt, initialDelay, maxDelay, jitter);
|
|
181
|
+
attempt += 1;
|
|
182
|
+
log.info(`${logPrefix} Reconnecting in ${delay}ms (attempt ${attempt}/${maxAttempts})...`);
|
|
183
|
+
|
|
184
|
+
reconnectTimer = setTimeout(() => {
|
|
185
|
+
reconnectTimer = null;
|
|
186
|
+
connect();
|
|
187
|
+
}, delay);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 首次连接
|
|
191
|
+
connect();
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
stop(): void {
|
|
195
|
+
if (stopped) return;
|
|
196
|
+
stopped = true;
|
|
197
|
+
log.info(`${logPrefix} Stopping WebSocket connection...`);
|
|
198
|
+
|
|
199
|
+
clearHeartbeat();
|
|
200
|
+
|
|
201
|
+
if (reconnectTimer !== null) {
|
|
202
|
+
clearTimeout(reconnectTimer);
|
|
203
|
+
reconnectTimer = null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
|
207
|
+
ws.close(1000, "Plugin stopped");
|
|
208
|
+
}
|
|
209
|
+
ws = null;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|