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
package/dist/channels/aun.js
CHANGED
|
@@ -1,21 +1,62 @@
|
|
|
1
|
-
import { AUNClient } from '@eleans/aun-core-
|
|
1
|
+
import { AUNClient, GatewayDiscovery } from '@eleans/aun-core-sdk';
|
|
2
|
+
import crypto from 'crypto';
|
|
2
3
|
import fs from 'fs';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
import { logger, localTimestamp } from '../utils/logger.js';
|
|
5
|
-
import { normalizeChannelInstances } from '../config.js';
|
|
6
|
+
import { normalizeChannelInstances, getChannelShowActivities } from '../config.js';
|
|
6
7
|
import { resolvePaths } from '../paths.js';
|
|
8
|
+
import { saveToUploads, sanitizeFileName } from '../utils/media-cache.js';
|
|
9
|
+
function guessMime(filename) {
|
|
10
|
+
const ext = path.extname(filename).toLowerCase();
|
|
11
|
+
const map = {
|
|
12
|
+
'.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json',
|
|
13
|
+
'.js': 'text/javascript', '.ts': 'text/typescript', '.py': 'text/x-python',
|
|
14
|
+
'.html': 'text/html', '.css': 'text/css', '.csv': 'text/csv',
|
|
15
|
+
'.pdf': 'application/pdf', '.zip': 'application/zip', '.gz': 'application/gzip',
|
|
16
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
17
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
18
|
+
'.xml': 'application/xml', '.yaml': 'application/x-yaml', '.yml': 'application/x-yaml',
|
|
19
|
+
};
|
|
20
|
+
return map[ext] || 'application/octet-stream';
|
|
21
|
+
}
|
|
22
|
+
function formatSize(bytes) {
|
|
23
|
+
if (bytes < 1024)
|
|
24
|
+
return `${bytes} B`;
|
|
25
|
+
if (bytes < 1048576)
|
|
26
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
27
|
+
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
28
|
+
}
|
|
7
29
|
export class AUNChannel {
|
|
8
30
|
config;
|
|
9
31
|
client = null;
|
|
32
|
+
projectPathProvider;
|
|
10
33
|
messageHandler;
|
|
34
|
+
recallHandler;
|
|
11
35
|
connected = false;
|
|
12
36
|
traceStream = null;
|
|
37
|
+
traceDate = ''; // 当前 trace 文件对应的日期 (YYYYMMDD)
|
|
13
38
|
trace(dir, event, data) {
|
|
39
|
+
if (!this.config.aunTrace)
|
|
40
|
+
return;
|
|
41
|
+
this.rotateTraceIfNeeded();
|
|
14
42
|
if (!this.traceStream)
|
|
15
43
|
return;
|
|
16
44
|
const line = JSON.stringify({ ts: localTimestamp(), dir, event, data });
|
|
17
45
|
this.traceStream.write(line + '\n');
|
|
18
46
|
}
|
|
47
|
+
rotateTraceIfNeeded() {
|
|
48
|
+
const d = new Date();
|
|
49
|
+
const today = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
|
50
|
+
if (this.traceDate === today && this.traceStream)
|
|
51
|
+
return;
|
|
52
|
+
if (this.traceStream) {
|
|
53
|
+
this.traceStream.end();
|
|
54
|
+
this.traceStream = null;
|
|
55
|
+
}
|
|
56
|
+
this.traceDate = today;
|
|
57
|
+
const logPath = path.join(resolvePaths().logs, `aun-${today}.log`);
|
|
58
|
+
this.traceStream = fs.createWriteStream(logPath, { flags: 'a' });
|
|
59
|
+
}
|
|
19
60
|
/** 判断 channelId 是否为群组 ID(g-xxx.agentid.pub 或 grp_ 前缀) */
|
|
20
61
|
isGroupId(id) {
|
|
21
62
|
return id.startsWith('grp_') || (id.startsWith('g-') && id.includes('.'));
|
|
@@ -43,14 +84,17 @@ export class AUNChannel {
|
|
|
43
84
|
const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
44
85
|
return new RegExp(`(^|\\s)@${escaped}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`).test(text);
|
|
45
86
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return
|
|
87
|
+
stripSelfMentionIfOnly(text, selfAid) {
|
|
88
|
+
if (!selfAid)
|
|
89
|
+
return text;
|
|
90
|
+
const mentions = text.match(/@[\w.-]+/g) || [];
|
|
91
|
+
if (mentions.length !== 1)
|
|
92
|
+
return text;
|
|
93
|
+
const escapedAid = selfAid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
94
|
+
return text
|
|
95
|
+
.replace(new RegExp(`(^|\\s)@${escapedAid}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`, 'g'), '$1')
|
|
96
|
+
.replace(/[ \t]+/g, ' ')
|
|
97
|
+
.trim();
|
|
54
98
|
}
|
|
55
99
|
buildGroupReplyContext(taskId, senderAid) {
|
|
56
100
|
const replyContext = {};
|
|
@@ -69,7 +113,9 @@ export class AUNChannel {
|
|
|
69
113
|
this.messageSeqMap.delete(messageId);
|
|
70
114
|
}
|
|
71
115
|
_aid;
|
|
116
|
+
_chatId = ''; // aid:device_id:slot_id — 多实例回声过滤
|
|
72
117
|
seenMessages = new Map();
|
|
118
|
+
peerInfoCache = new Map();
|
|
73
119
|
messageSeqMap = new Map(); // messageId → seq (for ack)
|
|
74
120
|
sentCount = new Map(); // channelId → 已发消息计数(用于判断最终回复)
|
|
75
121
|
// Reconnect state (TS-layer fallback, on top of SDK auto_reconnect)
|
|
@@ -78,12 +124,17 @@ export class AUNChannel {
|
|
|
78
124
|
reconnectTimer = null;
|
|
79
125
|
static RECONNECT_DELAYS = [60, 120, 300, 600]; // seconds
|
|
80
126
|
onChannelDown;
|
|
127
|
+
// SDK reconnect throttling — avoid log spam when SDK enters tight reconnect loop
|
|
128
|
+
lastReconnectLogTime = 0;
|
|
129
|
+
lastReconnectLogAttempt = 0;
|
|
130
|
+
static RECONNECT_LOG_INTERVAL = 60_000; // log at most every 60s
|
|
131
|
+
static RECONNECT_LOG_STEP = 100; // or every 100 attempts
|
|
132
|
+
static SDK_RECONNECT_GIVEUP = 50; // force TS-layer fallback after this many SDK attempts
|
|
81
133
|
constructor(config) {
|
|
82
134
|
this.config = config;
|
|
83
135
|
if (config.aunTrace) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
logger.info(`[AUN] Trace logging enabled: ${logPath}`);
|
|
136
|
+
this.rotateTraceIfNeeded();
|
|
137
|
+
logger.info(`[AUN] Trace logging enabled (daily rotation): ${resolvePaths().logs}/aun-YYYYMMDD.log`);
|
|
87
138
|
}
|
|
88
139
|
}
|
|
89
140
|
async connect() {
|
|
@@ -104,18 +155,22 @@ export class AUNChannel {
|
|
|
104
155
|
const aunPath = this.config.keystorePath || `${process.env.HOME || '~'}/.aun`;
|
|
105
156
|
const aidName = this.config.aid;
|
|
106
157
|
const encryptionSeed = this.config.encryptionSeed || process.env.AUN_ENCRYPTION_SEED || undefined;
|
|
107
|
-
// Gateway URL
|
|
158
|
+
// Gateway URL 解析:优先用配置的 gatewayUrl,否则通过 well-known 自动发现
|
|
108
159
|
let gateway = this.config.gatewayUrl || '';
|
|
109
160
|
if (!gateway) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
gateway =
|
|
161
|
+
// AID 本身即域名(如 evolai.agentid.pub),用其查询 well-known,与 Python SDK 行为对齐
|
|
162
|
+
const wellKnownUrl = `https://${aidName}/.well-known/aun-gateway`;
|
|
163
|
+
try {
|
|
164
|
+
const discovery = new GatewayDiscovery({});
|
|
165
|
+
gateway = await discovery.discover(wellKnownUrl);
|
|
166
|
+
logger.info(`[AUN] Gateway discovered: ${gateway}`);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
logger.warn(`[AUN] Well-known discovery failed (${e}), no fallback available`);
|
|
115
170
|
}
|
|
116
171
|
}
|
|
117
172
|
if (!gateway) {
|
|
118
|
-
logger.error('[AUN] Cannot
|
|
173
|
+
logger.error('[AUN] Cannot resolve gateway URL from AID');
|
|
119
174
|
return;
|
|
120
175
|
}
|
|
121
176
|
logger.info(`[AUN] Initializing: aid=${aidName}, gateway=${gateway}, aun_path=${aunPath}`);
|
|
@@ -145,9 +200,23 @@ export class AUNChannel {
|
|
|
145
200
|
this.handleIncomingGroupMessage(data);
|
|
146
201
|
});
|
|
147
202
|
this.client.on('connection.state', (data) => {
|
|
148
|
-
|
|
203
|
+
// trace is handled inside handleConnectionState with throttling
|
|
149
204
|
this.handleConnectionState(data);
|
|
150
205
|
});
|
|
206
|
+
this.client.on('message.recalled', (data) => {
|
|
207
|
+
this.trace('IN', 'message.recalled', data);
|
|
208
|
+
if (data && typeof data === 'object') {
|
|
209
|
+
const ids = data.message_ids;
|
|
210
|
+
if (Array.isArray(ids)) {
|
|
211
|
+
for (const id of ids) {
|
|
212
|
+
if (typeof id === 'string') {
|
|
213
|
+
logger.info(`[AUN] Message recalled: ${id}`);
|
|
214
|
+
this.recallHandler?.(id);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
151
220
|
// Authenticate
|
|
152
221
|
// Workaround: SDK 0.3.x _loadIdentityOrRaise doesn't set identity.aid from requested aid,
|
|
153
222
|
// causing gateway "missing aid" error. Patch to backfill aid on loaded identity.
|
|
@@ -190,6 +259,8 @@ export class AUNChannel {
|
|
|
190
259
|
try {
|
|
191
260
|
await this.client.connect({ access_token: accessToken, gateway: this.client._gatewayUrl }, { auto_reconnect: true, retry: { max_attempts: 5, initial_delay: 1.0, max_delay: 30.0 } });
|
|
192
261
|
this._aid = this.client.aid ?? undefined;
|
|
262
|
+
const deviceId = this.client._device_id ?? '';
|
|
263
|
+
this._chatId = this._aid ? `${this._aid}:${deviceId}:` : '';
|
|
193
264
|
this.connected = true;
|
|
194
265
|
this.reconnectAttempt = 0;
|
|
195
266
|
// Workaround: SDK e2ee uses _identity.cert for sender_cert_fingerprint;
|
|
@@ -212,6 +283,64 @@ export class AUNChannel {
|
|
|
212
283
|
}
|
|
213
284
|
}
|
|
214
285
|
// ── Event handlers ──────────────────────────────────────────
|
|
286
|
+
async downloadAttachment(att, channelId) {
|
|
287
|
+
const ownerAid = att.owner_aid || this._aid || '';
|
|
288
|
+
const objectKey = att.object_key;
|
|
289
|
+
const filename = att.filename || objectKey.split('/').pop() || 'unknown';
|
|
290
|
+
if (!objectKey) {
|
|
291
|
+
logger.warn('[AUN] Attachment missing object_key, skipping');
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
let downloadUrl;
|
|
295
|
+
try {
|
|
296
|
+
const ticket = await this.client.call('storage.create_download_ticket', {
|
|
297
|
+
owner_aid: ownerAid,
|
|
298
|
+
object_key: objectKey,
|
|
299
|
+
});
|
|
300
|
+
downloadUrl = ticket.download_url || '';
|
|
301
|
+
if (!downloadUrl) {
|
|
302
|
+
logger.warn(`[AUN] No download_url for attachment: ${filename}`);
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
logger.warn(`[AUN] create_download_ticket failed for ${filename}: ${e}`);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
let buffer;
|
|
311
|
+
try {
|
|
312
|
+
const res = await fetch(downloadUrl);
|
|
313
|
+
if (!res.ok) {
|
|
314
|
+
logger.warn(`[AUN] Download failed for ${filename}: HTTP ${res.status}`);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
buffer = Buffer.from(await res.arrayBuffer());
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
logger.warn(`[AUN] Download error for ${filename}: ${e}`);
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
if (att.sha256) {
|
|
324
|
+
const { createHash } = await import('node:crypto');
|
|
325
|
+
const actual = createHash('sha256').update(buffer).digest('hex');
|
|
326
|
+
if (actual !== att.sha256) {
|
|
327
|
+
logger.warn(`[AUN] SHA256 mismatch for ${filename}: expected ${att.sha256.slice(0, 8)}… got ${actual.slice(0, 8)}…`);
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const projectPath = this.projectPathProvider
|
|
332
|
+
? await this.projectPathProvider(channelId)
|
|
333
|
+
: process.cwd();
|
|
334
|
+
try {
|
|
335
|
+
const result = saveToUploads(buffer, filename, projectPath);
|
|
336
|
+
logger.info(`[AUN] Saved attachment: ${result.filePath} (${result.size} bytes)`);
|
|
337
|
+
return result.filePath;
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
logger.warn(`[AUN] saveToUploads failed for ${filename}: ${e}`);
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
215
344
|
async handleIncomingPrivateMessage(data) {
|
|
216
345
|
if (!data || typeof data !== 'object')
|
|
217
346
|
return;
|
|
@@ -219,23 +348,62 @@ export class AUNChannel {
|
|
|
219
348
|
const fromAid = msg.from ?? '';
|
|
220
349
|
const payload = msg.payload ?? '';
|
|
221
350
|
const text = this.extractTextPayload(payload);
|
|
222
|
-
const taskId =
|
|
351
|
+
const taskId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
|
|
223
352
|
const messageId = msg.message_id ?? '';
|
|
224
353
|
const seq = msg.seq;
|
|
354
|
+
// 回声过滤:自己发出的消息会被 gateway fanout 回来,
|
|
355
|
+
// 只有 from_aid == self 且 chat_id 不匹配时才丢弃(说明是其它实例发的)
|
|
356
|
+
const msgChatId = typeof payload === 'object' && payload !== null && payload.chat_id;
|
|
357
|
+
if (this._aid && fromAid === this._aid && (!msgChatId || !this._chatId || msgChatId !== this._chatId)) {
|
|
358
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
225
361
|
// Detect @mentions
|
|
226
362
|
const mentions = [];
|
|
227
363
|
if (this._aid && text.includes(`@${this._aid}`)) {
|
|
228
364
|
mentions.push(this._aid);
|
|
229
365
|
}
|
|
366
|
+
// Process attachments
|
|
367
|
+
const rawAttachments = Array.isArray(payload?.attachments)
|
|
368
|
+
? payload.attachments
|
|
369
|
+
: [];
|
|
370
|
+
let finalText = text;
|
|
371
|
+
if (rawAttachments.length > 0 && this.client) {
|
|
372
|
+
const fileParts = [];
|
|
373
|
+
for (const att of rawAttachments) {
|
|
374
|
+
const filePath = await this.downloadAttachment(att, fromAid);
|
|
375
|
+
if (filePath) {
|
|
376
|
+
const name = sanitizeFileName(att.filename || att.object_key?.split('/').pop() || 'file');
|
|
377
|
+
fileParts.push(`[文件: ${name} → ${filePath}]`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (fileParts.length > 0) {
|
|
381
|
+
const parts = [];
|
|
382
|
+
if (text)
|
|
383
|
+
parts.push(text);
|
|
384
|
+
parts.push(...fileParts);
|
|
385
|
+
parts.push('请使用 Read 工具读取文件内容。');
|
|
386
|
+
finalText = parts.join('\n\n');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Extract chat_id from payload for multi-instance routing (falls back to fromAid)
|
|
390
|
+
const chatId = (typeof payload === 'object' && payload !== null && payload.chat_id)
|
|
391
|
+
? String(payload.chat_id)
|
|
392
|
+
: fromAid;
|
|
393
|
+
const peerInfo = await this.fetchPeerInfo(fromAid);
|
|
394
|
+
const shortAid = this.getShortAid(fromAid);
|
|
395
|
+
const displayName = peerInfo.name || shortAid;
|
|
230
396
|
this.dispatchMessage({
|
|
231
|
-
channelId:
|
|
397
|
+
channelId: chatId,
|
|
232
398
|
userId: fromAid,
|
|
233
|
-
text,
|
|
399
|
+
text: finalText,
|
|
234
400
|
chatType: 'private',
|
|
235
401
|
messageId,
|
|
236
402
|
seq,
|
|
237
403
|
taskId,
|
|
238
404
|
mentions,
|
|
405
|
+
peerName: displayName || undefined,
|
|
406
|
+
peerType: peerInfo.type || 'unknown',
|
|
239
407
|
});
|
|
240
408
|
}
|
|
241
409
|
async handleIncomingGroupMessage(data) {
|
|
@@ -243,10 +411,10 @@ export class AUNChannel {
|
|
|
243
411
|
return;
|
|
244
412
|
const msg = data;
|
|
245
413
|
const groupId = msg.group_id ?? '';
|
|
246
|
-
const senderAid = msg.sender_aid ??
|
|
414
|
+
const senderAid = msg.sender_aid ?? '';
|
|
247
415
|
const payload = msg.payload ?? '';
|
|
248
416
|
const text = this.extractTextPayload(payload);
|
|
249
|
-
const taskId =
|
|
417
|
+
const taskId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
|
|
250
418
|
const messageId = msg.message_id ?? '';
|
|
251
419
|
const seq = msg.seq;
|
|
252
420
|
// Extract structured mentions from payload (e.g. payload.mentions: ["evolai.agentid.pub"])
|
|
@@ -270,17 +438,47 @@ export class AUNChannel {
|
|
|
270
438
|
this.acknowledgeImmediately(messageId, seq);
|
|
271
439
|
return;
|
|
272
440
|
}
|
|
273
|
-
const strippedText = this.
|
|
274
|
-
|
|
441
|
+
const strippedText = this.stripSelfMentionIfOnly(text, this._aid);
|
|
442
|
+
// Detect attachments before the empty-text guard
|
|
443
|
+
const rawAttachments = Array.isArray(payload?.attachments)
|
|
444
|
+
? payload.attachments
|
|
445
|
+
: [];
|
|
446
|
+
const hasAttachments = rawAttachments.length > 0;
|
|
447
|
+
// Allow through if there's text OR attachments; both-empty messages are silently dropped
|
|
448
|
+
if (!strippedText && !hasAttachments) {
|
|
275
449
|
this.acknowledgeImmediately(messageId, seq);
|
|
276
450
|
return;
|
|
277
451
|
}
|
|
278
452
|
const mentions = mentionedAll ? ['all'] : (this._aid ? [this._aid] : []);
|
|
453
|
+
// Process attachments
|
|
454
|
+
let finalText = strippedText;
|
|
455
|
+
if (hasAttachments && this.client) {
|
|
456
|
+
const fileParts = [];
|
|
457
|
+
for (const att of rawAttachments) {
|
|
458
|
+
const filePath = await this.downloadAttachment(att, groupId);
|
|
459
|
+
if (filePath) {
|
|
460
|
+
const name = sanitizeFileName(att.filename || att.object_key?.split('/').pop() || 'file');
|
|
461
|
+
fileParts.push(`[文件: ${name} → ${filePath}]`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (fileParts.length > 0) {
|
|
465
|
+
const parts = [];
|
|
466
|
+
if (strippedText)
|
|
467
|
+
parts.push(strippedText);
|
|
468
|
+
parts.push(...fileParts);
|
|
469
|
+
parts.push('请使用 Read 工具读取文件内容。');
|
|
470
|
+
finalText = parts.join('\n\n');
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const peerInfo = await this.fetchPeerInfo(senderAid);
|
|
474
|
+
const shortAid = this.getShortAid(senderAid);
|
|
475
|
+
const displayName = peerInfo.name || shortAid;
|
|
279
476
|
this.dispatchMessage({
|
|
280
477
|
channelId: groupId,
|
|
281
478
|
userId: senderAid,
|
|
282
|
-
peerName:
|
|
283
|
-
|
|
479
|
+
peerName: displayName || undefined,
|
|
480
|
+
peerType: peerInfo.type || 'unknown',
|
|
481
|
+
text: finalText,
|
|
284
482
|
chatType: 'group',
|
|
285
483
|
messageId,
|
|
286
484
|
seq,
|
|
@@ -316,6 +514,7 @@ export class AUNChannel {
|
|
|
316
514
|
chatType: event.chatType,
|
|
317
515
|
peerId: event.userId || event.channelId || '',
|
|
318
516
|
peerName: event.peerName,
|
|
517
|
+
peerType: event.peerType,
|
|
319
518
|
messageId: event.messageId,
|
|
320
519
|
threadId: event.taskId,
|
|
321
520
|
mentions: mentionObjects,
|
|
@@ -331,6 +530,8 @@ export class AUNChannel {
|
|
|
331
530
|
if (state === 'connected') {
|
|
332
531
|
this.connected = true;
|
|
333
532
|
this.reconnectAttempt = 0;
|
|
533
|
+
this.lastReconnectLogTime = 0;
|
|
534
|
+
this.lastReconnectLogAttempt = 0;
|
|
334
535
|
logger.info('[AUN] Connected');
|
|
335
536
|
}
|
|
336
537
|
else if (state === 'disconnected') {
|
|
@@ -338,21 +539,50 @@ export class AUNChannel {
|
|
|
338
539
|
logger.warn(`[AUN] Disconnected: ${data.error ?? 'unknown'}`);
|
|
339
540
|
}
|
|
340
541
|
else if (state === 'reconnecting') {
|
|
341
|
-
|
|
542
|
+
const attempt = data.attempt ?? 0;
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
// Throttled logging: first attempt, every N attempts, or every M seconds
|
|
545
|
+
const isFirst = attempt <= 1;
|
|
546
|
+
const isStep = attempt - this.lastReconnectLogAttempt >= AUNChannel.RECONNECT_LOG_STEP;
|
|
547
|
+
const isInterval = now - this.lastReconnectLogTime >= AUNChannel.RECONNECT_LOG_INTERVAL;
|
|
548
|
+
if (isFirst || isStep || isInterval) {
|
|
549
|
+
const suppressed = attempt - this.lastReconnectLogAttempt - 1;
|
|
550
|
+
const suffix = suppressed > 0 ? `, ${suppressed} suppressed since last log` : '';
|
|
551
|
+
logger.info(`[AUN] SDK reconnecting (attempt ${attempt}${suffix})`);
|
|
552
|
+
this.lastReconnectLogTime = now;
|
|
553
|
+
this.lastReconnectLogAttempt = attempt;
|
|
554
|
+
this.trace('IN', 'connection.state', data);
|
|
555
|
+
}
|
|
556
|
+
// Detect runaway SDK reconnect loop: force disconnect and use TS-layer backoff
|
|
557
|
+
if (attempt >= AUNChannel.SDK_RECONNECT_GIVEUP && !this.intentionalDisconnect) {
|
|
558
|
+
logger.warn(`[AUN] SDK reconnect stuck at attempt ${attempt}, forcing TS-layer reconnect with backoff`);
|
|
559
|
+
this.connected = false;
|
|
560
|
+
if (this.client) {
|
|
561
|
+
this.client.close().catch(() => { });
|
|
562
|
+
this.client = null;
|
|
563
|
+
}
|
|
564
|
+
this.scheduleReconnect();
|
|
565
|
+
}
|
|
342
566
|
}
|
|
343
567
|
else if (state === 'terminal_failed') {
|
|
344
568
|
this.connected = false;
|
|
345
|
-
|
|
346
|
-
|
|
569
|
+
const reason = data.reason ?? '';
|
|
570
|
+
logger.error(`[AUN] Terminal failure: ${data.error ?? 'unknown'}${reason ? ` (${reason})` : ''}`);
|
|
347
571
|
if (!this.intentionalDisconnect) {
|
|
348
572
|
this.scheduleReconnect();
|
|
349
573
|
}
|
|
350
574
|
}
|
|
351
575
|
}
|
|
352
576
|
// ── Public API (same interface as before) ───────────────────
|
|
577
|
+
onProjectPathRequest(provider) {
|
|
578
|
+
this.projectPathProvider = provider;
|
|
579
|
+
}
|
|
353
580
|
onMessage(handler) {
|
|
354
581
|
this.messageHandler = handler;
|
|
355
582
|
}
|
|
583
|
+
onRecall(handler) {
|
|
584
|
+
this.recallHandler = handler;
|
|
585
|
+
}
|
|
356
586
|
async sendMessage(channelId, text, context) {
|
|
357
587
|
if (!this.connected || !this.client) {
|
|
358
588
|
logger.warn('[AUN] Cannot send: not connected');
|
|
@@ -374,9 +604,16 @@ export class AUNChannel {
|
|
|
374
604
|
finalText = `@${context.peerId} ` + finalText;
|
|
375
605
|
}
|
|
376
606
|
}
|
|
377
|
-
const
|
|
607
|
+
const payload = { type: 'text', text: finalText };
|
|
378
608
|
if (context?.threadId)
|
|
379
|
-
|
|
609
|
+
payload.thread_id = context.threadId;
|
|
610
|
+
const params = { payload, encrypt: true };
|
|
611
|
+
// Multi-instance routing: channelId may be "aid:device_id:slot_id"
|
|
612
|
+
const colonIdx = channelId.indexOf(':');
|
|
613
|
+
const targetAid = colonIdx > 0 ? channelId.substring(0, colonIdx) : channelId;
|
|
614
|
+
if (colonIdx > 0) {
|
|
615
|
+
params.payload.chat_id = channelId;
|
|
616
|
+
}
|
|
380
617
|
try {
|
|
381
618
|
if (this.isGroupId(channelId)) {
|
|
382
619
|
params.group_id = channelId;
|
|
@@ -384,7 +621,7 @@ export class AUNChannel {
|
|
|
384
621
|
await this.client.call('group.send', params);
|
|
385
622
|
}
|
|
386
623
|
else {
|
|
387
|
-
params.to =
|
|
624
|
+
params.to = targetAid;
|
|
388
625
|
this.trace('OUT', 'message.send', params);
|
|
389
626
|
await this.client.call('message.send', params);
|
|
390
627
|
}
|
|
@@ -394,6 +631,103 @@ export class AUNChannel {
|
|
|
394
631
|
logger.error(`[AUN] Send failed to ${channelId}: ${e}`);
|
|
395
632
|
}
|
|
396
633
|
}
|
|
634
|
+
async sendFile(channelId, filePath, context) {
|
|
635
|
+
if (!this.connected || !this.client) {
|
|
636
|
+
logger.warn('[AUN] Cannot sendFile: not connected');
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const absPath = path.resolve(filePath);
|
|
640
|
+
if (!fs.existsSync(absPath)) {
|
|
641
|
+
logger.warn(`[AUN] sendFile: file not found: ${absPath}`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const stat = fs.statSync(absPath);
|
|
645
|
+
if (stat.size === 0) {
|
|
646
|
+
logger.warn('[AUN] sendFile: file is empty');
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
650
|
+
logger.warn(`[AUN] sendFile: file too large (${formatSize(stat.size)}, max 10 MB)`);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const filename = path.basename(absPath);
|
|
654
|
+
const fileData = fs.readFileSync(absPath);
|
|
655
|
+
const sha256 = crypto.createHash('sha256').update(fileData).digest('hex');
|
|
656
|
+
const contentType = guessMime(filename);
|
|
657
|
+
const objectKey = `shared/${crypto.randomUUID()}/${filename}`;
|
|
658
|
+
try {
|
|
659
|
+
// Upload to storage
|
|
660
|
+
if (stat.size <= 64 * 1024) {
|
|
661
|
+
// Inline upload for small files (≤64KB)
|
|
662
|
+
await this.client.call('storage.put_object', {
|
|
663
|
+
object_key: objectKey,
|
|
664
|
+
content: fileData.toString('base64'),
|
|
665
|
+
content_type: contentType,
|
|
666
|
+
is_private: false,
|
|
667
|
+
overwrite: true,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
// Ticket upload for large files
|
|
672
|
+
const session = await this.client.call('storage.create_upload_session', {
|
|
673
|
+
object_key: objectKey,
|
|
674
|
+
size_bytes: stat.size,
|
|
675
|
+
content_type: contentType,
|
|
676
|
+
});
|
|
677
|
+
const uploadUrl = session.upload_url;
|
|
678
|
+
if (!uploadUrl)
|
|
679
|
+
throw new Error('No upload_url in session response');
|
|
680
|
+
const uploadResp = await fetch(uploadUrl, { method: 'PUT', body: fileData });
|
|
681
|
+
if (!uploadResp.ok)
|
|
682
|
+
throw new Error(`HTTP upload failed: ${uploadResp.status}`);
|
|
683
|
+
await this.client.call('storage.complete_upload', {
|
|
684
|
+
object_key: objectKey,
|
|
685
|
+
sha256,
|
|
686
|
+
content_type: contentType,
|
|
687
|
+
is_private: false,
|
|
688
|
+
size_bytes: stat.size,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
// Send message with attachment
|
|
692
|
+
const attachment = {
|
|
693
|
+
owner_aid: this._aid || '',
|
|
694
|
+
object_key: objectKey,
|
|
695
|
+
filename,
|
|
696
|
+
size_bytes: stat.size,
|
|
697
|
+
sha256,
|
|
698
|
+
content_type: contentType,
|
|
699
|
+
};
|
|
700
|
+
const filePayload = {
|
|
701
|
+
type: 'file',
|
|
702
|
+
text: `📎 ${filename} (${formatSize(stat.size)})`,
|
|
703
|
+
attachments: [attachment],
|
|
704
|
+
};
|
|
705
|
+
if (context?.threadId)
|
|
706
|
+
filePayload.thread_id = context.threadId;
|
|
707
|
+
const params = { payload: filePayload, encrypt: true };
|
|
708
|
+
// Multi-instance routing
|
|
709
|
+
const fileColonIdx = channelId.indexOf(':');
|
|
710
|
+
const fileTargetAid = fileColonIdx > 0 ? channelId.substring(0, fileColonIdx) : channelId;
|
|
711
|
+
if (fileColonIdx > 0) {
|
|
712
|
+
params.payload.chat_id = channelId;
|
|
713
|
+
}
|
|
714
|
+
if (this.isGroupId(channelId)) {
|
|
715
|
+
params.group_id = channelId;
|
|
716
|
+
this.trace('OUT', 'group.send.file', params);
|
|
717
|
+
await this.client.call('group.send', params);
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
params.to = fileTargetAid;
|
|
721
|
+
this.trace('OUT', 'message.send.file', params);
|
|
722
|
+
await this.client.call('message.send', params);
|
|
723
|
+
}
|
|
724
|
+
logger.info(`[AUN] File sent: ${filename} (${formatSize(stat.size)}) → ${channelId}`);
|
|
725
|
+
}
|
|
726
|
+
catch (e) {
|
|
727
|
+
this.trace('OUT', 'sendFile.error', { channelId, filePath, error: String(e) });
|
|
728
|
+
logger.error(`[AUN] sendFile failed for ${channelId}: ${e}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
397
731
|
acknowledge(messageId) {
|
|
398
732
|
// Gateway auto-delivery-ack is sufficient; skip explicit message.ack RPC
|
|
399
733
|
// to avoid duplicate "已送达" at the sender CLI
|
|
@@ -404,18 +738,28 @@ export class AUNChannel {
|
|
|
404
738
|
this.sentCount.delete(channelId); // 新任务开始,重置计数
|
|
405
739
|
if (!this.client || !this.connected)
|
|
406
740
|
return;
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
741
|
+
const eventMap = {
|
|
742
|
+
start: 'task.started',
|
|
743
|
+
done: 'task.completed',
|
|
744
|
+
interrupted: 'task.interrupted',
|
|
745
|
+
error: 'task.error',
|
|
746
|
+
timeout: 'task.timeout',
|
|
412
747
|
};
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
748
|
+
const payload = {
|
|
749
|
+
type: 'event',
|
|
750
|
+
event: eventMap[status] ?? `task.${status}`,
|
|
751
|
+
data: { session_id: sessionId },
|
|
752
|
+
severity: status === 'error' || status === 'timeout' ? 'error' : 'info',
|
|
416
753
|
};
|
|
417
754
|
if (context?.threadId)
|
|
418
|
-
|
|
755
|
+
payload.thread_id = context.threadId;
|
|
756
|
+
const params = { payload, encrypt: true };
|
|
757
|
+
// Multi-instance routing
|
|
758
|
+
const statusColonIdx = channelId.indexOf(':');
|
|
759
|
+
const statusTargetAid = statusColonIdx > 0 ? channelId.substring(0, statusColonIdx) : channelId;
|
|
760
|
+
if (statusColonIdx > 0) {
|
|
761
|
+
payload.chat_id = channelId;
|
|
762
|
+
}
|
|
419
763
|
if (this.isGroupId(channelId)) {
|
|
420
764
|
params.group_id = channelId;
|
|
421
765
|
this.trace('OUT', 'group.send.status', params);
|
|
@@ -424,7 +768,7 @@ export class AUNChannel {
|
|
|
424
768
|
});
|
|
425
769
|
}
|
|
426
770
|
else {
|
|
427
|
-
params.to =
|
|
771
|
+
params.to = statusTargetAid;
|
|
428
772
|
this.trace('OUT', 'message.send.status', params);
|
|
429
773
|
this.client.call('message.send', params).catch(e => {
|
|
430
774
|
logger.debug(`[AUN] Processing status failed: ${e}`);
|
|
@@ -444,8 +788,14 @@ export class AUNChannel {
|
|
|
444
788
|
catch {
|
|
445
789
|
payloadObj = { text: payload };
|
|
446
790
|
}
|
|
791
|
+
// Multi-instance routing
|
|
792
|
+
const customColonIdx = channelId.indexOf(':');
|
|
793
|
+
const customTargetAid = customColonIdx > 0 ? channelId.substring(0, customColonIdx) : channelId;
|
|
794
|
+
if (customColonIdx > 0) {
|
|
795
|
+
payloadObj.chat_id = channelId;
|
|
796
|
+
}
|
|
447
797
|
const sendParams = {
|
|
448
|
-
to:
|
|
798
|
+
to: customTargetAid, payload: payloadObj,
|
|
449
799
|
encrypt: true,
|
|
450
800
|
};
|
|
451
801
|
this.trace('OUT', 'message.send.custom', sendParams);
|
|
@@ -532,6 +882,37 @@ export class AUNChannel {
|
|
|
532
882
|
maxAttempts: AUNChannel.RECONNECT_DELAYS.length,
|
|
533
883
|
};
|
|
534
884
|
}
|
|
885
|
+
async fetchPeerInfo(aid) {
|
|
886
|
+
const cached = this.peerInfoCache.get(aid);
|
|
887
|
+
if (cached !== undefined)
|
|
888
|
+
return cached;
|
|
889
|
+
if (!this.client)
|
|
890
|
+
return { type: null };
|
|
891
|
+
try {
|
|
892
|
+
const md = await this.client.auth.downloadAgentMd(aid);
|
|
893
|
+
const typeMatch = md.match(/^type:\s*["']?(\w+)["']?/m);
|
|
894
|
+
const nameMatch = md.match(/^name:\s*["']?(.+?)["']?\s*$/m);
|
|
895
|
+
const type = typeMatch?.[1] === 'human' ? 'human' : 'ai';
|
|
896
|
+
const name = nameMatch?.[1]?.trim() || undefined;
|
|
897
|
+
const info = { type, name };
|
|
898
|
+
this.peerInfoCache.set(aid, info);
|
|
899
|
+
setTimeout(() => this.peerInfoCache.delete(aid), 30 * 60 * 1000);
|
|
900
|
+
return info;
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
return { type: null }; // no agent.md → unknown
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
async uploadAgentMd(content) {
|
|
907
|
+
if (!this.client)
|
|
908
|
+
throw new Error('not connected');
|
|
909
|
+
await this.client.auth.uploadAgentMd(content);
|
|
910
|
+
}
|
|
911
|
+
async downloadAgentMd(aid) {
|
|
912
|
+
if (!this.client)
|
|
913
|
+
throw new Error('not connected');
|
|
914
|
+
return this.client.auth.downloadAgentMd(aid);
|
|
915
|
+
}
|
|
535
916
|
}
|
|
536
917
|
// Plugin implementation
|
|
537
918
|
export class AUNChannelPlugin {
|
|
@@ -554,7 +935,6 @@ export class AUNChannelPlugin {
|
|
|
554
935
|
const channel = new AUNChannel({
|
|
555
936
|
aid: inst.aid,
|
|
556
937
|
keystorePath: inst.keystorePath,
|
|
557
|
-
gatewayPort: inst.gatewayPort,
|
|
558
938
|
gatewayUrl: inst.gatewayUrl,
|
|
559
939
|
accessToken: inst.accessToken,
|
|
560
940
|
flushDelay: inst.flushDelay,
|
|
@@ -564,19 +944,23 @@ export class AUNChannelPlugin {
|
|
|
564
944
|
const adapter = {
|
|
565
945
|
channelName: inst.name,
|
|
566
946
|
sendText: (id, text, context) => channel.sendMessage(id, text, context),
|
|
947
|
+
sendFile: (id, filePath, context) => channel.sendFile(id, filePath, context),
|
|
567
948
|
acknowledge: (messageId) => { channel.acknowledge(messageId); return Promise.resolve(); },
|
|
568
949
|
sendProcessingStatus: (id, status, sessionId, context) => channel.sendProcessingStatus(id, status, sessionId, context),
|
|
569
950
|
sendCustomPayload: (id, payload) => channel.sendCustomPayload(id, payload),
|
|
951
|
+
uploadAgentMd: (content) => channel.uploadAgentMd(content),
|
|
952
|
+
downloadAgentMd: (aid) => channel.downloadAgentMd(aid),
|
|
953
|
+
_selfAid: () => channel.getStatus().aid,
|
|
570
954
|
};
|
|
571
955
|
const policy = {
|
|
572
|
-
canSwitchProject: (chatType, identity) => identity === 'owner',
|
|
573
|
-
canListProjects: (chatType, identity) => identity === 'owner',
|
|
956
|
+
canSwitchProject: (chatType, identity) => identity === 'owner' || identity === 'admin',
|
|
957
|
+
canListProjects: (chatType, identity) => identity === 'owner' || identity === 'admin',
|
|
574
958
|
canCreateSession: (chatType, identity) => true,
|
|
575
959
|
canDeleteSession: (chatType, identity) => true,
|
|
576
|
-
canImportCliSession: (chatType, identity) => identity === 'owner',
|
|
960
|
+
canImportCliSession: (chatType, identity) => identity === 'owner' || identity === 'admin',
|
|
577
961
|
messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
|
|
578
962
|
showMiddleResult: (chatType, identity) => {
|
|
579
|
-
const mode = inst.
|
|
963
|
+
const mode = getChannelShowActivities(config, inst.name);
|
|
580
964
|
if (mode === 'none')
|
|
581
965
|
return false;
|
|
582
966
|
if (mode === 'dm-only')
|
|
@@ -586,7 +970,7 @@ export class AUNChannelPlugin {
|
|
|
586
970
|
return true;
|
|
587
971
|
},
|
|
588
972
|
showIdleMonitor: (chatType, identity) => {
|
|
589
|
-
const mode = inst.
|
|
973
|
+
const mode = getChannelShowActivities(config, inst.name);
|
|
590
974
|
if (mode === 'none')
|
|
591
975
|
return false;
|
|
592
976
|
if (mode === 'dm-only')
|
|
@@ -609,6 +993,7 @@ export class AUNChannelPlugin {
|
|
|
609
993
|
options,
|
|
610
994
|
connect: () => channel.connect(),
|
|
611
995
|
disconnect: () => channel.disconnect(),
|
|
996
|
+
onProjectPathRequest: (channelId) => Promise.resolve(config.projects?.defaultPath || process.cwd()),
|
|
612
997
|
});
|
|
613
998
|
}
|
|
614
999
|
return result;
|