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.
- package/README.md +426 -0
- package/bin/linco.js +465 -0
- package/package.json +25 -0
- package/public/index.html +1457 -0
- package/server.js +17 -0
- package/src/agentRunner.js +37 -0
- package/src/agents/claude.js +1 -0
- package/src/agents/codex.js +869 -0
- package/src/attachmentHandler.js +258 -0
- package/src/claudeRunner.js +564 -0
- package/src/config.js +371 -0
- package/src/danger.js +21 -0
- package/src/httpStatic.js +166 -0
- package/src/imConnector.js +488 -0
- package/src/imageHandler.js +38 -0
- package/src/lincoProtocol.js +209 -0
- package/src/localAuth.js +46 -0
- package/src/logger.js +137 -0
- package/src/outgoingAttachmentHandler.js +204 -0
- package/src/protocol.js +17 -0
- package/src/serverApp.js +61 -0
- package/src/session.js +359 -0
- package/src/slashCommands.js +349 -0
- package/src/streamBuffer.js +73 -0
- package/src/wsServer.js +293 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
const LINCO_INBOUND_TYPES = new Set(['ping', 'pong', 'inbound_message', 'danger_confirm', 'permission_response']);
|
|
4
|
+
|
|
5
|
+
function isLincoMessage(msg) {
|
|
6
|
+
return msg && typeof msg === 'object' && LINCO_INBOUND_TYPES.has(msg.type);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function toInternal(msg) {
|
|
10
|
+
if (msg.type === 'inbound_message') {
|
|
11
|
+
return {
|
|
12
|
+
_lincoMode: true,
|
|
13
|
+
type: 'message',
|
|
14
|
+
text: String(msg.text || '').trim(),
|
|
15
|
+
attachments: lincoFilesToAttachments(msg.files),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return { ...msg };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createLincoAdapter(rawWs, session, config) {
|
|
22
|
+
const linco = session.linco || {};
|
|
23
|
+
linco.fullText = linco.fullText || '';
|
|
24
|
+
linco.streamId = linco.streamId || `linco-stream-${Date.now()}`;
|
|
25
|
+
session.linco = linco;
|
|
26
|
+
|
|
27
|
+
const closed = { current: false };
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
send(jsonString) {
|
|
31
|
+
let event;
|
|
32
|
+
try {
|
|
33
|
+
event = JSON.parse(jsonString);
|
|
34
|
+
} catch {
|
|
35
|
+
event = { type: 'system', text: String(jsonString || '') };
|
|
36
|
+
}
|
|
37
|
+
const payload = mapLocalEventToLinco(event, session, config, linco);
|
|
38
|
+
if (!payload || closed.current) return;
|
|
39
|
+
const wrapped = wrapLincoEnvelope(payload, config);
|
|
40
|
+
if (Array.isArray(wrapped)) {
|
|
41
|
+
for (const item of wrapped) rawWs.send(JSON.stringify(item));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
rawWs.send(JSON.stringify(wrapped));
|
|
45
|
+
},
|
|
46
|
+
rawSend(data) {
|
|
47
|
+
if (!closed.current) rawWs.send(data);
|
|
48
|
+
},
|
|
49
|
+
close() {
|
|
50
|
+
closed.current = true;
|
|
51
|
+
},
|
|
52
|
+
get readyState() {
|
|
53
|
+
return rawWs.readyState;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function wrapLincoEnvelope(payload, config) {
|
|
59
|
+
const meta = lincoMetaDefaults(config, {});
|
|
60
|
+
return pruneUndefined({
|
|
61
|
+
...payload,
|
|
62
|
+
from: payload.from || (config.agents ? Object.keys(config.agents)[0] : 'claude'),
|
|
63
|
+
to: payload.to || 'robot',
|
|
64
|
+
source: payload.source || 'ws',
|
|
65
|
+
ts: payload.ts || Date.now(),
|
|
66
|
+
accountId: payload.accountId || meta.accountId,
|
|
67
|
+
agentId: payload.agentId || meta.agentId,
|
|
68
|
+
channel: payload.channel || meta.channel,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mapLocalEventToLinco(event, session, config, linco) {
|
|
73
|
+
const meta = lincoMetaDefaults(config, linco || {});
|
|
74
|
+
const base = {
|
|
75
|
+
accountId: meta.accountId,
|
|
76
|
+
agentId: meta.agentId,
|
|
77
|
+
userId: meta.userId,
|
|
78
|
+
chatType: meta.chatType,
|
|
79
|
+
targetType: meta.targetType,
|
|
80
|
+
targetId: meta.targetId,
|
|
81
|
+
sessionKey: session.id,
|
|
82
|
+
streamId: meta.streamId,
|
|
83
|
+
messageId: meta.messageId,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
switch (event.type) {
|
|
87
|
+
case 'assistant_start':
|
|
88
|
+
linco.fullText = '';
|
|
89
|
+
linco.streamId = linco.streamId || `linco-stream-${Date.now()}`;
|
|
90
|
+
return null;
|
|
91
|
+
case 'assistant_chunk': {
|
|
92
|
+
const delta = String(event.text || '');
|
|
93
|
+
linco.fullText = `${linco.fullText || ''}${delta}`;
|
|
94
|
+
return {
|
|
95
|
+
...base,
|
|
96
|
+
type: 'stream_chunk',
|
|
97
|
+
mode: 'chunk',
|
|
98
|
+
streamId: linco.streamId,
|
|
99
|
+
delta,
|
|
100
|
+
fullText: linco.fullText,
|
|
101
|
+
done: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
case 'assistant_end':
|
|
105
|
+
return {
|
|
106
|
+
...base,
|
|
107
|
+
type: 'stream_chunk',
|
|
108
|
+
mode: 'chunk',
|
|
109
|
+
streamId: linco.streamId,
|
|
110
|
+
delta: '',
|
|
111
|
+
fullText: linco.fullText || '',
|
|
112
|
+
done: true,
|
|
113
|
+
};
|
|
114
|
+
case 'thinking':
|
|
115
|
+
case 'thinking_clear':
|
|
116
|
+
return null;
|
|
117
|
+
case 'system':
|
|
118
|
+
case 'error':
|
|
119
|
+
return {
|
|
120
|
+
...base,
|
|
121
|
+
type: 'outbound_message',
|
|
122
|
+
messageId: `linco-${event.type}-${Date.now()}`,
|
|
123
|
+
text: event.text || '',
|
|
124
|
+
};
|
|
125
|
+
case 'outgoing_attachment':
|
|
126
|
+
return mapOutgoingAttachment(event, base, session);
|
|
127
|
+
default:
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
...event,
|
|
131
|
+
type: event.type || 'outbound_message',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function mapOutgoingAttachment(event, base, session) {
|
|
137
|
+
const file = event.id ? session.outgoingAttachments?.get(event.id) : null;
|
|
138
|
+
if (event.error) {
|
|
139
|
+
return {
|
|
140
|
+
...base,
|
|
141
|
+
type: 'outbound_message',
|
|
142
|
+
messageId: `linco-file-error-${Date.now()}`,
|
|
143
|
+
text: event.error,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const payload = {
|
|
148
|
+
...base,
|
|
149
|
+
type: 'outbound_message',
|
|
150
|
+
messageId: `linco-media-${Date.now()}`,
|
|
151
|
+
text: `Agent 生成了文件:${event.name || '未命名文件'}`,
|
|
152
|
+
mediaName: event.name,
|
|
153
|
+
mediaType: event.mimeType,
|
|
154
|
+
mediaUrl: event.url,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (file?.path) {
|
|
158
|
+
payload.mediaUrl = file.path;
|
|
159
|
+
try {
|
|
160
|
+
payload.mediaBase64 = fs.readFileSync(file.path).toString('base64');
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return payload;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function lincoFilesToAttachments(files) {
|
|
168
|
+
if (!Array.isArray(files)) return [];
|
|
169
|
+
return files.map(file => ({
|
|
170
|
+
name: file?.name || file?.mediaName || 'attachment',
|
|
171
|
+
mimeType: file?.type || file?.mimeType || file?.mediaType || '',
|
|
172
|
+
base64: file?.base64 || file?.mediaBase64 || '',
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function lincoMetaDefaults(config, meta = {}) {
|
|
177
|
+
return {
|
|
178
|
+
accountId: meta.accountId || config.im?.account || 'main',
|
|
179
|
+
agentId: meta.agentId || config.im?.agentId || 'main',
|
|
180
|
+
chatType: meta.chatType || 'direct',
|
|
181
|
+
targetType: meta.targetType || meta.chatType || 'direct',
|
|
182
|
+
targetId: meta.targetId || meta.userId,
|
|
183
|
+
userId: meta.userId || meta.targetId,
|
|
184
|
+
messageId: meta.messageId,
|
|
185
|
+
streamId: meta.streamId,
|
|
186
|
+
channel: meta.channel || config.im?.channel || 'linco',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildStreamId(msg) {
|
|
191
|
+
const messageId = String(msg.messageId || '').trim();
|
|
192
|
+
if (messageId) return `linco-stream-${messageId}`;
|
|
193
|
+
return `linco-stream-${Date.now()}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pruneUndefined(value) {
|
|
197
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
isLincoMessage,
|
|
202
|
+
toInternal,
|
|
203
|
+
createLincoAdapter,
|
|
204
|
+
mapLocalEventToLinco,
|
|
205
|
+
lincoFilesToAttachments,
|
|
206
|
+
buildStreamId,
|
|
207
|
+
lincoMetaDefaults,
|
|
208
|
+
pruneUndefined,
|
|
209
|
+
};
|
package/src/localAuth.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const { updateUserConfig } = require('./config');
|
|
3
|
+
|
|
4
|
+
function ensureLocalToken(config) {
|
|
5
|
+
if (config.localWeb?.token) return config.localWeb.token;
|
|
6
|
+
|
|
7
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
8
|
+
updateUserConfig((userConfig) => {
|
|
9
|
+
userConfig.localWeb = {
|
|
10
|
+
...(userConfig.localWeb || {}),
|
|
11
|
+
token,
|
|
12
|
+
};
|
|
13
|
+
return userConfig;
|
|
14
|
+
}, config.configFile);
|
|
15
|
+
config.localWeb = { ...(config.localWeb || {}), token };
|
|
16
|
+
return token;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function requestToken(req, url = null) {
|
|
20
|
+
const parsedUrl = url || new URL(req.url || '/', 'http://localhost');
|
|
21
|
+
const queryToken = parsedUrl.searchParams.get('localToken') || parsedUrl.searchParams.get('token');
|
|
22
|
+
if (queryToken) return queryToken;
|
|
23
|
+
|
|
24
|
+
const header = req.headers?.authorization || '';
|
|
25
|
+
const match = String(header).match(/^Bearer\s+(.+)$/i);
|
|
26
|
+
return match ? match[1].trim() : '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isLocalRequestAuthorized(req, config, url = null) {
|
|
30
|
+
const token = config.localWeb?.token;
|
|
31
|
+
return !!token && requestToken(req, url) === token;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function localUrlWithToken(config) {
|
|
35
|
+
const host = config.host === '0.0.0.0' ? '127.0.0.1' : config.host;
|
|
36
|
+
const url = new URL(`http://${host}:${config.port}/`);
|
|
37
|
+
if (config.localWeb?.token) url.searchParams.set('localToken', config.localWeb.token);
|
|
38
|
+
return url.toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
ensureLocalToken,
|
|
43
|
+
isLocalRequestAuthorized,
|
|
44
|
+
localUrlWithToken,
|
|
45
|
+
requestToken,
|
|
46
|
+
};
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const LEVELS = {
|
|
5
|
+
debug: 10,
|
|
6
|
+
info: 20,
|
|
7
|
+
warn: 30,
|
|
8
|
+
error: 40,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function normalizeLevel(level) {
|
|
12
|
+
const normalized = String(level || '').trim().toLowerCase();
|
|
13
|
+
return Object.prototype.hasOwnProperty.call(LEVELS, normalized) ? normalized : 'info';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function localTimestamp() {
|
|
17
|
+
const d = new Date();
|
|
18
|
+
const pad = n => String(n).padStart(2, '0');
|
|
19
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}Z`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function todayStamp() {
|
|
23
|
+
return new Date().toISOString().slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatFields(fields) {
|
|
27
|
+
return Object.entries(fields)
|
|
28
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
29
|
+
.map(([key, value]) => `${key}=${formatValue(value)}`)
|
|
30
|
+
.join(' ');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatValue(value) {
|
|
34
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
35
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
36
|
+
return JSON.stringify(text.replace(/[\r\n]+/g, ' '));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createLogger(config = {}) {
|
|
40
|
+
const configuredLevel = normalizeLevel(process.env.LOG_LEVEL || config.logLevel);
|
|
41
|
+
const logsDir = config.logsDir;
|
|
42
|
+
|
|
43
|
+
let currentLogDate = '';
|
|
44
|
+
let currentLogFile = '';
|
|
45
|
+
|
|
46
|
+
function shouldLog(level) {
|
|
47
|
+
return LEVELS[level] >= LEVELS[configuredLevel];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ensureLogsDir() {
|
|
51
|
+
if (logsDir && !fs.existsSync(logsDir)) {
|
|
52
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getLogFile() {
|
|
57
|
+
const today = todayStamp();
|
|
58
|
+
return path.join(logsDir, `linco-${today}.log`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rotateIfNeeded() {
|
|
62
|
+
const nextFile = getLogFile();
|
|
63
|
+
if (nextFile !== currentLogFile) {
|
|
64
|
+
currentLogFile = nextFile;
|
|
65
|
+
currentLogDate = todayStamp();
|
|
66
|
+
cleanupOldLogs();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeToFile(level, message, fields = {}) {
|
|
71
|
+
if (!currentLogFile) return;
|
|
72
|
+
const line = JSON.stringify({
|
|
73
|
+
ts: localTimestamp(),
|
|
74
|
+
level,
|
|
75
|
+
msg: message,
|
|
76
|
+
...fields,
|
|
77
|
+
}) + '\n';
|
|
78
|
+
try {
|
|
79
|
+
fs.appendFileSync(currentLogFile, line);
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore write failures to avoid crashing the app
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cleanupOldLogs(maxDays = 30) {
|
|
86
|
+
if (!logsDir) return;
|
|
87
|
+
try {
|
|
88
|
+
const entries = fs.readdirSync(logsDir);
|
|
89
|
+
const cutoff = new Date();
|
|
90
|
+
cutoff.setDate(cutoff.getDate() - maxDays);
|
|
91
|
+
const cutoffStamp = cutoff.toISOString().slice(0, 10);
|
|
92
|
+
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (!entry.startsWith('linco-') || !entry.endsWith('.log')) continue;
|
|
95
|
+
const fileDate = entry.slice(7, 17); // extract YYYY-MM-DD
|
|
96
|
+
if (fileDate < cutoffStamp) {
|
|
97
|
+
fs.unlinkSync(path.join(logsDir, entry));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore cleanup errors
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function write(level, message, fields = {}) {
|
|
106
|
+
if (!shouldLog(level)) return;
|
|
107
|
+
|
|
108
|
+
const timestamp = localTimestamp();
|
|
109
|
+
const suffix = formatFields(fields);
|
|
110
|
+
const line = `${timestamp} ${level.toUpperCase()} ${message}${suffix ? ` ${suffix}` : ''}`;
|
|
111
|
+
|
|
112
|
+
if (logsDir) {
|
|
113
|
+
ensureLogsDir();
|
|
114
|
+
rotateIfNeeded();
|
|
115
|
+
writeToFile(level, message, fields);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (level === 'error') {
|
|
119
|
+
console.error(line);
|
|
120
|
+
} else if (level === 'warn') {
|
|
121
|
+
console.warn(line);
|
|
122
|
+
} else {
|
|
123
|
+
console.log(line);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
debug: (message, fields) => write('debug', message, fields),
|
|
129
|
+
info: (message, fields) => write('info', message, fields),
|
|
130
|
+
warn: (message, fields) => write('warn', message, fields),
|
|
131
|
+
error: (message, fields) => write('error', message, fields),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
createLogger,
|
|
137
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { ensureDir } = require('./config');
|
|
5
|
+
const { send } = require('./protocol');
|
|
6
|
+
|
|
7
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
8
|
+
const TEMP_EXTENSIONS = new Set(['.tmp', '.temp', '.part', '.crdownload']);
|
|
9
|
+
|
|
10
|
+
function startOutboxWatcher(ws, session, config) {
|
|
11
|
+
stopOutboxWatcher(session);
|
|
12
|
+
const outboxDir = getOutboxDir(session, config);
|
|
13
|
+
ensureDir(outboxDir);
|
|
14
|
+
session.outgoingAttachments = new Map();
|
|
15
|
+
seedOutboxSeen(session, config);
|
|
16
|
+
|
|
17
|
+
session.outboxTimer = setInterval(() => scanOutbox(ws, session, config), 1000);
|
|
18
|
+
session.outboxTimer.unref?.();
|
|
19
|
+
setTimeout(() => scanOutbox(ws, session, config), 600).unref?.();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stopOutboxWatcher(session) {
|
|
23
|
+
if (session.outboxTimer) {
|
|
24
|
+
clearInterval(session.outboxTimer);
|
|
25
|
+
session.outboxTimer = null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resetOutgoingAttachments(session, config) {
|
|
30
|
+
session.outgoingAttachments = new Map();
|
|
31
|
+
seedOutboxSeen(session, config);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function seedOutboxSeen(session, config) {
|
|
35
|
+
const outboxDir = getOutboxDir(session, config);
|
|
36
|
+
session.outboxSeen = new Set();
|
|
37
|
+
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = fs.readdirSync(outboxDir, { withFileTypes: true });
|
|
41
|
+
} catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (entry.isFile()) {
|
|
47
|
+
session.outboxSeen.add(path.resolve(outboxDir, entry.name));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function scanOutbox(ws, session, config) {
|
|
53
|
+
const outboxDir = getOutboxDir(session, config);
|
|
54
|
+
ensureDir(outboxDir);
|
|
55
|
+
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = fs.readdirSync(outboxDir, { withFileTypes: true });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.log(`[${session.id}] scan outbox failed:`, err.message);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!entry.isFile()) continue;
|
|
66
|
+
const filePath = path.resolve(outboxDir, entry.name);
|
|
67
|
+
if (!isInside(filePath, outboxDir)) continue;
|
|
68
|
+
if (session.outboxSeen.has(filePath)) continue;
|
|
69
|
+
if (shouldIgnoreFile(entry.name)) continue;
|
|
70
|
+
|
|
71
|
+
let stat;
|
|
72
|
+
try {
|
|
73
|
+
stat = fs.statSync(filePath);
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!stat.isFile() || stat.size === 0) continue;
|
|
79
|
+
if (Date.now() - stat.mtimeMs < 500) continue;
|
|
80
|
+
|
|
81
|
+
session.outboxSeen.add(filePath);
|
|
82
|
+
registerOutgoingAttachment(ws, session, config, filePath, stat.size);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function registerOutgoingAttachment(ws, session, config, filePath, size) {
|
|
87
|
+
if (size > config.maxOutgoingAttachmentBytes) {
|
|
88
|
+
send(ws, 'outgoing_attachment', {
|
|
89
|
+
error: `文件超过发送大小限制 ${(config.maxOutgoingAttachmentBytes / 1024 / 1024).toFixed(0)}MB`,
|
|
90
|
+
name: path.basename(filePath),
|
|
91
|
+
size,
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const id = crypto.randomBytes(12).toString('hex');
|
|
97
|
+
const name = path.basename(filePath);
|
|
98
|
+
const mimeType = mimeFromFilename(name);
|
|
99
|
+
const kind = IMAGE_EXTENSIONS.has(path.extname(name).toLowerCase()) ? 'image' : 'file';
|
|
100
|
+
|
|
101
|
+
session.outgoingAttachments.set(id, {
|
|
102
|
+
id,
|
|
103
|
+
name,
|
|
104
|
+
path: filePath,
|
|
105
|
+
mimeType,
|
|
106
|
+
size,
|
|
107
|
+
kind,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const message = {
|
|
111
|
+
id,
|
|
112
|
+
name,
|
|
113
|
+
mimeType,
|
|
114
|
+
size,
|
|
115
|
+
kind,
|
|
116
|
+
url: `/api/session/${encodeURIComponent(session.id)}/outgoing/${encodeURIComponent(id)}?agentType=${encodeURIComponent(session.agentType || 'claude')}`,
|
|
117
|
+
};
|
|
118
|
+
console.log(`[${session.id}] outgoing attachment: ${name} (${size} bytes)`);
|
|
119
|
+
send(ws, 'outgoing_attachment', message);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getOutgoingAttachment(activeSessions, sessionId, fileId, config, agentType) {
|
|
123
|
+
const activeKey = agentType ? `${agentType}:${sessionId}` : null;
|
|
124
|
+
const session = activeKey
|
|
125
|
+
? activeSessions.get(activeKey)
|
|
126
|
+
: activeSessions.get(sessionId) || [...activeSessions.values()].find(candidate => candidate.id === sessionId);
|
|
127
|
+
if (!session) return null;
|
|
128
|
+
|
|
129
|
+
const file = session.outgoingAttachments?.get(fileId);
|
|
130
|
+
if (!file) return null;
|
|
131
|
+
|
|
132
|
+
const outboxDir = getOutboxDir(session, config);
|
|
133
|
+
const resolved = path.resolve(file.path);
|
|
134
|
+
if (!isInside(resolved, outboxDir)) return null;
|
|
135
|
+
|
|
136
|
+
let stat;
|
|
137
|
+
try {
|
|
138
|
+
stat = fs.statSync(resolved);
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!stat.isFile()) return null;
|
|
144
|
+
return { ...file, path: resolved, size: stat.size };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getOutboxDir(session, config) {
|
|
148
|
+
return session.outboxDir;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildOutboxSystemPrompt(session, config) {
|
|
152
|
+
return `${config.systemPrompt}
|
|
153
|
+
|
|
154
|
+
工具使用要求:调用 Read 工具读取普通文件时不要传 pages 字段;只有读取 PDF 且需要指定页码时才传 pages,且 pages 不能是空字符串。
|
|
155
|
+
|
|
156
|
+
如果你需要把文件或图片发送给用户,请将文件保存或复制到以下目录:
|
|
157
|
+
${getOutboxDir(session, config)}
|
|
158
|
+
|
|
159
|
+
保存完成后,系统会自动把该文件展示在用户对话框中。请使用有意义的文件名,不要把敏感文件放入 outbox,除非用户明确要求。`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function shouldIgnoreFile(name) {
|
|
163
|
+
if (name.startsWith('.') && name !== '.gitkeep') return true;
|
|
164
|
+
if (name.endsWith('~')) return true;
|
|
165
|
+
return TEMP_EXTENSIONS.has(path.extname(name).toLowerCase());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function mimeFromFilename(name) {
|
|
169
|
+
switch (path.extname(name).toLowerCase()) {
|
|
170
|
+
case '.png': return 'image/png';
|
|
171
|
+
case '.jpg':
|
|
172
|
+
case '.jpeg': return 'image/jpeg';
|
|
173
|
+
case '.gif': return 'image/gif';
|
|
174
|
+
case '.webp': return 'image/webp';
|
|
175
|
+
case '.svg': return 'image/svg+xml';
|
|
176
|
+
case '.txt': return 'text/plain; charset=utf-8';
|
|
177
|
+
case '.md': return 'text/markdown; charset=utf-8';
|
|
178
|
+
case '.csv': return 'text/csv; charset=utf-8';
|
|
179
|
+
case '.json': return 'application/json';
|
|
180
|
+
case '.pdf': return 'application/pdf';
|
|
181
|
+
case '.doc': return 'application/msword';
|
|
182
|
+
case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
183
|
+
case '.xls': return 'application/vnd.ms-excel';
|
|
184
|
+
case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
185
|
+
case '.sql': return 'application/sql';
|
|
186
|
+
case '.zip': return 'application/zip';
|
|
187
|
+
default: return 'application/octet-stream';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isInside(filePath, dir) {
|
|
192
|
+
const relative = path.relative(dir, filePath);
|
|
193
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
buildOutboxSystemPrompt,
|
|
198
|
+
getOutboxDir,
|
|
199
|
+
getOutgoingAttachment,
|
|
200
|
+
mimeFromFilename,
|
|
201
|
+
resetOutgoingAttachments,
|
|
202
|
+
startOutboxWatcher,
|
|
203
|
+
stopOutboxWatcher,
|
|
204
|
+
};
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function send(ws, type, payload = {}) {
|
|
2
|
+
ws.send(JSON.stringify({ type, ...payload }));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function sendSystem(ws, text) {
|
|
6
|
+
send(ws, 'system', { text });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function sendError(ws, text) {
|
|
10
|
+
send(ws, 'error', { text });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
send,
|
|
15
|
+
sendError,
|
|
16
|
+
sendSystem,
|
|
17
|
+
};
|
package/src/serverApp.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { ensureDir, loadConfig } = require('./config');
|
|
2
|
+
const { ensureLocalToken, localUrlWithToken } = require('./localAuth');
|
|
3
|
+
const { createStaticServer } = require('./httpStatic');
|
|
4
|
+
const { startImConnectors } = require('./imConnector');
|
|
5
|
+
const { createLogger } = require('./logger');
|
|
6
|
+
const { attachWebSocketServer } = require('./wsServer');
|
|
7
|
+
|
|
8
|
+
function startServer(rootDir, options = {}) {
|
|
9
|
+
const config = options.config || loadConfig(rootDir);
|
|
10
|
+
config.logger = options.logger || config.logger || createLogger(config);
|
|
11
|
+
const log = config.logger;
|
|
12
|
+
ensureLocalToken(config);
|
|
13
|
+
|
|
14
|
+
if (config.gitBashEnv) {
|
|
15
|
+
log.info('git bash detected', { path: config.gitBashEnv });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const server = createStaticServer(config);
|
|
19
|
+
attachWebSocketServer(server, config);
|
|
20
|
+
const imConnectors = startImConnectors(config);
|
|
21
|
+
|
|
22
|
+
server.on('close', () => {
|
|
23
|
+
log.info('server closing');
|
|
24
|
+
for (const connector of imConnectors) connector.stop();
|
|
25
|
+
options.onClose?.(server, config);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
server.listen(config.port, config.host, () => {
|
|
29
|
+
ensureDir(config.lincoHome);
|
|
30
|
+
ensureDir(config.sessionsDir);
|
|
31
|
+
ensureDir(config.logsDir);
|
|
32
|
+
const localUrl = localUrlWithToken(config);
|
|
33
|
+
log.info('server started', {
|
|
34
|
+
host: config.host,
|
|
35
|
+
port: config.port,
|
|
36
|
+
lincoHome: config.lincoHome,
|
|
37
|
+
sessionsDir: config.sessionsDir,
|
|
38
|
+
imEnabled: !!config.im?.enabled,
|
|
39
|
+
agents: Object.entries(config.agents || {}).filter(([, agent]) => agent.enabled).map(([type]) => type),
|
|
40
|
+
});
|
|
41
|
+
console.log('🚀 IM + Agent 桥接服务已启动');
|
|
42
|
+
console.log('');
|
|
43
|
+
console.log('📋 请复制下面的完整地址到浏览器打开本地测试页:');
|
|
44
|
+
console.log(localUrl);
|
|
45
|
+
console.log('');
|
|
46
|
+
console.log(` Linco 运行目录: ${config.lincoHome}`);
|
|
47
|
+
console.log(` 会话目录: ${config.sessionsDir}`);
|
|
48
|
+
console.log(` 日志级别: ${config.logLevel}`);
|
|
49
|
+
if (config.im?.enabled) {
|
|
50
|
+
const enabledAgents = Object.entries(config.agents || {}).filter(([, agent]) => agent.enabled).map(([type]) => type).join(', ');
|
|
51
|
+
console.log(` 远端 IM: 已启用 (${config.im.channel}/${config.im.account}; agents: ${enabledAgents || 'none'})`);
|
|
52
|
+
}
|
|
53
|
+
options.onListening?.(server, config);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return server;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
startServer,
|
|
61
|
+
};
|