@xgjktech/xg_cwork_im 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -17
- package/package.json +1 -1
- package/src/channel.ts +313 -191
- package/src/connection.ts +11 -3
- package/src/group-history-tool.ts +34 -16
- package/src/resource-file.ts +253 -0
- package/src/send-group-message-tool.ts +98 -97
- package/src/send-service.ts +215 -68
- package/src/tool-json-result.ts +24 -0
- package/src/types.ts +287 -206
package/src/channel.ts
CHANGED
|
@@ -13,16 +13,20 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
|
13
13
|
import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { clearTokenCache, getToken } from "./auth.js";
|
|
16
|
-
import { startWebSocket } from "./connection.js";
|
|
17
|
-
import { sendTextMessage } from "./send-service.js";
|
|
18
|
-
import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
import { startWebSocket, type ImStreamClient } from "./connection.js";
|
|
17
|
+
import { sendReplyDeliverBlock, sendTextMessage, type ReplyDeliverPayload } from "./send-service.js";
|
|
18
|
+
import {
|
|
19
|
+
imFormatToMimeType,
|
|
20
|
+
type BotIdentity,
|
|
21
|
+
type GatewayStartContext,
|
|
22
|
+
type MsgFileVO,
|
|
23
|
+
type PluginRuntime,
|
|
24
|
+
type ResolvedAccount,
|
|
25
|
+
type WsMessage,
|
|
26
|
+
type WsMessageContent,
|
|
27
|
+
type WsMessageParams,
|
|
28
|
+
type XgImChannelPlugin,
|
|
29
|
+
type XgImConfig,
|
|
26
30
|
} from "./types.js";
|
|
27
31
|
|
|
28
32
|
// ─── 全局 Runtime(在 index.ts 的 register 中注入)────────────────────────────
|
|
@@ -41,10 +45,14 @@ function getXgImRuntime(): PluginRuntime {
|
|
|
41
45
|
// ─── 配置 Schema ──────────────────────────────────────────────────────────────
|
|
42
46
|
|
|
43
47
|
const XgImAccountConfigSchema = z.object({
|
|
44
|
-
|
|
48
|
+
// 注意:为了兼容 OpenClaw doctor 生成的 accounts.default(仅包含默认项,不含 appKey),这里先放宽为 optional,
|
|
49
|
+
// 然后在 XgImConfigSchema.superRefine 中强制校验:除 default 之外的账号必须提供 appKey。
|
|
50
|
+
appKey: z.string().min(1, "appKey is required").optional(),
|
|
45
51
|
agentId: z.string().optional().default("main"),
|
|
46
52
|
name: z.string().optional(),
|
|
47
53
|
groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
|
|
54
|
+
fileUploadFormField: z.string().min(1).optional(),
|
|
55
|
+
maxAttachmentBytes: z.number().int().positive().optional(),
|
|
48
56
|
});
|
|
49
57
|
|
|
50
58
|
const XgImConfigSchema = z.object({
|
|
@@ -61,7 +69,24 @@ const XgImConfigSchema = z.object({
|
|
|
61
69
|
initialReconnectDelay: z.number().int().positive().optional().default(1_000),
|
|
62
70
|
maxReconnectDelay: z.number().int().positive().optional().default(60_000),
|
|
63
71
|
reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
|
|
64
|
-
|
|
72
|
+
fileUploadFormField: z.string().min(1).optional(),
|
|
73
|
+
maxAttachmentBytes: z.number().int().positive().optional(),
|
|
74
|
+
// 多账户:对象 map(key 为 accountId)
|
|
75
|
+
accounts: z.record(z.string(), XgImAccountConfigSchema).optional(),
|
|
76
|
+
}).superRefine((val, ctx) => {
|
|
77
|
+
const accounts = val.accounts;
|
|
78
|
+
if (!accounts) return;
|
|
79
|
+
for (const [key, acc] of Object.entries(accounts)) {
|
|
80
|
+
if (key === "default") continue;
|
|
81
|
+
if (!acc || typeof acc !== "object") continue;
|
|
82
|
+
if (!("appKey" in acc) || !acc.appKey) {
|
|
83
|
+
ctx.addIssue({
|
|
84
|
+
code: z.ZodIssueCode.custom,
|
|
85
|
+
path: ["accounts", key, "appKey"],
|
|
86
|
+
message: "appKey is required for account entries (except accounts.default)",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
65
90
|
});
|
|
66
91
|
|
|
67
92
|
// ─── 辅助函数 ─────────────────────────────────────────────────────────────────
|
|
@@ -71,19 +96,25 @@ function getXgImConfig(cfg: OpenClawConfig, accountId?: string | null): XgImConf
|
|
|
71
96
|
const raw = (cfg as Record<string, Record<string, unknown>>)?.channels?.xg_cwork_im as XgImConfig | undefined;
|
|
72
97
|
if (!raw) throw new Error("[cwork_im] channels.xg_cwork_im config not found");
|
|
73
98
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
99
|
+
const accounts = raw.accounts as unknown;
|
|
100
|
+
const accountMap =
|
|
101
|
+
accounts && typeof accounts === "object" && !Array.isArray(accounts)
|
|
102
|
+
? (accounts as Record<string, Partial<XgImConfig> | undefined>)
|
|
103
|
+
: undefined;
|
|
104
|
+
const defaults = accountMap?.default ? { ...accountMap.default } : undefined;
|
|
105
|
+
|
|
106
|
+
// 指定了具体账户 ID 时,优先合并对应账户配置
|
|
107
|
+
if (accountId && accountId !== "default" && accountMap) {
|
|
108
|
+
const sub = accountMap[accountId];
|
|
109
|
+
if (sub) return { ...raw, ...(defaults ?? {}), ...sub };
|
|
81
110
|
}
|
|
82
111
|
|
|
83
|
-
// 没有指定 accountId(如 Cron outbound 场景),自动 fallback
|
|
112
|
+
// 没有指定 accountId(如 Cron outbound 场景),自动 fallback 到「第一个账户」
|
|
84
113
|
// 避免顶层 raw 没有 appKey 时 getToken 失败
|
|
85
|
-
if (
|
|
86
|
-
|
|
114
|
+
if (accountMap && !raw.appKey) {
|
|
115
|
+
const firstKey = Object.keys(accountMap).find((k) => k !== "default");
|
|
116
|
+
const first = firstKey ? accountMap[firstKey] : undefined;
|
|
117
|
+
if (first) return { ...raw, ...(defaults ?? {}), ...first };
|
|
87
118
|
}
|
|
88
119
|
|
|
89
120
|
return raw;
|
|
@@ -117,6 +148,210 @@ function toLogger(sink: { info?: (msg: string) => void; warn?: (msg: string) =>
|
|
|
117
148
|
};
|
|
118
149
|
}
|
|
119
150
|
|
|
151
|
+
// ─── WebSocket 入站:纯函数与 @ 后派发 ───────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function collectFileItems(msgContent: WsMessageContent | undefined): MsgFileVO[] {
|
|
154
|
+
return (msgContent?.files ?? []).filter((f) => f.url?.trim());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildInboundDisplayText(msgContent: WsMessageContent | undefined, fileItems: MsgFileVO[]): string {
|
|
158
|
+
let rawText = msgContent?.text ?? "";
|
|
159
|
+
if (fileItems.length > 0) {
|
|
160
|
+
const names = fileItems
|
|
161
|
+
.map((f) => f.name?.trim() || f.fileId || "未命名文件")
|
|
162
|
+
.join("、");
|
|
163
|
+
const fileNote = `[附件 ${fileItems.length} 个: ${names}]`;
|
|
164
|
+
rawText = rawText.trim() ? `${rawText.trim()}\n\n${fileNote}` : fileNote;
|
|
165
|
+
}
|
|
166
|
+
return rawText;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isActuallyMentioned(
|
|
170
|
+
msg: WsMessage,
|
|
171
|
+
params: WsMessageParams,
|
|
172
|
+
bot: { userId: string; name: string },
|
|
173
|
+
rawTextForLegacy: string,
|
|
174
|
+
): boolean {
|
|
175
|
+
const mentions = params.mentions;
|
|
176
|
+
const isMentioned =
|
|
177
|
+
Array.isArray(mentions) && (mentions.includes(bot.userId) || mentions.includes("all"));
|
|
178
|
+
const isLegacyMentioned =
|
|
179
|
+
msg.cmd === "robotMention" &&
|
|
180
|
+
(!params.mentions || params.mentions.length === 0) &&
|
|
181
|
+
new RegExp(`@${bot.name}\\b`).test(rawTextForLegacy);
|
|
182
|
+
return isMentioned || isLegacyMentioned;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function mediaFieldsFromFileItems(fileItems: MsgFileVO[]): Record<string, unknown> {
|
|
186
|
+
if (fileItems.length === 0) return {};
|
|
187
|
+
const mimes = fileItems.map(
|
|
188
|
+
(f) => imFormatToMimeType(f.format) ?? "application/octet-stream",
|
|
189
|
+
);
|
|
190
|
+
return {
|
|
191
|
+
MediaPath: fileItems[0]!.url,
|
|
192
|
+
MediaUrl: fileItems[0]!.url,
|
|
193
|
+
MediaPaths: fileItems.map((f) => f.url),
|
|
194
|
+
MediaUrls: fileItems.map((f) => f.url),
|
|
195
|
+
MediaType: mimes[0],
|
|
196
|
+
MediaTypes: mimes,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function untrustedContextFromExtAndBackground(
|
|
201
|
+
msgExt: WsMessageContent["ext"],
|
|
202
|
+
senderBackground: string | undefined,
|
|
203
|
+
): string[] | undefined {
|
|
204
|
+
const parts: string[] = [];
|
|
205
|
+
if (msgExt) parts.push(JSON.stringify(msgExt));
|
|
206
|
+
if (senderBackground) parts.push(String(senderBackground));
|
|
207
|
+
return parts.length > 0 ? parts : undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildTargetReplyMeta(params: WsMessageParams): {
|
|
211
|
+
targetMsgId: string;
|
|
212
|
+
targetUserId: string;
|
|
213
|
+
targetUserName: string;
|
|
214
|
+
previewText: string;
|
|
215
|
+
} {
|
|
216
|
+
return {
|
|
217
|
+
targetMsgId: params.msgId,
|
|
218
|
+
targetUserId: params.userInfo?.id ?? "",
|
|
219
|
+
targetUserName: params.userInfo?.name ?? "未知用户",
|
|
220
|
+
previewText: params.msgContent?.text ?? "",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 流式 START → dispatch → 必发 END;首包超时走 HTTP 覆盖占位 */
|
|
225
|
+
async function dispatchMentionedReply(args: {
|
|
226
|
+
rt: any;
|
|
227
|
+
cfg: OpenClawConfig;
|
|
228
|
+
config: XgImConfig;
|
|
229
|
+
log: Logger;
|
|
230
|
+
logPrefix: string;
|
|
231
|
+
route: { sessionKey: string };
|
|
232
|
+
inboundCtx: unknown;
|
|
233
|
+
params: WsMessageParams;
|
|
234
|
+
currentIdentity: BotIdentity;
|
|
235
|
+
streamClient: ImStreamClient;
|
|
236
|
+
}): Promise<void> {
|
|
237
|
+
const { rt, cfg, config, log, logPrefix, route, inboundCtx, params, currentIdentity, streamClient } = args;
|
|
238
|
+
|
|
239
|
+
log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
|
|
240
|
+
let isFirstReply = true;
|
|
241
|
+
const dispatchStart = Date.now();
|
|
242
|
+
|
|
243
|
+
const { msgId: streamMsgId } = await streamClient.start({
|
|
244
|
+
groupId: params.groupId,
|
|
245
|
+
});
|
|
246
|
+
log.info(`${logPrefix} [stream] START acknowledged: msgId=${streamMsgId}`);
|
|
247
|
+
|
|
248
|
+
let hasFirstReply = false;
|
|
249
|
+
const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 30 * 60_000;
|
|
250
|
+
const timeoutLabel = `${logPrefix} [stream] First reply timeout after ${firstReplyTimeoutMs}ms, updating thinking message as error`;
|
|
251
|
+
const firstReplyTimeout = setTimeout(async () => {
|
|
252
|
+
if (hasFirstReply) return;
|
|
253
|
+
log.warn(timeoutLabel);
|
|
254
|
+
try {
|
|
255
|
+
const reply = buildTargetReplyMeta(params);
|
|
256
|
+
const timeoutText = "当前请求处理超时,请稍后重试。";
|
|
257
|
+
await sendTextMessage(
|
|
258
|
+
config,
|
|
259
|
+
currentIdentity.token,
|
|
260
|
+
params.groupId,
|
|
261
|
+
timeoutText,
|
|
262
|
+
[reply.targetUserId] as string[],
|
|
263
|
+
log,
|
|
264
|
+
streamMsgId,
|
|
265
|
+
reply,
|
|
266
|
+
);
|
|
267
|
+
log.info(
|
|
268
|
+
`${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} text="${timeoutText}"`,
|
|
269
|
+
);
|
|
270
|
+
} catch (err: unknown) {
|
|
271
|
+
log.error(`${logPrefix} [timeout] Failed to send timeout reply: ${String(err)}`);
|
|
272
|
+
}
|
|
273
|
+
}, firstReplyTimeoutMs);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
277
|
+
ctx: inboundCtx,
|
|
278
|
+
cfg,
|
|
279
|
+
dispatcherOptions: {
|
|
280
|
+
responsePrefix: "",
|
|
281
|
+
deliver: async (payload: ReplyDeliverPayload) => {
|
|
282
|
+
try {
|
|
283
|
+
const textPart = (payload.markdown || payload.text || "").trim();
|
|
284
|
+
const hasMedia =
|
|
285
|
+
Boolean(payload.mediaUrl?.trim()) ||
|
|
286
|
+
Boolean(payload.mediaUrls?.some((u) => typeof u === "string" && u.trim()));
|
|
287
|
+
if (!textPart && !(hasMedia && !payload.isThinking)) return;
|
|
288
|
+
|
|
289
|
+
if (isFirstReply) {
|
|
290
|
+
const ttfr = Date.now() - dispatchStart;
|
|
291
|
+
log.info(
|
|
292
|
+
`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`,
|
|
293
|
+
);
|
|
294
|
+
isFirstReply = false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const reply = buildTargetReplyMeta(params);
|
|
298
|
+
const atIds = [reply.targetUserId] as string[];
|
|
299
|
+
|
|
300
|
+
if (!hasFirstReply) {
|
|
301
|
+
hasFirstReply = true;
|
|
302
|
+
clearTimeout(firstReplyTimeout);
|
|
303
|
+
await sendReplyDeliverBlock(
|
|
304
|
+
config,
|
|
305
|
+
currentIdentity.token,
|
|
306
|
+
params.groupId,
|
|
307
|
+
payload,
|
|
308
|
+
atIds,
|
|
309
|
+
log,
|
|
310
|
+
streamMsgId,
|
|
311
|
+
reply,
|
|
312
|
+
);
|
|
313
|
+
const preview =
|
|
314
|
+
textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
315
|
+
log.info(
|
|
316
|
+
`${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} preview="${preview}"`,
|
|
317
|
+
);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
await sendReplyDeliverBlock(
|
|
322
|
+
config,
|
|
323
|
+
currentIdentity.token,
|
|
324
|
+
params.groupId,
|
|
325
|
+
payload,
|
|
326
|
+
atIds,
|
|
327
|
+
log,
|
|
328
|
+
undefined,
|
|
329
|
+
reply,
|
|
330
|
+
);
|
|
331
|
+
const preview =
|
|
332
|
+
textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
333
|
+
log.info(
|
|
334
|
+
`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`,
|
|
335
|
+
);
|
|
336
|
+
} catch (err: unknown) {
|
|
337
|
+
log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}`);
|
|
344
|
+
} finally {
|
|
345
|
+
clearTimeout(firstReplyTimeout);
|
|
346
|
+
try {
|
|
347
|
+
await streamClient.end(streamMsgId, "stop");
|
|
348
|
+
log.info(`${logPrefix} [stream] END sent: msgId=${streamMsgId}`);
|
|
349
|
+
} catch (endErr: unknown) {
|
|
350
|
+
log.error(`${logPrefix} [stream] END failed (msgId=${streamMsgId}): ${String(endErr)}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
120
355
|
// ─── Channel Plugin 定义 ─────────────────────────────────────────────────────
|
|
121
356
|
|
|
122
357
|
export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
@@ -136,7 +371,7 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
136
371
|
chatTypes: ["group"] as Array<"direct" | "group">,
|
|
137
372
|
reactions: false,
|
|
138
373
|
threads: false,
|
|
139
|
-
media:
|
|
374
|
+
media: true,
|
|
140
375
|
nativeCommands: false,
|
|
141
376
|
blockStreaming: false,
|
|
142
377
|
},
|
|
@@ -147,9 +382,13 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
147
382
|
listAccountIds: (cfg: OpenClawConfig): string[] => {
|
|
148
383
|
try {
|
|
149
384
|
const config = getXgImConfig(cfg);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
385
|
+
const accounts = config.accounts as unknown;
|
|
386
|
+
if (accounts) {
|
|
387
|
+
if (!Array.isArray(accounts) && typeof accounts === "object") {
|
|
388
|
+
// 推荐写法:对象 key 作为 accountId
|
|
389
|
+
const keys = Object.keys(accounts as Record<string, unknown>).filter((k) => k !== "default");
|
|
390
|
+
return keys;
|
|
391
|
+
}
|
|
153
392
|
}
|
|
154
393
|
return isConfigured(cfg) ? ["default"] : [];
|
|
155
394
|
} catch {
|
|
@@ -179,6 +418,8 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
179
418
|
}
|
|
180
419
|
},
|
|
181
420
|
|
|
421
|
+
// 注意:OpenClaw 可能在缺省路由时使用 defaultAccountId。
|
|
422
|
+
// 我们使用 "default" 作为别名:getXgImConfig 会自动 fallback 到第一个非 default 的真实账号。
|
|
182
423
|
defaultAccountId: (): string => "default",
|
|
183
424
|
|
|
184
425
|
isConfigured: (account: ResolvedAccount): boolean => account.configured,
|
|
@@ -231,6 +472,35 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
231
472
|
messageId: randomUUID(),
|
|
232
473
|
};
|
|
233
474
|
},
|
|
475
|
+
|
|
476
|
+
/** 出站带媒体:上传资源后发 FILE,与网关 deliver 逻辑一致(OpenClaw 要求与 sendText 同时实现) */
|
|
477
|
+
sendMedia: async (ctx) => {
|
|
478
|
+
const { cfg, to, text, mediaUrl, accountId, log: ctxLog } = ctx as typeof ctx & { log?: Logger };
|
|
479
|
+
const log = toLogger(ctxLog);
|
|
480
|
+
const config = getXgImConfig(cfg, accountId);
|
|
481
|
+
const identity = await getToken(config, log);
|
|
482
|
+
|
|
483
|
+
if (!mediaUrl?.trim()) {
|
|
484
|
+
await sendTextMessage(config, identity.token, to, text || "", [], log);
|
|
485
|
+
return { channel: "xg_cwork_im", messageId: randomUUID() };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
await sendReplyDeliverBlock(
|
|
489
|
+
config,
|
|
490
|
+
identity.token,
|
|
491
|
+
to,
|
|
492
|
+
{ text: text || "", mediaUrl: mediaUrl.trim() },
|
|
493
|
+
[],
|
|
494
|
+
log,
|
|
495
|
+
undefined,
|
|
496
|
+
undefined,
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
channel: "xg_cwork_im",
|
|
501
|
+
messageId: randomUUID(),
|
|
502
|
+
};
|
|
503
|
+
},
|
|
234
504
|
},
|
|
235
505
|
|
|
236
506
|
// ── 网关(WebSocket 长连接)─────────────────────────────────────────────────
|
|
@@ -286,22 +556,16 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
286
556
|
}
|
|
287
557
|
|
|
288
558
|
const msgContent = params.msgContent;
|
|
289
|
-
const
|
|
290
|
-
const msgUrl = msgContent?.url;
|
|
559
|
+
const fileItems = collectFileItems(msgContent);
|
|
291
560
|
const msgExt = msgContent?.ext;
|
|
292
561
|
const senderId = params.userInfo?.id;
|
|
293
562
|
const senderName = params.userInfo?.name || senderId || "";
|
|
294
563
|
const senderBackground = params.userInfo?.background;
|
|
295
564
|
|
|
296
|
-
|
|
297
|
-
// 如果是语音消息且没有文本内容,设为占位符
|
|
298
|
-
if (msgType === "voice" && !rawText) {
|
|
299
|
-
rawText = "[语音消息]";
|
|
300
|
-
}
|
|
301
|
-
const text = rawText;
|
|
565
|
+
const text = buildInboundDisplayText(msgContent, fileItems);
|
|
302
566
|
|
|
303
|
-
if (!text &&
|
|
304
|
-
log.debug?.(`${logPrefix} Empty message (no text and no
|
|
567
|
+
if (!text.trim() && fileItems.length === 0) {
|
|
568
|
+
log.debug?.(`${logPrefix} Empty message (no text and no files), skipping`);
|
|
305
569
|
return;
|
|
306
570
|
}
|
|
307
571
|
|
|
@@ -309,21 +573,8 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
309
573
|
`${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`,
|
|
310
574
|
);
|
|
311
575
|
|
|
312
|
-
// 1. 获取当前机器人身份
|
|
313
576
|
const currentIdentity = await getToken(config, log);
|
|
314
|
-
|
|
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 视角的“单条纯净消息”
|
|
577
|
+
const actuallyMentioned = isActuallyMentioned(msg, params, currentIdentity, text);
|
|
327
578
|
|
|
328
579
|
// 通过 PluginRuntime 路由消息到 OpenClaw
|
|
329
580
|
const route = rt.channel.routing.resolveAgentRoute({
|
|
@@ -378,16 +629,8 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
378
629
|
OriginatingChannel: "xg_cwork_im",
|
|
379
630
|
OriginatingTo: params.groupId,
|
|
380
631
|
GroupChannel: route.sessionKey,
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
})(),
|
|
632
|
+
...mediaFieldsFromFileItems(fileItems),
|
|
633
|
+
UntrustedContext: untrustedContextFromExtAndBackground(msgExt, senderBackground),
|
|
391
634
|
// 同时保留原始 ext 供可能的后续逻辑使用
|
|
392
635
|
XgImExt: msgExt,
|
|
393
636
|
});
|
|
@@ -420,140 +663,19 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
420
663
|
},
|
|
421
664
|
});
|
|
422
665
|
|
|
423
|
-
// 【只有真 @ 我的消息才做】呼叫 AI 激活推理
|
|
424
666
|
if (actuallyMentioned) {
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
// 1.1 为“思考中”占位增加首回复超时保护(默认 5 分钟,可通过 firstReplyTimeoutMs 配置)
|
|
436
|
-
let hasFirstReply = false;
|
|
437
|
-
let firstReplyTimedOut = false;
|
|
438
|
-
const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 5 * 60_000;
|
|
439
|
-
const timeoutLabel = `${logPrefix} [stream] First reply timeout after ${firstReplyTimeoutMs}ms, updating thinking message as error`;
|
|
440
|
-
const firstReplyTimeout = setTimeout(async () => {
|
|
441
|
-
if (hasFirstReply) {
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
firstReplyTimedOut = true;
|
|
445
|
-
log.warn(timeoutLabel);
|
|
446
|
-
try {
|
|
447
|
-
const senderId = params.userInfo?.id;
|
|
448
|
-
const senderName = params.userInfo?.name ?? "未知用户";
|
|
449
|
-
const text = params.msgContent?.text ?? "";
|
|
450
|
-
const reply = {
|
|
451
|
-
targetMsgId: params.msgId,
|
|
452
|
-
targetUserId: senderId ?? "",
|
|
453
|
-
targetUserName: senderName,
|
|
454
|
-
previewText: text,
|
|
455
|
-
};
|
|
456
|
-
const timeoutText = "当前请求处理超时,请稍后重试。";
|
|
457
|
-
await sendTextMessage(
|
|
458
|
-
config,
|
|
459
|
-
currentIdentity.token,
|
|
460
|
-
params.groupId,
|
|
461
|
-
timeoutText,
|
|
462
|
-
[senderId ?? ""] as string[],
|
|
463
|
-
log,
|
|
464
|
-
msgId,
|
|
465
|
-
reply,
|
|
466
|
-
);
|
|
467
|
-
log.info(
|
|
468
|
-
`${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${msgId} text="${timeoutText}"`,
|
|
469
|
-
);
|
|
470
|
-
} catch (err: unknown) {
|
|
471
|
-
log.error(`${logPrefix} [timeout] Failed to send timeout reply: ${String(err)}`);
|
|
472
|
-
}
|
|
473
|
-
}, firstReplyTimeoutMs);
|
|
474
|
-
|
|
475
|
-
// 2. 分发消息给 AI,deliver 回调负责发送首条和后续回复
|
|
476
|
-
let fullText = "";
|
|
477
|
-
|
|
478
|
-
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
479
|
-
ctx: inboundCtx,
|
|
667
|
+
await dispatchMentionedReply({
|
|
668
|
+
rt,
|
|
480
669
|
cfg: ctx.cfg,
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const ttfr = Date.now() - dispatchStart;
|
|
490
|
-
log.info(`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`);
|
|
491
|
-
isFirstReply = false;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
fullText += textToSend;
|
|
495
|
-
|
|
496
|
-
const senderId = params.userInfo?.id;
|
|
497
|
-
const senderName = params.userInfo?.name ?? "未知用户";
|
|
498
|
-
const text = params.msgContent?.text ?? "";
|
|
499
|
-
const reply = {
|
|
500
|
-
targetMsgId: params.msgId,
|
|
501
|
-
targetUserId: senderId ?? "",
|
|
502
|
-
targetUserName: senderName,
|
|
503
|
-
previewText: text,
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
// 第一次有效回复:覆盖“思考中”占位消息(带 msgId)
|
|
507
|
-
if (!hasFirstReply) {
|
|
508
|
-
hasFirstReply = true;
|
|
509
|
-
clearTimeout(firstReplyTimeout);
|
|
510
|
-
await sendTextMessage(
|
|
511
|
-
config,
|
|
512
|
-
currentIdentity.token,
|
|
513
|
-
params.groupId,
|
|
514
|
-
textToSend,
|
|
515
|
-
[senderId ?? ""] as string[],
|
|
516
|
-
log,
|
|
517
|
-
msgId,
|
|
518
|
-
reply,
|
|
519
|
-
);
|
|
520
|
-
const preview = textToSend.length > 80 ? `${textToSend.slice(0, 80)}...` : textToSend;
|
|
521
|
-
log.info(
|
|
522
|
-
`${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${msgId} text="${preview}"`,
|
|
523
|
-
);
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// 后续回复:作为独立消息发送(不再复用 msgId)
|
|
528
|
-
await sendTextMessage(
|
|
529
|
-
config,
|
|
530
|
-
currentIdentity.token,
|
|
531
|
-
params.groupId,
|
|
532
|
-
textToSend,
|
|
533
|
-
[senderId ?? ""] as string[],
|
|
534
|
-
log,
|
|
535
|
-
undefined,
|
|
536
|
-
reply,
|
|
537
|
-
);
|
|
538
|
-
const preview = textToSend.length > 80 ? `${textToSend.slice(0, 80)}...` : textToSend;
|
|
539
|
-
log.info(
|
|
540
|
-
`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} text="${preview}"`,
|
|
541
|
-
);
|
|
542
|
-
} catch (err: unknown) {
|
|
543
|
-
log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
|
|
544
|
-
throw err;
|
|
545
|
-
}
|
|
546
|
-
},
|
|
547
|
-
},
|
|
670
|
+
config,
|
|
671
|
+
log,
|
|
672
|
+
logPrefix,
|
|
673
|
+
route,
|
|
674
|
+
inboundCtx,
|
|
675
|
+
params,
|
|
676
|
+
currentIdentity,
|
|
677
|
+
streamClient: wsHandle.streamClient,
|
|
548
678
|
});
|
|
549
|
-
|
|
550
|
-
// 3. AI 推理完成,发送 END,结束本次流式占位语义
|
|
551
|
-
// 一旦发送 END,就不再补发「请求超时」消息,因此这里无条件清理定时器。
|
|
552
|
-
clearTimeout(firstReplyTimeout);
|
|
553
|
-
await wsHandle.streamClient.end(msgId, "stop");
|
|
554
|
-
log.info(`${logPrefix} [stream] END sent: msgId=${msgId}`);
|
|
555
|
-
|
|
556
|
-
log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}`);
|
|
557
679
|
} else {
|
|
558
680
|
// 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
|
|
559
681
|
log.debug?.(`${logPrefix} Not mentioned in group, quietly memorized the message context.`);
|
package/src/connection.ts
CHANGED
|
@@ -44,6 +44,9 @@ export interface ImStreamClient {
|
|
|
44
44
|
/** 心跳间隔(ms):每 20 秒发一次 ping,防止连接被后台剔除 */
|
|
45
45
|
const PING_INTERVAL_MS = 20_000;
|
|
46
46
|
|
|
47
|
+
/** 心跳日志间隔(ms):每 10 分钟输出一次 info 日志,避免刷屏 */
|
|
48
|
+
const PING_LOG_INTERVAL_MS = 600_000;
|
|
49
|
+
|
|
47
50
|
/** 计算指数退避延迟(带随机抖动) */
|
|
48
51
|
function calcDelay(
|
|
49
52
|
attempt: number,
|
|
@@ -107,9 +110,10 @@ export function startWebSocket(
|
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
/** 启动心跳:每
|
|
113
|
+
/** 启动心跳:每 20s 发一次 ping,info 日志每 10 分钟输出一次 */
|
|
111
114
|
function startHeartbeat(socket: WebSocket): void {
|
|
112
115
|
clearHeartbeat();
|
|
116
|
+
let lastLogTime = 0;
|
|
113
117
|
|
|
114
118
|
pingTimer = setInterval(() => {
|
|
115
119
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
@@ -117,8 +121,12 @@ export function startWebSocket(
|
|
|
117
121
|
return;
|
|
118
122
|
}
|
|
119
123
|
|
|
120
|
-
const now =
|
|
121
|
-
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const shouldLog = now - lastLogTime >= PING_LOG_INTERVAL_MS;
|
|
126
|
+
if (shouldLog) {
|
|
127
|
+
lastLogTime = now;
|
|
128
|
+
log.info(`${logPrefix} ♥ Ping sent at ${new Date().toISOString()}`);
|
|
129
|
+
}
|
|
122
130
|
|
|
123
131
|
// 发送标准 WebSocket ping 帧
|
|
124
132
|
socket.ping((err: Error | null) => {
|