linco-connect 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.
@@ -0,0 +1,488 @@
1
+ const { WebSocket } = require('ws');
2
+ const { executeAgentQuery, resolvePendingDanger, resolvePendingPermission } = require('./agentRunner');
3
+ const { handleMessageWithAttachments } = require('./attachmentHandler');
4
+ const { startOutboxWatcher } = require('./outgoingAttachmentHandler');
5
+ const { send, sendError, sendSystem } = require('./protocol');
6
+ const { cleanupSession, createSession } = require('./session');
7
+ const { handleSlashCommand } = require('./slashCommands');
8
+ const {
9
+ isLincoMessage,
10
+ mapLocalEventToLinco,
11
+ lincoFilesToAttachments,
12
+ buildStreamId,
13
+ lincoMetaDefaults,
14
+ pruneUndefined,
15
+ } = require('./lincoProtocol');
16
+
17
+ function startImConnector(config) {
18
+ const connectors = startImConnectors(config);
19
+ return connectors[0] || null;
20
+ }
21
+
22
+ function startImConnectors(config) {
23
+ if (!config.im?.enabled) return [];
24
+ const agents = Object.entries(config.agents || { claude: { enabled: true } })
25
+ .filter(([, agent]) => agent?.enabled);
26
+
27
+ const connectors = agents.map(([agentType, agent]) => {
28
+ const connector = new ImConnector(config, agentType, agent);
29
+ connector.start();
30
+ return connector;
31
+ });
32
+
33
+ return connectors;
34
+ }
35
+
36
+ class ImConnector {
37
+ constructor(config, agentType = 'claude', agentConfig = {}) {
38
+ this.config = config;
39
+ this.agentType = agentType;
40
+ this.agentConfig = agentConfig;
41
+ this.sessions = new Map();
42
+ this.ws = null;
43
+ this.stopped = false;
44
+ this.reconnectAttempts = 0;
45
+ this.reconnectTimer = null;
46
+ this.heartbeatTimer = null;
47
+ this.connectTimer = null;
48
+ this.pendingEvents = [];
49
+ }
50
+
51
+ start() {
52
+ if (!this.config.im?.appId || !this.config.im?.appSecret) {
53
+ console.log(`[IM:${this.agentType}] 远端 IM 已启用,但缺少 Linco token,已跳过连接。`);
54
+ return;
55
+ }
56
+
57
+ this.connect();
58
+ }
59
+
60
+ stop() {
61
+ this.stopped = true;
62
+ clearTimeout(this.reconnectTimer);
63
+ clearTimeout(this.connectTimer);
64
+ clearInterval(this.heartbeatTimer);
65
+ this.reconnectTimer = null;
66
+ this.connectTimer = null;
67
+ this.heartbeatTimer = null;
68
+
69
+ if (this.ws) {
70
+ const ws = this.ws;
71
+ if (ws.readyState === WebSocket.OPEN) {
72
+ this.sendPresence('offline', 'shutdown');
73
+ }
74
+ this.ws = null;
75
+ try {
76
+ ws.close();
77
+ } catch {}
78
+ }
79
+
80
+ for (const session of this.sessions.values()) {
81
+ clearInterval(session.remoteIdleTimer);
82
+ this.unregisterSession(session);
83
+ cleanupSession(session);
84
+ }
85
+ this.sessions.clear();
86
+ }
87
+
88
+ connect() {
89
+ if (this.stopped) return;
90
+
91
+ let url;
92
+ try {
93
+ url = this.buildUrl();
94
+ } catch (err) {
95
+ console.log(`[IM:${this.agentType}] 远端 IM 地址无效: ${err.message}`);
96
+ this.scheduleReconnect();
97
+ return;
98
+ }
99
+
100
+ console.log(`[IM:${this.agentType}] 正在连接远端 IM: ${safeUrlForLog(url)}`);
101
+ const ws = new WebSocket(url, {
102
+ maxPayload: this.config.maxWsPayloadBytes,
103
+ });
104
+ this.ws = ws;
105
+
106
+ this.connectTimer = setTimeout(() => {
107
+ if (this.ws === ws && ws.readyState === WebSocket.CONNECTING) {
108
+ ws.terminate();
109
+ }
110
+ }, this.config.im.connectTimeoutMs).unref?.();
111
+
112
+ ws.on('open', () => {
113
+ if (this.ws !== ws) return;
114
+ clearTimeout(this.connectTimer);
115
+ this.connectTimer = null;
116
+ this.reconnectAttempts = 0;
117
+ console.log(`[IM:${this.agentType}] 远端 IM 已连接。`);
118
+ this.sendPresence('online');
119
+ this.flushPendingEvents();
120
+ this.startHeartbeat();
121
+ });
122
+
123
+ ws.on('message', (data) => {
124
+ if (this.ws !== ws) return;
125
+ this.handleRawMessage(data);
126
+ });
127
+
128
+ ws.on('close', () => {
129
+ if (this.ws !== ws) return;
130
+ this.ws = null;
131
+ clearTimeout(this.connectTimer);
132
+ this.connectTimer = null;
133
+ clearInterval(this.heartbeatTimer);
134
+ this.heartbeatTimer = null;
135
+ console.log(`[IM:${this.agentType}] 远端 IM 连接已断开。`);
136
+ this.scheduleReconnect();
137
+ });
138
+
139
+ ws.on('error', (err) => {
140
+ if (this.ws !== ws) return;
141
+ console.log(`[IM:${this.agentType}] 远端 IM 连接错误: ${err.message}`);
142
+ });
143
+ }
144
+
145
+ buildUrl() {
146
+ const url = new URL(this.agentConfig.wsUrl || this.config.im.wsUrl);
147
+ if (url.protocol !== 'wss:' && !this.config.im.allowInsecureWs) {
148
+ throw new Error('远端 IM 默认要求 wss;本地调试可设置 LINCO_IM_ALLOW_INSECURE_WS=1');
149
+ }
150
+ url.searchParams.delete('appId');
151
+ url.searchParams.delete('appSecret');
152
+ url.searchParams.set('token', `${this.config.im.appId}:${this.config.im.appSecret}`);
153
+ return url.toString();
154
+ }
155
+
156
+ startHeartbeat() {
157
+ clearInterval(this.heartbeatTimer);
158
+ this.heartbeatTimer = setInterval(() => {
159
+ if (!this.isOpen()) return;
160
+ this.sendRemote({ type: 'ping', from: this.agentType, ts: Date.now() });
161
+ }, this.config.im.heartbeatMs);
162
+ this.heartbeatTimer.unref?.();
163
+ }
164
+
165
+ scheduleReconnect() {
166
+ if (this.stopped) return;
167
+ clearTimeout(this.reconnectTimer);
168
+
169
+ const min = this.config.im.reconnectMinMs;
170
+ const max = this.config.im.reconnectMaxMs;
171
+ const baseDelay = Math.min(max, min * (2 ** this.reconnectAttempts));
172
+ const jitter = Math.floor(baseDelay * 0.2 * Math.random());
173
+ const delay = Math.min(max, baseDelay + jitter);
174
+ this.reconnectAttempts += 1;
175
+
176
+ console.log(`[IM:${this.agentType}] ${delay}ms 后重连远端 IM。`);
177
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
178
+ this.reconnectTimer.unref?.();
179
+ }
180
+
181
+ handleRawMessage(data) {
182
+ let msg;
183
+ try {
184
+ msg = JSON.parse(data.toString());
185
+ } catch {
186
+ console.log('[IM] 收到无法解析的消息。');
187
+ return;
188
+ }
189
+
190
+ if (!isLincoMessage(msg)) return;
191
+ this.handleMessage(msg);
192
+ }
193
+
194
+ handleMessage(msg) {
195
+ if (msg.type === 'ping') {
196
+ this.sendRemote({ type: 'pong', from: this.agentType, ts: Date.now() });
197
+ return;
198
+ }
199
+
200
+ if (msg.type === 'pong') return;
201
+
202
+ if (msg.type === 'inbound_message') {
203
+ this.handleInboundMessage(msg);
204
+ return;
205
+ }
206
+
207
+ const sessionKey = lincoSessionKey(msg);
208
+ if (!sessionKey) return;
209
+
210
+ const session = this.sessions.get(sessionKey);
211
+ if (!session) return;
212
+ session.lastRemoteActivityAt = Date.now();
213
+
214
+ if (msg.type === 'danger_confirm') {
215
+ if (!resolvePendingDanger(!!msg.approved, session.ws, session, this.config)) {
216
+ sendError(session.ws, '没有待确认的危险操作');
217
+ }
218
+ return;
219
+ }
220
+
221
+ if (!resolvePendingPermission(!!msg.approved, session.ws, session, this.config)) {
222
+ sendError(session.ws, '没有待确认的工具权限请求');
223
+ }
224
+ }
225
+
226
+ handleInboundMessage(msg) {
227
+ const to = String(msg.to || this.agentType);
228
+ if (!['agent', 'claude', this.agentType].includes(to)) return;
229
+
230
+ const sessionKey = lincoSessionKey(msg);
231
+ if (!sessionKey) {
232
+ this.sendLincoMessage({ ...lincoMetaFromMessage(msg), type: 'outbound_message', text: '消息缺少 sessionKey' });
233
+ return;
234
+ }
235
+
236
+ const session = this.getOrCreateSession(sessionKey, msg);
237
+ if (!session) return;
238
+ session.lastRemoteActivityAt = Date.now();
239
+ const linco = lincoMetaFromMessage(msg);
240
+ linco.streamId = linco.streamId || buildStreamId(msg);
241
+ linco.fullText = '';
242
+ session.linco = linco;
243
+
244
+ this.handleChatMessage(msg, session);
245
+ }
246
+
247
+ handleChatMessage(msg, session) {
248
+ const rawText = String(msg.text || '').trim();
249
+ const attachments = lincoFilesToAttachments(msg.files);
250
+ const ws = createRemoteAdapter(this, session, session.linco);
251
+ if (!session.isTurnActive) session.ws = ws;
252
+
253
+ if (rawText.startsWith('/') && attachments.length === 0) {
254
+ if (handleSlashCommand(rawText, ws, session, this.config)) return;
255
+ }
256
+
257
+ handleMessageWithAttachments({ text: rawText, attachments }, ws, session, this.config, executeAgentQuery);
258
+ }
259
+
260
+ getOrCreateSession(sessionKey, msg) {
261
+ const existing = this.sessions.get(sessionKey);
262
+ if (existing) return existing;
263
+
264
+ let session;
265
+ try {
266
+ session = createSession(this.config, { externalSessionId: sessionKey, agentType: this.agentType });
267
+ } catch (err) {
268
+ this.sendLincoMessage({ ...lincoMetaFromMessage(msg), type: 'outbound_message', text: `会话初始化失败: ${err.message}` });
269
+ return null;
270
+ }
271
+
272
+ if (!this.registerSession(session)) {
273
+ cleanupSession(session);
274
+ this.sendLincoMessage({ ...lincoMetaFromMessage(msg), type: 'outbound_message', text: '该 sessionKey 已有活动连接' });
275
+ return null;
276
+ }
277
+
278
+ const linco = lincoMetaFromMessage(msg);
279
+ linco.streamId = linco.streamId || buildStreamId(msg);
280
+ linco.fullText = '';
281
+ session.linco = linco;
282
+ session.ws = createRemoteAdapter(this, session, linco);
283
+ session.lastRemoteActivityAt = Date.now();
284
+ this.sessions.set(session.id, session);
285
+ startOutboxWatcher(session.ws, session, this.config);
286
+ this.startIdleTimer(session);
287
+
288
+ console.log(`[IM:${this.agentType}] 新远端会话 [${session.id}],工作目录: ${session.workspace}`);
289
+ sendSessionInfo(session.ws, session, this.config);
290
+ sendSystem(session.ws, `已连接到 ${this.agentType} Agent
291
+ 工作目录: ${session.workspace}
292
+ 输入 /help 查看可用命令`);
293
+ return session;
294
+ }
295
+
296
+ registerSession(session) {
297
+ const activeSessions = this.config.activeSessions || new Map();
298
+ this.config.activeSessions = activeSessions;
299
+ if (activeSessions.has(session.activeKey)) return false;
300
+ activeSessions.set(session.activeKey, session);
301
+ return true;
302
+ }
303
+
304
+ unregisterSession(session) {
305
+ if (this.config.activeSessions?.get(session.activeKey) === session) {
306
+ this.config.activeSessions.delete(session.activeKey);
307
+ }
308
+ }
309
+
310
+ startIdleTimer(session) {
311
+ clearInterval(session.remoteIdleTimer);
312
+ session.remoteIdleTimer = setInterval(() => {
313
+ const idleMs = Date.now() - (session.lastRemoteActivityAt || Date.now());
314
+ if (idleMs >= this.config.im.idleSessionMs) {
315
+ this.closeSession(session.id);
316
+ }
317
+ }, Math.min(this.config.im.idleSessionMs, 60 * 1000));
318
+ session.remoteIdleTimer.unref?.();
319
+ }
320
+
321
+ closeSession(sessionKey) {
322
+ const session = this.sessions.get(sessionKey);
323
+ if (!session) return;
324
+
325
+ clearInterval(session.remoteIdleTimer);
326
+ this.sessions.delete(sessionKey);
327
+ this.unregisterSession(session);
328
+ cleanupSession(session);
329
+ console.log(`[IM:${this.agentType}] 远端会话结束 [${session.id}]`);
330
+ }
331
+
332
+ sendLincoMessage(payload) {
333
+ const meta = lincoMetaDefaults(this.config, payload);
334
+ const message = pruneUndefined({
335
+ ...payload,
336
+ from: payload.from || this.agentType,
337
+ to: payload.to || 'robot',
338
+ source: payload.source || 'ws',
339
+ ts: payload.ts || Date.now(),
340
+ accountId: payload.accountId || meta.accountId,
341
+ agentId: payload.agentId || meta.agentId,
342
+ channel: payload.channel || meta.channel,
343
+ });
344
+ return this.sendRemote(message);
345
+ }
346
+
347
+ sendPresence(status, reason) {
348
+ const meta = lincoMetaDefaults(this.config);
349
+ return this.sendRemote(pruneUndefined({
350
+ type: 'presence_event',
351
+ from: this.agentType,
352
+ to: 'robot',
353
+ source: 'ws',
354
+ ts: Date.now(),
355
+ accountId: meta.accountId,
356
+ agentId: meta.agentId,
357
+ channel: meta.channel,
358
+ status,
359
+ reason,
360
+ }));
361
+ }
362
+
363
+ sendRemote(payload) {
364
+ if (!this.isOpen()) {
365
+ this.queueRemote(payload);
366
+ return false;
367
+ }
368
+ this.ws.send(JSON.stringify(payload));
369
+ return true;
370
+ }
371
+
372
+ queueRemote(payload) {
373
+ if (payload?.type === 'ping' || payload?.type === 'pong') return;
374
+ this.pendingEvents.push(payload);
375
+ const maxPendingEvents = this.config.im.maxPendingEvents;
376
+ if (this.pendingEvents.length > maxPendingEvents) {
377
+ this.pendingEvents.splice(0, this.pendingEvents.length - maxPendingEvents);
378
+ }
379
+ }
380
+
381
+ flushPendingEvents() {
382
+ if (!this.isOpen() || this.pendingEvents.length === 0) return;
383
+ const pending = this.pendingEvents.splice(0);
384
+ for (const payload of pending) {
385
+ this.ws.send(JSON.stringify(payload));
386
+ }
387
+ }
388
+
389
+ isOpen() {
390
+ return this.ws?.readyState === WebSocket.OPEN;
391
+ }
392
+ }
393
+
394
+ function createRemoteAdapter(connector, session, lincoMeta = session.linco || {}) {
395
+ const linco = {
396
+ ...lincoMeta,
397
+ streamId: lincoMeta.streamId || `linco-stream-${Date.now()}`,
398
+ fullText: lincoMeta.fullText || '',
399
+ };
400
+
401
+ return {
402
+ send(jsonString) {
403
+ let event;
404
+ try {
405
+ event = JSON.parse(jsonString);
406
+ } catch {
407
+ event = { type: 'system', text: String(jsonString || '') };
408
+ }
409
+
410
+ const payload = mapLocalEventToLinco(event, session, connector.config, linco);
411
+ if (!payload) return;
412
+ if (Array.isArray(payload)) {
413
+ for (const item of payload) connector.sendLincoMessage(item);
414
+ return;
415
+ }
416
+ connector.sendLincoMessage(payload);
417
+ },
418
+ };
419
+ }
420
+
421
+ function sendSessionInfo(ws, session, config) {
422
+ send(ws, 'session_info', {
423
+ sessionKey: session.id,
424
+ storageId: session.storageId,
425
+ agentType: session.agentType,
426
+ agentSessionId: session.agentSessionId,
427
+ workspace: session.workspace,
428
+ runtime: {
429
+ dir: session.runtimeDir,
430
+ attachmentsDir: session.attachmentsDir,
431
+ outboxDir: session.outboxDir,
432
+ },
433
+ upload: {
434
+ maxCount: config.maxAttachmentCount,
435
+ maxFileBytes: config.maxAttachmentBytes,
436
+ maxTotalBytes: config.maxTotalAttachmentBytes,
437
+ blockedExtensions: config.allowUnsafeAttachments ? [] : config.unsafeAttachmentExtensions,
438
+ },
439
+ capabilities: {
440
+ incomingAttachments: true,
441
+ multimodalImages: session.agentType === 'claude',
442
+ outgoingAttachments: true,
443
+ remoteIm: true,
444
+ agentType: session.agentType,
445
+ },
446
+ });
447
+ }
448
+
449
+ function lincoSessionKey(msg) {
450
+ return String(msg.sessionKey || '').trim();
451
+ }
452
+
453
+ function lincoMetaFromMessage(msg) {
454
+ const userId = stringOrUndefined(msg.userId || msg.targetId);
455
+ return pruneUndefined({
456
+ accountId: stringOrUndefined(msg.accountId),
457
+ agentId: stringOrUndefined(msg.agentId),
458
+ chatType: stringOrUndefined(msg.chatType || msg.targetType),
459
+ targetType: stringOrUndefined(msg.targetType || msg.chatType),
460
+ targetId: stringOrUndefined(msg.targetId || msg.userId),
461
+ userId,
462
+ messageId: stringOrUndefined(msg.messageId),
463
+ streamId: stringOrUndefined(msg.streamId) || buildStreamId(msg),
464
+ sessionKey: lincoSessionKey(msg),
465
+ channel: stringOrUndefined(msg.channel),
466
+ });
467
+ }
468
+
469
+ function stringOrUndefined(value) {
470
+ const text = String(value || '').trim();
471
+ return text || undefined;
472
+ }
473
+
474
+ function safeUrlForLog(value) {
475
+ try {
476
+ const url = new URL(value);
477
+ url.searchParams.delete('token');
478
+ url.searchParams.delete('appSecret');
479
+ return url.toString();
480
+ } catch {
481
+ return '(invalid url)';
482
+ }
483
+ }
484
+
485
+ module.exports = {
486
+ startImConnector,
487
+ startImConnectors,
488
+ };
@@ -0,0 +1,38 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { ensureDir } = require('./config');
4
+ const { sendError, sendSystem } = require('./protocol');
5
+
6
+ function handleImageMessage(msg, ws, session, executeAgentQuery) {
7
+ const { base64, mimeType, text } = msg;
8
+ if (!base64) {
9
+ sendError(ws, '❌ 图片数据为空');
10
+ return;
11
+ }
12
+
13
+ const ext = mimeType?.split('/')[1] || 'png';
14
+ const imgFile = path.join(session.attachmentsDir, `.upload_${Date.now()}.${ext}`);
15
+ let imageSize = 0;
16
+
17
+ try {
18
+ ensureDir(session.attachmentsDir);
19
+ const buffer = Buffer.from(base64, 'base64');
20
+ imageSize = buffer.length;
21
+ fs.writeFileSync(imgFile, buffer);
22
+ } catch (err) {
23
+ sendError(ws, `❌ 保存图片失败: ${err.message}`);
24
+ return;
25
+ }
26
+
27
+ sendSystem(ws, `🖼️ 图片已接收 (${(imageSize / 1024).toFixed(1)} KB)`);
28
+
29
+ const prompt = text || '请用中文描述这张图片的内容。';
30
+ executeAgentQuery([
31
+ { type: 'text', text: `请始终使用中文直接回答用户。不要描述你的内部工具调用、执行步骤或实现细节,除非用户明确询问。\n\n用户请求:\n${prompt}` },
32
+ { type: 'image', source: { type: 'base64', media_type: mimeType || 'image/png', data: base64 } }
33
+ ], ws, session);
34
+ }
35
+
36
+ module.exports = {
37
+ handleImageMessage,
38
+ };