evolclaw 2.4.0 → 2.5.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 +33 -14
- package/dist/agents/claude-runner.js +224 -23
- package/dist/agents/codex-runner.js +2 -8
- package/dist/agents/gemini-runner.js +1 -8
- package/dist/channels/aun.js +438 -53
- package/dist/channels/dingtalk.js +506 -0
- package/dist/channels/feishu.js +31 -231
- package/dist/channels/qqbot.js +391 -0
- package/dist/channels/wechat.js +36 -38
- package/dist/channels/wecom.js +549 -0
- package/dist/cli.js +69 -9
- package/dist/config.js +98 -2
- package/dist/core/command-handler.js +462 -112
- package/dist/core/message/message-bridge.js +8 -5
- package/dist/core/message/message-processor.js +146 -54
- package/dist/core/message/message-queue.js +48 -0
- package/dist/core/message/stream-flusher.js +2 -2
- package/dist/core/session/session-manager.js +21 -3
- package/dist/index.js +48 -13
- package/dist/ipc.js +14 -4
- package/dist/templates/skills.md +64 -0
- package/dist/utils/error-dict.js +63 -0
- package/dist/utils/error-utils.js +156 -56
- package/dist/utils/format.js +32 -0
- package/dist/utils/init-channel.js +734 -8
- package/dist/utils/init.js +33 -2
- package/dist/utils/media-cache.js +2 -0
- package/dist/utils/stats-collector.js +0 -8
- package/package.json +9 -3
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { logger } from '../utils/logger.js';
|
|
2
|
+
import { markdownToPlainText } from '../utils/format.js';
|
|
3
|
+
import { normalizeChannelInstances, getChannelShowActivities } from '../config.js';
|
|
4
|
+
// ── QQBotChannel ────────────────────────────────────────────────────────────
|
|
5
|
+
export class QQBotChannel {
|
|
6
|
+
config;
|
|
7
|
+
client = null;
|
|
8
|
+
connected = false;
|
|
9
|
+
messageHandler = null;
|
|
10
|
+
recallHandler;
|
|
11
|
+
seenMessages = new Map();
|
|
12
|
+
chatTypeCache = new Map();
|
|
13
|
+
msgIdCache = new Map();
|
|
14
|
+
groupOpenidCache = new Map();
|
|
15
|
+
markdownFailed = false;
|
|
16
|
+
cleanupInterval = null;
|
|
17
|
+
projectPathProvider = null;
|
|
18
|
+
constructor(config) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
}
|
|
21
|
+
// ── Public helpers (testable) ──────────────────────────────────────────────
|
|
22
|
+
isDuplicate(msgId) {
|
|
23
|
+
if (this.seenMessages.has(msgId))
|
|
24
|
+
return true;
|
|
25
|
+
this.seenMessages.set(msgId, Date.now());
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
resolveChatId(event) {
|
|
29
|
+
return event.type === 'group' && event.groupOpenid ? event.groupOpenid : event.senderId;
|
|
30
|
+
}
|
|
31
|
+
shouldProcess(type) {
|
|
32
|
+
return type === 'c2c' || type === 'group';
|
|
33
|
+
}
|
|
34
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
35
|
+
async connect() {
|
|
36
|
+
const { appId, clientSecret } = this.config;
|
|
37
|
+
if (!appId || !clientSecret || appId.includes('your-') || clientSecret.includes('your-')) {
|
|
38
|
+
throw new Error('QQBot appId/clientSecret not configured');
|
|
39
|
+
}
|
|
40
|
+
const { QQBotClient } = await import('pure-qqbot');
|
|
41
|
+
this.client = new QQBotClient({
|
|
42
|
+
appId,
|
|
43
|
+
clientSecret,
|
|
44
|
+
typingKeepAlive: true,
|
|
45
|
+
logger: {
|
|
46
|
+
info: (msg) => logger.debug(`[QQBot/SDK] ${msg}`),
|
|
47
|
+
error: (msg) => logger.error(`[QQBot/SDK] ${msg}`),
|
|
48
|
+
debug: (msg) => logger.debug(`[QQBot/SDK] ${msg}`),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
this.client.onMessage(async (event) => {
|
|
52
|
+
await this.handleIncoming(event);
|
|
53
|
+
});
|
|
54
|
+
await this.client.start();
|
|
55
|
+
this.client.startBackgroundRefresh();
|
|
56
|
+
this.connected = true;
|
|
57
|
+
// Hourly cleanup of old dedup entries
|
|
58
|
+
this.cleanupInterval = setInterval(() => {
|
|
59
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
60
|
+
for (const [id, ts] of this.seenMessages) {
|
|
61
|
+
if (ts < cutoff)
|
|
62
|
+
this.seenMessages.delete(id);
|
|
63
|
+
}
|
|
64
|
+
}, 60 * 60 * 1000);
|
|
65
|
+
logger.info('[QQBot] Connected via WebSocket Gateway v2');
|
|
66
|
+
}
|
|
67
|
+
async disconnect() {
|
|
68
|
+
this.connected = false;
|
|
69
|
+
if (this.cleanupInterval) {
|
|
70
|
+
clearInterval(this.cleanupInterval);
|
|
71
|
+
this.cleanupInterval = null;
|
|
72
|
+
}
|
|
73
|
+
if (this.client) {
|
|
74
|
+
try {
|
|
75
|
+
this.client.stopBackgroundRefresh();
|
|
76
|
+
this.client.stop();
|
|
77
|
+
}
|
|
78
|
+
catch { /* ignore */ }
|
|
79
|
+
this.client = null;
|
|
80
|
+
}
|
|
81
|
+
logger.info('[QQBot] Disconnected');
|
|
82
|
+
}
|
|
83
|
+
onMessage(handler) {
|
|
84
|
+
this.messageHandler = handler;
|
|
85
|
+
}
|
|
86
|
+
onRecall(handler) {
|
|
87
|
+
this.recallHandler = handler;
|
|
88
|
+
}
|
|
89
|
+
// ── Inbound message handling ───────────────────────────────────────────────
|
|
90
|
+
async handleIncoming(event) {
|
|
91
|
+
try {
|
|
92
|
+
// Filter: only c2c and group
|
|
93
|
+
if (!this.shouldProcess(event.type))
|
|
94
|
+
return;
|
|
95
|
+
// Dedup
|
|
96
|
+
if (event.messageId && this.isDuplicate(event.messageId)) {
|
|
97
|
+
logger.debug(`[QQBot] Duplicate message skipped: ${event.messageId}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const chatId = this.resolveChatId(event);
|
|
101
|
+
const chatType = event.type === 'group' ? 'group' : 'private';
|
|
102
|
+
// Cache for outbound routing
|
|
103
|
+
this.chatTypeCache.set(chatId, chatType);
|
|
104
|
+
this.msgIdCache.set(chatId, event.messageId);
|
|
105
|
+
if (event.groupOpenid)
|
|
106
|
+
this.groupOpenidCache.set(chatId, event.groupOpenid);
|
|
107
|
+
if (!this.messageHandler)
|
|
108
|
+
return;
|
|
109
|
+
// Check for attachments (images/files)
|
|
110
|
+
const attachments = event.attachments || [];
|
|
111
|
+
const imageAttachments = attachments.filter(a => a.content_type?.startsWith('image/'));
|
|
112
|
+
const fileAttachments = attachments.filter(a => !a.content_type?.startsWith('image/'));
|
|
113
|
+
if (imageAttachments.length > 0) {
|
|
114
|
+
await this.handleImageAttachments(imageAttachments, event, chatId, chatType);
|
|
115
|
+
}
|
|
116
|
+
else if (fileAttachments.length > 0) {
|
|
117
|
+
await this.handleFileAttachments(fileAttachments, event, chatId, chatType);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Pure text
|
|
121
|
+
const text = (event.content || '').trim();
|
|
122
|
+
if (!text)
|
|
123
|
+
return;
|
|
124
|
+
await this.messageHandler({
|
|
125
|
+
channelId: chatId, content: text, chatType,
|
|
126
|
+
peerId: event.senderId || '', peerName: event.senderName,
|
|
127
|
+
messageId: event.messageId,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
logger.error('[QQBot] Failed to process incoming message:', error);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// ── Inbound media handling ─────────────────────────────────────────────────
|
|
136
|
+
async handleImageAttachments(attachments, event, chatId, chatType) {
|
|
137
|
+
const images = [];
|
|
138
|
+
for (const att of attachments) {
|
|
139
|
+
if (!att.url)
|
|
140
|
+
continue;
|
|
141
|
+
try {
|
|
142
|
+
const { safeFetch, validateImage } = await import('../utils/media-cache.js');
|
|
143
|
+
const buffer = await safeFetch(att.url, { skipSsrfCheck: true });
|
|
144
|
+
const result = await validateImage(buffer);
|
|
145
|
+
if (result.mime) {
|
|
146
|
+
images.push({ data: buffer.toString('base64'), mimeType: result.mime });
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
logger.warn(`[QQBot] Image validation failed: ${'reason' in result ? result.reason : 'unknown'}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
logger.error('[QQBot] Failed to download image:', error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const text = (event.content || '').trim();
|
|
157
|
+
const prompt = text || (images.length > 0 ? '用户发送了一张图片,请分析这张图片的内容。' : '[空消息]');
|
|
158
|
+
await this.messageHandler({
|
|
159
|
+
channelId: chatId, content: prompt, chatType,
|
|
160
|
+
peerId: event.senderId || '', peerName: event.senderName,
|
|
161
|
+
messageId: event.messageId,
|
|
162
|
+
images: images.length > 0 ? images : undefined,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async handleFileAttachments(attachments, event, chatId, chatType) {
|
|
166
|
+
for (const att of attachments) {
|
|
167
|
+
if (!att.url)
|
|
168
|
+
continue;
|
|
169
|
+
const fileName = att.filename || 'unknown';
|
|
170
|
+
try {
|
|
171
|
+
const { safeFetch, saveToUploads, sanitizeFileName } = await import('../utils/media-cache.js');
|
|
172
|
+
const projectPath = this.projectPathProvider
|
|
173
|
+
? await this.projectPathProvider(chatId)
|
|
174
|
+
: process.cwd();
|
|
175
|
+
const buffer = await safeFetch(att.url, { skipSsrfCheck: true });
|
|
176
|
+
const { filePath } = saveToUploads(buffer, sanitizeFileName(fileName), projectPath);
|
|
177
|
+
await this.messageHandler({
|
|
178
|
+
channelId: chatId,
|
|
179
|
+
content: `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`,
|
|
180
|
+
chatType, peerId: event.senderId || '', peerName: event.senderName,
|
|
181
|
+
messageId: event.messageId,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
logger.error('[QQBot] Failed to download file:', error);
|
|
186
|
+
await this.messageHandler({
|
|
187
|
+
channelId: chatId, content: `[文件下载失败] ${fileName}`,
|
|
188
|
+
chatType, peerId: event.senderId || '', peerName: event.senderName,
|
|
189
|
+
messageId: event.messageId,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ── Outbound: text (markdown with fallback) ────────────────────────────────
|
|
195
|
+
async sendMessage(chatId, content) {
|
|
196
|
+
if (!this.client)
|
|
197
|
+
return;
|
|
198
|
+
const chatType = this.chatTypeCache.get(chatId);
|
|
199
|
+
const msgId = this.msgIdCache.get(chatId);
|
|
200
|
+
// Try Markdown first, fallback to plain text
|
|
201
|
+
if (!this.markdownFailed) {
|
|
202
|
+
try {
|
|
203
|
+
if (chatType === 'group') {
|
|
204
|
+
const groupOpenid = this.groupOpenidCache.get(chatId) || chatId;
|
|
205
|
+
await this.client.sendGroupMessage(groupOpenid, content, msgId);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
await this.client.sendPrivateMarkdown(chatId, content, msgId);
|
|
209
|
+
}
|
|
210
|
+
return; // success
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
const errMsg = String(error?.message || error);
|
|
214
|
+
// Check if this is a markdown permission error
|
|
215
|
+
if (errMsg.includes('not support') || errMsg.includes('permission') ||
|
|
216
|
+
errMsg.includes('markdown') || error?.code === 304003 || error?.code === 304004) {
|
|
217
|
+
logger.warn('[QQBot] Markdown not supported, falling back to plain text globally');
|
|
218
|
+
this.markdownFailed = true;
|
|
219
|
+
// Fall through to plain text below
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// Other error — log and return, don't fallback
|
|
223
|
+
logger.error(`[QQBot] sendMessage failed for ${chatId}:`, errMsg);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Plain text fallback
|
|
229
|
+
try {
|
|
230
|
+
const plainText = markdownToPlainText(content);
|
|
231
|
+
if (chatType === 'group') {
|
|
232
|
+
const groupOpenid = this.groupOpenidCache.get(chatId) || chatId;
|
|
233
|
+
await this.client.sendGroupMessage(groupOpenid, plainText, msgId);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
await this.client.sendPrivateMessage(chatId, plainText, msgId);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
logger.error(`[QQBot] sendMessage (plaintext) failed for ${chatId}:`, error?.message || error);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// ── Outbound: image ────────────────────────────────────────────────────────
|
|
244
|
+
async sendImage(chatId, png) {
|
|
245
|
+
if (!this.client)
|
|
246
|
+
return;
|
|
247
|
+
try {
|
|
248
|
+
const fs = await import('fs');
|
|
249
|
+
const path = await import('path');
|
|
250
|
+
const os = await import('os');
|
|
251
|
+
const tmpPath = path.join(os.tmpdir(), `evolclaw-qqbot-${Date.now()}.png`);
|
|
252
|
+
fs.writeFileSync(tmpPath, png);
|
|
253
|
+
const chatType = this.chatTypeCache.get(chatId);
|
|
254
|
+
const msgId = this.msgIdCache.get(chatId);
|
|
255
|
+
if (chatType === 'group') {
|
|
256
|
+
const groupOpenid = this.groupOpenidCache.get(chatId) || chatId;
|
|
257
|
+
await this.client.sendGroupImage(groupOpenid, `file://${tmpPath}`, msgId);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
await this.client.sendPrivateImage(chatId, `file://${tmpPath}`, msgId);
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
fs.unlinkSync(tmpPath);
|
|
264
|
+
}
|
|
265
|
+
catch { /* ignore */ }
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
logger.error(`[QQBot] sendImage failed for ${chatId}:`, error?.message || error);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// ── Outbound: file ─────────────────────────────────────────────────────────
|
|
272
|
+
async sendFile(chatId, filePath) {
|
|
273
|
+
if (!this.client)
|
|
274
|
+
return;
|
|
275
|
+
try {
|
|
276
|
+
const fs = await import('fs');
|
|
277
|
+
const header = Buffer.alloc(12);
|
|
278
|
+
const fd = fs.openSync(filePath, 'r');
|
|
279
|
+
fs.readSync(fd, header, 0, 12, 0);
|
|
280
|
+
fs.closeSync(fd);
|
|
281
|
+
const { fileTypeFromBuffer } = await import('file-type');
|
|
282
|
+
const ftype = await fileTypeFromBuffer(header);
|
|
283
|
+
if (ftype && ftype.mime.startsWith('image/')) {
|
|
284
|
+
const buf = fs.readFileSync(filePath);
|
|
285
|
+
return this.sendImage(chatId, buf);
|
|
286
|
+
}
|
|
287
|
+
const chatType = this.chatTypeCache.get(chatId);
|
|
288
|
+
const msgId = this.msgIdCache.get(chatId);
|
|
289
|
+
if (chatType === 'group') {
|
|
290
|
+
const groupOpenid = this.groupOpenidCache.get(chatId) || chatId;
|
|
291
|
+
await this.client.sendGroupFile(groupOpenid, `file://${filePath}`, msgId);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
await this.client.sendPrivateFile(chatId, `file://${filePath}`, msgId);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
logger.error(`[QQBot] sendFile failed for ${chatId}:`, error?.message || error);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// ── Plugin ─────────────────────────────────────────────────────────────────────
|
|
303
|
+
function isValidCredential(value) {
|
|
304
|
+
return !!value && !value.includes('your-') && !value.includes('placeholder');
|
|
305
|
+
}
|
|
306
|
+
export class QQBotChannelPlugin {
|
|
307
|
+
name = 'qqbot';
|
|
308
|
+
isEnabled(config) {
|
|
309
|
+
const raw = config.channels?.qqbot;
|
|
310
|
+
if (!raw)
|
|
311
|
+
return false;
|
|
312
|
+
if (Array.isArray(raw)) {
|
|
313
|
+
return raw.some(inst => inst.enabled !== false && isValidCredential(inst.appId) && isValidCredential(inst.clientSecret));
|
|
314
|
+
}
|
|
315
|
+
if (raw.enabled === false)
|
|
316
|
+
return false;
|
|
317
|
+
return isValidCredential(raw.appId) && isValidCredential(raw.clientSecret);
|
|
318
|
+
}
|
|
319
|
+
async createChannels(config) {
|
|
320
|
+
const instances = normalizeChannelInstances(config.channels?.qqbot, 'qqbot');
|
|
321
|
+
const result = [];
|
|
322
|
+
for (const inst of instances) {
|
|
323
|
+
if (inst.enabled === false)
|
|
324
|
+
continue;
|
|
325
|
+
if (!isValidCredential(inst.appId) || !isValidCredential(inst.clientSecret))
|
|
326
|
+
continue;
|
|
327
|
+
const channel = new QQBotChannel({
|
|
328
|
+
appId: inst.appId,
|
|
329
|
+
clientSecret: inst.clientSecret,
|
|
330
|
+
});
|
|
331
|
+
const adapter = {
|
|
332
|
+
channelName: inst.name,
|
|
333
|
+
sendText: (id, text) => channel.sendMessage(id, text),
|
|
334
|
+
sendFile: (id, filePath) => channel.sendFile(id, filePath),
|
|
335
|
+
sendImage: (id, png) => channel.sendImage(id, png),
|
|
336
|
+
};
|
|
337
|
+
const policy = {
|
|
338
|
+
canSwitchProject: (_chatType, identity) => identity === 'owner' || identity === 'admin',
|
|
339
|
+
canListProjects: (_chatType, identity) => identity === 'owner' || identity === 'admin',
|
|
340
|
+
canCreateSession: () => true,
|
|
341
|
+
canDeleteSession: () => true,
|
|
342
|
+
canImportCliSession: (_chatType, identity) => identity === 'owner' || identity === 'admin',
|
|
343
|
+
messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
|
|
344
|
+
showMiddleResult: (chatType, identity) => {
|
|
345
|
+
const mode = getChannelShowActivities(config, inst.name);
|
|
346
|
+
if (mode === 'none')
|
|
347
|
+
return false;
|
|
348
|
+
if (mode === 'dm-only')
|
|
349
|
+
return chatType === 'private';
|
|
350
|
+
if (mode === 'owner-dm-only')
|
|
351
|
+
return chatType === 'private' && identity === 'owner';
|
|
352
|
+
return true;
|
|
353
|
+
},
|
|
354
|
+
showIdleMonitor: (chatType, identity) => {
|
|
355
|
+
const mode = getChannelShowActivities(config, inst.name);
|
|
356
|
+
if (mode === 'none')
|
|
357
|
+
return false;
|
|
358
|
+
if (mode === 'dm-only')
|
|
359
|
+
return chatType === 'private';
|
|
360
|
+
if (mode === 'owner-dm-only')
|
|
361
|
+
return chatType === 'private' && identity === 'owner';
|
|
362
|
+
return true;
|
|
363
|
+
},
|
|
364
|
+
accumulateErrors: () => true,
|
|
365
|
+
};
|
|
366
|
+
const options = {
|
|
367
|
+
fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g,
|
|
368
|
+
supportsImages: true,
|
|
369
|
+
flushDelay: inst.flushDelay,
|
|
370
|
+
};
|
|
371
|
+
result.push({
|
|
372
|
+
channelType: 'qqbot',
|
|
373
|
+
adapter,
|
|
374
|
+
channel,
|
|
375
|
+
policy,
|
|
376
|
+
options,
|
|
377
|
+
connect: () => channel.connect(),
|
|
378
|
+
disconnect: () => channel.disconnect(),
|
|
379
|
+
onProjectPathRequest: () => Promise.resolve(config.projects?.defaultPath || process.cwd()),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
async createChannel(config) {
|
|
385
|
+
const instances = await this.createChannels(config);
|
|
386
|
+
if (instances.length === 0) {
|
|
387
|
+
throw new Error('QQBot config missing or invalid');
|
|
388
|
+
}
|
|
389
|
+
return instances[0];
|
|
390
|
+
}
|
|
391
|
+
}
|
package/dist/channels/wechat.js
CHANGED
|
@@ -4,7 +4,11 @@ import path from 'path';
|
|
|
4
4
|
import { resolvePaths } from '../paths.js';
|
|
5
5
|
import { logger } from '../utils/logger.js';
|
|
6
6
|
import { sanitizeFileName, saveToUploads, safeFetch } from '../utils/media-cache.js';
|
|
7
|
+
import { markdownToPlainText } from '../utils/format.js';
|
|
7
8
|
const CHANNEL_VERSION = '1.0.0';
|
|
9
|
+
const ILINK_APP_ID = 'bot';
|
|
10
|
+
// iLink-App-ClientVersion: major<<16 | minor<<8 | patch (uint32)
|
|
11
|
+
const ILINK_APP_CLIENT_VERSION = String((1 << 16) | (0 << 8) | 0); // 1.0.0 = 65536
|
|
8
12
|
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
9
13
|
const DEFAULT_API_TIMEOUT_MS = 15_000;
|
|
10
14
|
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
|
|
@@ -67,38 +71,6 @@ async function downloadMedia(cdnMedia, hexKey) {
|
|
|
67
71
|
return encrypted; // 无 key = 明文
|
|
68
72
|
return decryptAesEcb(encrypted, parseAesKey(aesKeyBase64));
|
|
69
73
|
}
|
|
70
|
-
// ── Markdown → Plain Text ───────────────────────────────────────────────────
|
|
71
|
-
function markdownToPlainText(text) {
|
|
72
|
-
let result = text;
|
|
73
|
-
// Code blocks: strip fences, keep content
|
|
74
|
-
result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code) => code.trim());
|
|
75
|
-
// Images: remove entirely
|
|
76
|
-
result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
|
|
77
|
-
// Links: keep display text only
|
|
78
|
-
result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
|
|
79
|
-
// Tables: remove separator rows
|
|
80
|
-
result = result.replace(/^\|[\s:|-]+\|$/gm, '');
|
|
81
|
-
result = result.replace(/^\|(.+)\|$/gm, (_, inner) => inner.split('|').map(cell => cell.trim()).join(' '));
|
|
82
|
-
// Bold/italic
|
|
83
|
-
result = result.replace(/\*\*(.+?)\*\*/g, '$1');
|
|
84
|
-
result = result.replace(/\*(.+?)\*/g, '$1');
|
|
85
|
-
result = result.replace(/__(.+?)__/g, '$1');
|
|
86
|
-
result = result.replace(/_(.+?)_/g, '$1');
|
|
87
|
-
// Strikethrough
|
|
88
|
-
result = result.replace(/~~(.+?)~~/g, '$1');
|
|
89
|
-
// Inline code
|
|
90
|
-
result = result.replace(/`([^`]+)`/g, '$1');
|
|
91
|
-
// Headers
|
|
92
|
-
result = result.replace(/^#{1,6}\s+/gm, '');
|
|
93
|
-
// Blockquotes
|
|
94
|
-
result = result.replace(/^>\s?/gm, '');
|
|
95
|
-
// Horizontal rules
|
|
96
|
-
result = result.replace(/^[-*_]{3,}$/gm, '');
|
|
97
|
-
// List markers
|
|
98
|
-
result = result.replace(/^(\s*)[-*+]\s/gm, '$1');
|
|
99
|
-
result = result.replace(/^(\s*)\d+\.\s/gm, '$1');
|
|
100
|
-
return result.trim();
|
|
101
|
-
}
|
|
102
74
|
// ── Message Text Extraction ─────────────────────────────────────────────────
|
|
103
75
|
function extractTextFromMessage(msg) {
|
|
104
76
|
if (!msg.item_list?.length)
|
|
@@ -129,6 +101,7 @@ function extractTextFromMessage(msg) {
|
|
|
129
101
|
export class WechatChannel {
|
|
130
102
|
config;
|
|
131
103
|
messageHandler;
|
|
104
|
+
recallHandler;
|
|
132
105
|
abortController;
|
|
133
106
|
connected = false;
|
|
134
107
|
// 内部状态(不外泄到核心层)
|
|
@@ -153,6 +126,9 @@ export class WechatChannel {
|
|
|
153
126
|
onMessage(handler) {
|
|
154
127
|
this.messageHandler = handler;
|
|
155
128
|
}
|
|
129
|
+
onRecall(handler) {
|
|
130
|
+
this.recallHandler = handler;
|
|
131
|
+
}
|
|
156
132
|
/** 注册 session 过期通知回调(用于跨渠道通知用户) */
|
|
157
133
|
onSessionExpiredNotify(handler) {
|
|
158
134
|
this.onSessionExpired = handler;
|
|
@@ -179,6 +155,13 @@ export class WechatChannel {
|
|
|
179
155
|
catch {
|
|
180
156
|
// ignore
|
|
181
157
|
}
|
|
158
|
+
// 通知 ilink 后端:bot 上线
|
|
159
|
+
try {
|
|
160
|
+
await this.notifyLifecycle('notifystart');
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
logger.warn('[WeChat] notifyStart failed during startup (ignored):', err);
|
|
164
|
+
}
|
|
182
165
|
this.abortController = new AbortController();
|
|
183
166
|
// 启动长轮询(不 await,后台运行)
|
|
184
167
|
this.pollLoop(this.abortController.signal).catch(err => {
|
|
@@ -196,6 +179,13 @@ export class WechatChannel {
|
|
|
196
179
|
this.abortController.abort();
|
|
197
180
|
this.abortController = undefined;
|
|
198
181
|
}
|
|
182
|
+
// 通知 ilink 后端:bot 下线
|
|
183
|
+
try {
|
|
184
|
+
await this.notifyLifecycle('notifystop');
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
logger.warn('[WeChat] notifyStop failed during shutdown (ignored):', err);
|
|
188
|
+
}
|
|
199
189
|
logger.info('[WeChat] Channel disconnected');
|
|
200
190
|
}
|
|
201
191
|
/** Get current connection status */
|
|
@@ -638,6 +628,12 @@ export class WechatChannel {
|
|
|
638
628
|
logger.info(`[WeChat] Sent media to ${to}, type=${item.type}`);
|
|
639
629
|
}
|
|
640
630
|
// ── ilink API Helpers ─────────────────────────────────────────────────
|
|
631
|
+
/** Notify ilink backend of bot lifecycle events (start/stop). */
|
|
632
|
+
async notifyLifecycle(action) {
|
|
633
|
+
const body = JSON.stringify({ base_info: { channel_version: CHANNEL_VERSION } });
|
|
634
|
+
await this.apiFetch(`ilink/bot/msg/${action}`, body, DEFAULT_CONFIG_TIMEOUT_MS);
|
|
635
|
+
logger.info(`[WeChat] ${action} succeeded`);
|
|
636
|
+
}
|
|
641
637
|
async apiFetch(endpoint, body, timeoutMs, externalSignal) {
|
|
642
638
|
const base = this.config.baseUrl.endsWith('/') ? this.config.baseUrl : `${this.config.baseUrl}/`;
|
|
643
639
|
const url = new URL(endpoint, base).toString();
|
|
@@ -676,6 +672,8 @@ export class WechatChannel {
|
|
|
676
672
|
'AuthorizationType': 'ilink_bot_token',
|
|
677
673
|
'Content-Length': String(Buffer.byteLength(body, 'utf-8')),
|
|
678
674
|
'X-WECHAT-UIN': wechatUin,
|
|
675
|
+
'iLink-App-Id': ILINK_APP_ID,
|
|
676
|
+
'iLink-App-ClientVersion': ILINK_APP_CLIENT_VERSION,
|
|
679
677
|
};
|
|
680
678
|
if (this.config.token?.trim()) {
|
|
681
679
|
headers['Authorization'] = `Bearer ${this.config.token.trim()}`;
|
|
@@ -707,7 +705,7 @@ export class WechatChannel {
|
|
|
707
705
|
});
|
|
708
706
|
}
|
|
709
707
|
}
|
|
710
|
-
import { normalizeChannelInstances } from '../config.js';
|
|
708
|
+
import { normalizeChannelInstances, getChannelShowActivities } from '../config.js';
|
|
711
709
|
export class WechatChannelPlugin {
|
|
712
710
|
name = 'wechat';
|
|
713
711
|
isEnabled(config) {
|
|
@@ -735,14 +733,14 @@ export class WechatChannelPlugin {
|
|
|
735
733
|
sendFile: (id, filePath) => channel.sendFile(id, filePath),
|
|
736
734
|
};
|
|
737
735
|
const policy = {
|
|
738
|
-
canSwitchProject: (chatType, identity) => identity === 'owner',
|
|
739
|
-
canListProjects: (chatType, identity) => identity === 'owner',
|
|
736
|
+
canSwitchProject: (chatType, identity) => identity === 'owner' || identity === 'admin',
|
|
737
|
+
canListProjects: (chatType, identity) => identity === 'owner' || identity === 'admin',
|
|
740
738
|
canCreateSession: (chatType, identity) => true,
|
|
741
739
|
canDeleteSession: (chatType, identity) => true,
|
|
742
|
-
canImportCliSession: (chatType, identity) => identity === 'owner',
|
|
740
|
+
canImportCliSession: (chatType, identity) => identity === 'owner' || identity === 'admin',
|
|
743
741
|
messagePrefix: (chatType, peerName) => '',
|
|
744
742
|
showMiddleResult: (chatType, identity) => {
|
|
745
|
-
const mode = inst.
|
|
743
|
+
const mode = getChannelShowActivities(config, inst.name);
|
|
746
744
|
if (mode === 'none')
|
|
747
745
|
return false;
|
|
748
746
|
if (mode === 'dm-only')
|
|
@@ -752,7 +750,7 @@ export class WechatChannelPlugin {
|
|
|
752
750
|
return true;
|
|
753
751
|
},
|
|
754
752
|
showIdleMonitor: (chatType, identity) => {
|
|
755
|
-
const mode = inst.
|
|
753
|
+
const mode = getChannelShowActivities(config, inst.name);
|
|
756
754
|
if (mode === 'none')
|
|
757
755
|
return false;
|
|
758
756
|
if (mode === 'dm-only')
|