neoagent 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/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/bin/neoagent.js +8 -0
- package/com.neoagent.plist +45 -0
- package/docs/configuration.md +45 -0
- package/docs/skills.md +45 -0
- package/lib/manager.js +459 -0
- package/package.json +61 -0
- package/server/db/database.js +239 -0
- package/server/index.js +442 -0
- package/server/middleware/auth.js +35 -0
- package/server/public/app.html +559 -0
- package/server/public/css/app.css +608 -0
- package/server/public/css/styles.css +472 -0
- package/server/public/favicon.svg +17 -0
- package/server/public/js/app.js +3283 -0
- package/server/public/login.html +313 -0
- package/server/routes/agents.js +125 -0
- package/server/routes/auth.js +105 -0
- package/server/routes/browser.js +116 -0
- package/server/routes/mcp.js +164 -0
- package/server/routes/memory.js +193 -0
- package/server/routes/messaging.js +153 -0
- package/server/routes/protocols.js +87 -0
- package/server/routes/scheduler.js +63 -0
- package/server/routes/settings.js +98 -0
- package/server/routes/skills.js +107 -0
- package/server/routes/store.js +1192 -0
- package/server/services/ai/compaction.js +82 -0
- package/server/services/ai/engine.js +1690 -0
- package/server/services/ai/models.js +46 -0
- package/server/services/ai/multiStep.js +112 -0
- package/server/services/ai/providers/anthropic.js +181 -0
- package/server/services/ai/providers/base.js +40 -0
- package/server/services/ai/providers/google.js +187 -0
- package/server/services/ai/providers/grok.js +121 -0
- package/server/services/ai/providers/ollama.js +162 -0
- package/server/services/ai/providers/openai.js +167 -0
- package/server/services/ai/toolRunner.js +218 -0
- package/server/services/browser/controller.js +320 -0
- package/server/services/cli/executor.js +204 -0
- package/server/services/mcp/client.js +260 -0
- package/server/services/memory/embeddings.js +126 -0
- package/server/services/memory/manager.js +431 -0
- package/server/services/messaging/base.js +23 -0
- package/server/services/messaging/discord.js +238 -0
- package/server/services/messaging/manager.js +328 -0
- package/server/services/messaging/telegram.js +243 -0
- package/server/services/messaging/telnyx.js +693 -0
- package/server/services/messaging/whatsapp.js +304 -0
- package/server/services/scheduler/cron.js +312 -0
- package/server/services/websocket.js +191 -0
- package/server/utils/security.js +71 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
const db = require('../../db/database');
|
|
2
|
+
const { WhatsAppPlatform } = require('./whatsapp');
|
|
3
|
+
const { TelnyxVoicePlatform } = require('./telnyx');
|
|
4
|
+
const { DiscordPlatform } = require('./discord');
|
|
5
|
+
const { TelegramPlatform } = require('./telegram');
|
|
6
|
+
|
|
7
|
+
class MessagingManager {
|
|
8
|
+
constructor(io) {
|
|
9
|
+
this.io = io;
|
|
10
|
+
this.platforms = new Map();
|
|
11
|
+
this.messageHandlers = [];
|
|
12
|
+
this.platformTypes = {
|
|
13
|
+
whatsapp: WhatsAppPlatform,
|
|
14
|
+
telnyx: TelnyxVoicePlatform,
|
|
15
|
+
discord: DiscordPlatform,
|
|
16
|
+
telegram: TelegramPlatform,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
registerHandler(handler) {
|
|
21
|
+
this.messageHandlers.push(handler);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async connectPlatform(userId, platformName, config = {}) {
|
|
25
|
+
const PlatformClass = this.platformTypes[platformName];
|
|
26
|
+
if (!PlatformClass) throw new Error(`Unknown platform: ${platformName}`);
|
|
27
|
+
|
|
28
|
+
// For Telnyx, inject saved whitelist and voice secret into config before constructing
|
|
29
|
+
if (platformName === 'telnyx') {
|
|
30
|
+
const wlRow = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
|
|
31
|
+
.get(userId, 'platform_whitelist_telnyx');
|
|
32
|
+
if (wlRow) {
|
|
33
|
+
try { config.allowedNumbers = JSON.parse(wlRow.value); } catch { /* ignore */ }
|
|
34
|
+
}
|
|
35
|
+
const secretRow = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
|
|
36
|
+
.get(userId, 'platform_voice_secret_telnyx');
|
|
37
|
+
if (secretRow) {
|
|
38
|
+
try { config.voiceSecret = JSON.parse(secretRow.value); } catch { config.voiceSecret = secretRow.value; }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// For Discord, inject saved allowedIds whitelist
|
|
43
|
+
if (platformName === 'discord') {
|
|
44
|
+
const wlRow = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
|
|
45
|
+
.get(userId, 'platform_whitelist_discord');
|
|
46
|
+
if (wlRow) {
|
|
47
|
+
try { config.allowedIds = JSON.parse(wlRow.value); } catch { /* ignore */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// For Telegram, inject saved allowedIds whitelist
|
|
52
|
+
if (platformName === 'telegram') {
|
|
53
|
+
const wlRow = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
|
|
54
|
+
.get(userId, 'platform_whitelist_telegram');
|
|
55
|
+
if (wlRow) {
|
|
56
|
+
try { config.allowedIds = JSON.parse(wlRow.value); } catch { /* ignore */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const key = `${userId}:${platformName}`;
|
|
61
|
+
let platform = this.platforms.get(key);
|
|
62
|
+
|
|
63
|
+
if (platform) {
|
|
64
|
+
await platform.disconnect().catch(() => {});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
platform = new PlatformClass(config);
|
|
68
|
+
this.platforms.set(key, platform);
|
|
69
|
+
|
|
70
|
+
platform.on('qr', (qr) => {
|
|
71
|
+
this.io.to(`user:${userId}`).emit('messaging:qr', { platform: platformName, qr });
|
|
72
|
+
db.prepare('UPDATE platform_connections SET status = ?, config = ? WHERE user_id = ? AND platform = ?')
|
|
73
|
+
.run('awaiting_qr', JSON.stringify(config), userId, platformName);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
platform.on('connected', () => {
|
|
77
|
+
this.io.to(`user:${userId}`).emit('messaging:connected', { platform: platformName });
|
|
78
|
+
db.prepare('UPDATE platform_connections SET status = ?, last_connected = datetime(\'now\') WHERE user_id = ? AND platform = ?')
|
|
79
|
+
.run('connected', userId, platformName);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
platform.on('disconnected', (info) => {
|
|
83
|
+
this.io.to(`user:${userId}`).emit('messaging:disconnected', { platform: platformName, ...info });
|
|
84
|
+
db.prepare('UPDATE platform_connections SET status = ? WHERE user_id = ? AND platform = ?')
|
|
85
|
+
.run('disconnected', userId, platformName);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
platform.on('logged_out', () => {
|
|
89
|
+
this.io.to(`user:${userId}`).emit('messaging:logged_out', { platform: platformName });
|
|
90
|
+
db.prepare('UPDATE platform_connections SET status = ? WHERE user_id = ? AND platform = ?')
|
|
91
|
+
.run('logged_out', userId, platformName);
|
|
92
|
+
this.platforms.delete(key);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Telnyx-specific: blocked inbound caller notification
|
|
96
|
+
platform.on('blocked_caller', (info) => {
|
|
97
|
+
this.io.to(`user:${userId}`).emit('messaging:blocked_sender', {
|
|
98
|
+
platform: platformName,
|
|
99
|
+
sender: info.caller,
|
|
100
|
+
chatId: info.ccId,
|
|
101
|
+
senderName: null
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Discord / Telegram: blocked sender notification with suggestions
|
|
106
|
+
platform.on('blocked_sender', (info) => {
|
|
107
|
+
this.io.to(`user:${userId}`).emit('messaging:blocked_sender', {
|
|
108
|
+
platform: platformName,
|
|
109
|
+
sender: info.sender,
|
|
110
|
+
chatId: info.chatId,
|
|
111
|
+
senderName: info.senderName || null,
|
|
112
|
+
meta: info.guildName ? `Server: ${info.guildName}` : (info.groupName ? `Group: ${info.groupName}` : null),
|
|
113
|
+
suggestions: info.suggestions || null,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
platform.on('message', async (msg) => {
|
|
118
|
+
db.prepare('INSERT INTO messages (user_id, role, content, platform, platform_msg_id, platform_chat_id, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
119
|
+
.run(userId, 'user', msg.content, platformName, msg.messageId, msg.chatId,
|
|
120
|
+
JSON.stringify({ sender: msg.sender, senderName: msg.senderName, isGroup: msg.isGroup, mediaType: msg.mediaType }),
|
|
121
|
+
msg.timestamp);
|
|
122
|
+
|
|
123
|
+
// Enrich with platform name so handlers and the web UI always have it
|
|
124
|
+
const enrichedMsg = { platform: platformName, ...msg };
|
|
125
|
+
|
|
126
|
+
this.io.to(`user:${userId}`).emit('messaging:message', enrichedMsg);
|
|
127
|
+
|
|
128
|
+
for (const handler of this.messageHandlers) {
|
|
129
|
+
try {
|
|
130
|
+
await handler(userId, enrichedMsg);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error('Message handler error:', err.message);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const existing = db.prepare('SELECT id FROM platform_connections WHERE user_id = ? AND platform = ?').get(userId, platformName);
|
|
138
|
+
if (!existing) {
|
|
139
|
+
db.prepare('INSERT INTO platform_connections (user_id, platform, config, status) VALUES (?, ?, ?, ?)')
|
|
140
|
+
.run(userId, platformName, JSON.stringify(config), 'connecting');
|
|
141
|
+
} else {
|
|
142
|
+
db.prepare('UPDATE platform_connections SET config = ?, status = ? WHERE user_id = ? AND platform = ?')
|
|
143
|
+
.run(JSON.stringify(config), 'connecting', userId, platformName);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await platform.connect();
|
|
147
|
+
return { status: platform.getStatus() };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async disconnectPlatform(userId, platformName) {
|
|
151
|
+
const key = `${userId}:${platformName}`;
|
|
152
|
+
const platform = this.platforms.get(key);
|
|
153
|
+
if (!platform) return { status: 'not_connected' };
|
|
154
|
+
|
|
155
|
+
await platform.disconnect();
|
|
156
|
+
this.platforms.delete(key);
|
|
157
|
+
|
|
158
|
+
db.prepare('UPDATE platform_connections SET status = ? WHERE user_id = ? AND platform = ?')
|
|
159
|
+
.run('disconnected', userId, platformName);
|
|
160
|
+
|
|
161
|
+
return { status: 'disconnected' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async sendMessage(userId, platformName, to, content, mediaPath) {
|
|
165
|
+
const key = `${userId}:${platformName}`;
|
|
166
|
+
const platform = this.platforms.get(key);
|
|
167
|
+
if (!platform) throw new Error(`Platform ${platformName} not connected`);
|
|
168
|
+
|
|
169
|
+
// Sentinel: agent can choose not to reply by sending [NO RESPONSE]
|
|
170
|
+
if (!mediaPath && typeof content === 'string' && content.trim().toUpperCase() === '[NO RESPONSE]') {
|
|
171
|
+
return { success: true, suppressed: true };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = await platform.sendMessage(to, content, { mediaPath });
|
|
175
|
+
|
|
176
|
+
db.prepare('INSERT INTO messages (user_id, role, content, platform, platform_chat_id, media_path) VALUES (?, ?, ?, ?, ?, ?)')
|
|
177
|
+
.run(userId, 'assistant', content, platformName, to, mediaPath || null);
|
|
178
|
+
|
|
179
|
+
// Notify the web UI so the sent message appears in chat
|
|
180
|
+
this.io.to(`user:${userId}`).emit('messaging:sent', {
|
|
181
|
+
platform: platformName,
|
|
182
|
+
to,
|
|
183
|
+
content,
|
|
184
|
+
mediaPath: mediaPath || null
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return { success: true, result };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
getPlatformStatus(userId, platformName) {
|
|
191
|
+
const key = `${userId}:${platformName}`;
|
|
192
|
+
const platform = this.platforms.get(key);
|
|
193
|
+
if (!platform) {
|
|
194
|
+
const conn = db.prepare('SELECT status FROM platform_connections WHERE user_id = ? AND platform = ?').get(userId, platformName);
|
|
195
|
+
return { status: conn?.status || 'not_configured' };
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
status: platform.getStatus(),
|
|
199
|
+
authInfo: platform.getAuthInfo()
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
getAllStatuses(userId) {
|
|
204
|
+
const connections = db.prepare('SELECT platform, status, last_connected FROM platform_connections WHERE user_id = ?').all(userId);
|
|
205
|
+
const statuses = {};
|
|
206
|
+
|
|
207
|
+
for (const conn of connections) {
|
|
208
|
+
const key = `${userId}:${conn.platform}`;
|
|
209
|
+
const platform = this.platforms.get(key);
|
|
210
|
+
statuses[conn.platform] = {
|
|
211
|
+
status: platform ? platform.getStatus() : conn.status,
|
|
212
|
+
lastConnected: conn.last_connected,
|
|
213
|
+
authInfo: platform?.getAuthInfo() || null
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return statuses;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async logoutPlatform(userId, platformName) {
|
|
221
|
+
const key = `${userId}:${platformName}`;
|
|
222
|
+
const platform = this.platforms.get(key);
|
|
223
|
+
if (platform && platform.logout) {
|
|
224
|
+
await platform.logout();
|
|
225
|
+
}
|
|
226
|
+
this.platforms.delete(key);
|
|
227
|
+
db.prepare('DELETE FROM platform_connections WHERE user_id = ? AND platform = ?').run(userId, platformName);
|
|
228
|
+
return { status: 'logged_out' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async restoreConnections() {
|
|
232
|
+
const rows = db.prepare(
|
|
233
|
+
"SELECT user_id, platform, config FROM platform_connections WHERE status IN ('connected', 'awaiting_qr')"
|
|
234
|
+
).all();
|
|
235
|
+
for (const row of rows) {
|
|
236
|
+
try {
|
|
237
|
+
const config = row.config ? JSON.parse(row.config) : {};
|
|
238
|
+
console.log(`[Messaging] Restoring ${row.platform} for user ${row.user_id}`);
|
|
239
|
+
await this.connectPlatform(row.user_id, row.platform, config);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error(`[Messaging] Failed to restore ${row.platform} for user ${row.user_id}:`, err.message);
|
|
242
|
+
db.prepare("UPDATE platform_connections SET status = 'disconnected' WHERE user_id = ? AND platform = ?")
|
|
243
|
+
.run(row.user_id, row.platform);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async makeCall(userId, to, greeting) {
|
|
249
|
+
const key = `${userId}:telnyx`;
|
|
250
|
+
const platform = this.platforms.get(key);
|
|
251
|
+
if (!platform) throw new Error('Telnyx Voice is not connected');
|
|
252
|
+
if (!platform.initiateCall) throw new Error('Telnyx platform does not support outbound calls');
|
|
253
|
+
const result = await platform.initiateCall(to, greeting);
|
|
254
|
+
this.io.to(`user:${userId}`).emit('messaging:call_initiated', { platform: 'telnyx', to, callControlId: result.callControlId });
|
|
255
|
+
return { success: true, ...result };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async markRead(userId, platformName, chatId, messageId) {
|
|
259
|
+
const key = `${userId}:${platformName}`;
|
|
260
|
+
const platform = this.platforms.get(key);
|
|
261
|
+
if (!platform?.markRead) return;
|
|
262
|
+
return platform.markRead(chatId, messageId);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async sendTyping(userId, platformName, chatId, isTyping) {
|
|
266
|
+
const key = `${userId}:${platformName}`;
|
|
267
|
+
const platform = this.platforms.get(key);
|
|
268
|
+
if (!platform?.sendTyping) return;
|
|
269
|
+
return platform.sendTyping(chatId, isTyping);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Route a raw Telnyx webhook event to the correct user's platform instance.
|
|
274
|
+
* We find the Telnyx platform instance that owns this call_control_id, or fall
|
|
275
|
+
* back to the first connected Telnyx instance.
|
|
276
|
+
*/
|
|
277
|
+
async handleTelnyxWebhook(event) {
|
|
278
|
+
// Try to find the platform by connection_id or phone number from event payload
|
|
279
|
+
for (const [key, platform] of this.platforms.entries()) {
|
|
280
|
+
if (platform.name === 'telnyx') {
|
|
281
|
+
await platform.handleWebhook(event);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Update the allowed-numbers list on a live Telnyx platform instance.
|
|
290
|
+
*/
|
|
291
|
+
updateTelnyxAllowedNumbers(userId, numbers) {
|
|
292
|
+
const key = `${userId}:telnyx`;
|
|
293
|
+
const platform = this.platforms.get(key);
|
|
294
|
+
if (platform?.setAllowedNumbers) platform.setAllowedNumbers(numbers);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Update the voice secret code on a live Telnyx platform instance.
|
|
299
|
+
*/
|
|
300
|
+
updateTelnyxVoiceSecret(userId, secret) {
|
|
301
|
+
const key = `${userId}:telnyx`;
|
|
302
|
+
const platform = this.platforms.get(key);
|
|
303
|
+
if (platform?.setVoiceSecret) platform.setVoiceSecret(secret);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Update the allowed-entries list on a live Discord platform instance.
|
|
308
|
+
* Accepts prefixed strings: "user:ID", "guild:ID", "channel:ID"
|
|
309
|
+
*/
|
|
310
|
+
updateDiscordAllowedIds(userId, ids) {
|
|
311
|
+
const key = `${userId}:discord`;
|
|
312
|
+
const platform = this.platforms.get(key);
|
|
313
|
+
if (platform?.setAllowedEntries) platform.setAllowedEntries(ids);
|
|
314
|
+
else if (platform?.setAllowedIds) platform.setAllowedIds(ids); // legacy fallback
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Update the allowed-entries list on a live Telegram platform instance.
|
|
319
|
+
* Accepts prefixed strings: "user:ID", "group:ID"
|
|
320
|
+
*/
|
|
321
|
+
updateTelegramAllowedIds(userId, ids) {
|
|
322
|
+
const key = `${userId}:telegram`;
|
|
323
|
+
const platform = this.platforms.get(key);
|
|
324
|
+
if (platform?.setAllowedEntries) platform.setAllowedEntries(ids);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = { MessagingManager };
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BasePlatform } = require('./base');
|
|
4
|
+
const TelegramBot = require('node-telegram-bot-api');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whitelist entry format (prefixed strings):
|
|
8
|
+
* "user:ID" → always respond in DMs, no mention needed
|
|
9
|
+
* "group:ID" → respond in this group/supergroup when @mentioned
|
|
10
|
+
* "ID" → legacy plain ID, treated as "user"
|
|
11
|
+
*
|
|
12
|
+
* Telegram group/supergroup IDs are negative numbers (e.g. -1001234567890).
|
|
13
|
+
*
|
|
14
|
+
* chatId emitted on message events:
|
|
15
|
+
* DMs: "dm_<userId>"
|
|
16
|
+
* Groups: "<chatId>" (negative integer as string)
|
|
17
|
+
*/
|
|
18
|
+
class TelegramPlatform extends BasePlatform {
|
|
19
|
+
constructor(config = {}) {
|
|
20
|
+
super('telegram', config);
|
|
21
|
+
this.supportsGroups = true;
|
|
22
|
+
this.supportsMedia = false;
|
|
23
|
+
|
|
24
|
+
this.botToken = config.botToken || '';
|
|
25
|
+
this.allowedEntries = Array.isArray(config.allowedIds) ? config.allowedIds : [];
|
|
26
|
+
|
|
27
|
+
this._bot = null;
|
|
28
|
+
this._botUser = null;
|
|
29
|
+
// In-memory ring buffer of recent messages per raw chatId (Telegram has no fetch history API for bots)
|
|
30
|
+
this._contextBuffers = new Map();
|
|
31
|
+
this._contextMaxSize = 25;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
async connect() {
|
|
37
|
+
if (!this.botToken) throw new Error('Telegram bot token is required');
|
|
38
|
+
|
|
39
|
+
if (this._bot) { try { await this._bot.stopPolling(); } catch {} this._bot = null; }
|
|
40
|
+
|
|
41
|
+
this._bot = new TelegramBot(this.botToken, { polling: true });
|
|
42
|
+
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const timeout = setTimeout(() => reject(new Error('Telegram login timed out after 20 s')), 20000);
|
|
45
|
+
|
|
46
|
+
this._bot.getMe()
|
|
47
|
+
.then((me) => {
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
this._botUser = me;
|
|
50
|
+
this.status = 'connected';
|
|
51
|
+
console.log(`[Telegram] Logged in as @${me.username} (${me.id})`);
|
|
52
|
+
this.emit('connected');
|
|
53
|
+
resolve({ status: 'connected' });
|
|
54
|
+
})
|
|
55
|
+
.catch((err) => { clearTimeout(timeout); reject(err); });
|
|
56
|
+
|
|
57
|
+
this._bot.on('message', (msg) => this._handleMessage(msg));
|
|
58
|
+
this._bot.on('polling_error', (err) => {
|
|
59
|
+
console.error('[Telegram] Polling error:', err.message);
|
|
60
|
+
if (err.message && err.message.includes('401')) {
|
|
61
|
+
this.status = 'error';
|
|
62
|
+
this.emit('error', { message: 'Invalid bot token' });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async disconnect() {
|
|
69
|
+
if (this._bot) { try { await this._bot.stopPolling(); } catch {} this._bot = null; }
|
|
70
|
+
this.status = 'disconnected';
|
|
71
|
+
this._botUser = null;
|
|
72
|
+
this.emit('disconnected', { manual: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async logout() { await this.disconnect(); }
|
|
76
|
+
getStatus() { return this.status; }
|
|
77
|
+
getAuthInfo() { return this._botUser ? { username: this._botUser.username, id: this._botUser.id } : null; }
|
|
78
|
+
|
|
79
|
+
// ── Whitelist ──────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
setAllowedEntries(entries) {
|
|
82
|
+
this.allowedEntries = Array.isArray(entries) ? entries : [];
|
|
83
|
+
console.log(`[Telegram] Whitelist updated: ${this.allowedEntries.length} entry(ies)`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Returns {allowed, requireMention} */
|
|
87
|
+
_checkAccess(msg) {
|
|
88
|
+
const isPrivate = msg.chat.type === 'private';
|
|
89
|
+
const userId = String(msg.from.id);
|
|
90
|
+
const chatId = String(msg.chat.id); // negative for groups
|
|
91
|
+
|
|
92
|
+
if (!this.allowedEntries.length) return { allowed: false, requireMention: false };
|
|
93
|
+
|
|
94
|
+
for (const entry of this.allowedEntries) {
|
|
95
|
+
const colon = entry.indexOf(':');
|
|
96
|
+
const type = colon > 0 ? entry.slice(0, colon) : 'user';
|
|
97
|
+
const id = colon > 0 ? entry.slice(colon + 1) : entry;
|
|
98
|
+
|
|
99
|
+
if (type === 'user' && id === userId) return { allowed: true, requireMention: false };
|
|
100
|
+
if (type === 'group' && id === chatId) return { allowed: true, requireMention: true };
|
|
101
|
+
}
|
|
102
|
+
return { allowed: false, requireMention: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_isMentioned(msg) {
|
|
106
|
+
if (!this._botUser) return false;
|
|
107
|
+
const text = msg.text || msg.caption || '';
|
|
108
|
+
const entities = msg.entities || msg.caption_entities || [];
|
|
109
|
+
for (const e of entities) {
|
|
110
|
+
if (e.type === 'mention') {
|
|
111
|
+
const mention = text.slice(e.offset, e.offset + e.length);
|
|
112
|
+
if (mention.toLowerCase() === `@${this._botUser.username.toLowerCase()}`) return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_stripMention(text) {
|
|
119
|
+
if (!this._botUser) return (text || '').trim();
|
|
120
|
+
return (text || '')
|
|
121
|
+
.replace(new RegExp(`@${this._botUser.username}`, 'gi'), '')
|
|
122
|
+
.replace(/\s{2,}/g, ' ')
|
|
123
|
+
.trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Context buffer (since Telegram bots can't fetch message history) ───────
|
|
127
|
+
|
|
128
|
+
_addToContext(rawChatId, entry) {
|
|
129
|
+
if (!this._contextBuffers.has(rawChatId)) this._contextBuffers.set(rawChatId, []);
|
|
130
|
+
const buf = this._contextBuffers.get(rawChatId);
|
|
131
|
+
buf.push(entry);
|
|
132
|
+
if (buf.length > this._contextMaxSize) buf.shift();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
_getContext(rawChatId) {
|
|
136
|
+
return [...(this._contextBuffers.get(rawChatId) || [])];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Message handler ────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
async _handleMessage(msg) {
|
|
142
|
+
if (!msg.from || msg.from.is_bot) return;
|
|
143
|
+
|
|
144
|
+
const isPrivate = msg.chat.type === 'private';
|
|
145
|
+
const userId = String(msg.from.id);
|
|
146
|
+
const rawChatId = String(msg.chat.id);
|
|
147
|
+
const outputChatId = isPrivate ? `dm_${userId}` : rawChatId;
|
|
148
|
+
|
|
149
|
+
const text = msg.text || msg.caption || '';
|
|
150
|
+
const senderName = [msg.from.first_name, msg.from.last_name].filter(Boolean).join(' ')
|
|
151
|
+
|| msg.from.username || userId;
|
|
152
|
+
|
|
153
|
+
// Always record into context buffer (even blocked messages add context)
|
|
154
|
+
this._addToContext(rawChatId, {
|
|
155
|
+
author: senderName,
|
|
156
|
+
content: text || (msg.photo ? '[photo]' : msg.document ? '[document]' : '[empty]'),
|
|
157
|
+
mine: false,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const { allowed, requireMention } = this._checkAccess(msg);
|
|
161
|
+
|
|
162
|
+
if (!allowed) {
|
|
163
|
+
const suggestions = [
|
|
164
|
+
{ label: `Add user (${senderName})`, prefixedId: `user:${userId}` },
|
|
165
|
+
];
|
|
166
|
+
if (!isPrivate) suggestions.push({
|
|
167
|
+
label: `Add group (${msg.chat.title || rawChatId})`,
|
|
168
|
+
prefixedId: `group:${rawChatId}`,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
this.emit('blocked_sender', {
|
|
172
|
+
sender: userId,
|
|
173
|
+
chatId: outputChatId,
|
|
174
|
+
senderName,
|
|
175
|
+
groupName: msg.chat.title || null,
|
|
176
|
+
suggestions,
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Group entries require @mention to activate
|
|
182
|
+
if (requireMention && !this._isMentioned(msg)) return;
|
|
183
|
+
|
|
184
|
+
let content = requireMention ? this._stripMention(text) : text;
|
|
185
|
+
if (!content && msg.photo) content = `[photo]`;
|
|
186
|
+
if (!content && msg.document) content = `[document: ${msg.document.file_name || 'file'}]`;
|
|
187
|
+
if (!content) return;
|
|
188
|
+
|
|
189
|
+
const fullSenderName = isPrivate
|
|
190
|
+
? senderName
|
|
191
|
+
: `${senderName} in ${msg.chat.title || rawChatId}`;
|
|
192
|
+
|
|
193
|
+
const channelContext = (!isPrivate && requireMention) ? this._getContext(rawChatId) : null;
|
|
194
|
+
|
|
195
|
+
this.emit('message', {
|
|
196
|
+
platform: 'telegram',
|
|
197
|
+
chatId: outputChatId,
|
|
198
|
+
sender: userId,
|
|
199
|
+
senderName: fullSenderName,
|
|
200
|
+
content,
|
|
201
|
+
mediaType: null,
|
|
202
|
+
isGroup: !isPrivate,
|
|
203
|
+
messageId: String(msg.message_id),
|
|
204
|
+
timestamp: new Date(msg.date * 1000).toISOString(),
|
|
205
|
+
channelContext,
|
|
206
|
+
channelName: isPrivate ? null : (msg.chat.title || rawChatId),
|
|
207
|
+
groupName: msg.chat.title || null,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Send ───────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* to: "dm_<userId>" for DMs, or a raw chat ID (e.g. "-1001234567890") for groups
|
|
215
|
+
*/
|
|
216
|
+
async sendMessage(to, content, _options = {}) {
|
|
217
|
+
if (!this._bot || this.status !== 'connected') throw new Error('Telegram not connected');
|
|
218
|
+
|
|
219
|
+
const telegramChatId = to.startsWith('dm_') ? to.slice(3) : to;
|
|
220
|
+
await this._bot.sendMessage(telegramChatId, content);
|
|
221
|
+
|
|
222
|
+
// Store outgoing message in context buffer
|
|
223
|
+
if (this._botUser) {
|
|
224
|
+
this._addToContext(telegramChatId, {
|
|
225
|
+
author: `[bot] ${this._botUser.username}`,
|
|
226
|
+
content,
|
|
227
|
+
mine: true,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { success: true };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async sendTyping(chatId, _isTyping) {
|
|
235
|
+
if (!this._bot || this.status !== 'connected') return;
|
|
236
|
+
try {
|
|
237
|
+
const id = chatId.startsWith('dm_') ? chatId.slice(3) : chatId;
|
|
238
|
+
await this._bot.sendChatAction(id, 'typing');
|
|
239
|
+
} catch { /* non-fatal */ }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = { TelegramPlatform };
|