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.
Files changed (54) hide show
  1. package/.env.example +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +42 -0
  4. package/bin/neoagent.js +8 -0
  5. package/com.neoagent.plist +45 -0
  6. package/docs/configuration.md +45 -0
  7. package/docs/skills.md +45 -0
  8. package/lib/manager.js +459 -0
  9. package/package.json +61 -0
  10. package/server/db/database.js +239 -0
  11. package/server/index.js +442 -0
  12. package/server/middleware/auth.js +35 -0
  13. package/server/public/app.html +559 -0
  14. package/server/public/css/app.css +608 -0
  15. package/server/public/css/styles.css +472 -0
  16. package/server/public/favicon.svg +17 -0
  17. package/server/public/js/app.js +3283 -0
  18. package/server/public/login.html +313 -0
  19. package/server/routes/agents.js +125 -0
  20. package/server/routes/auth.js +105 -0
  21. package/server/routes/browser.js +116 -0
  22. package/server/routes/mcp.js +164 -0
  23. package/server/routes/memory.js +193 -0
  24. package/server/routes/messaging.js +153 -0
  25. package/server/routes/protocols.js +87 -0
  26. package/server/routes/scheduler.js +63 -0
  27. package/server/routes/settings.js +98 -0
  28. package/server/routes/skills.js +107 -0
  29. package/server/routes/store.js +1192 -0
  30. package/server/services/ai/compaction.js +82 -0
  31. package/server/services/ai/engine.js +1690 -0
  32. package/server/services/ai/models.js +46 -0
  33. package/server/services/ai/multiStep.js +112 -0
  34. package/server/services/ai/providers/anthropic.js +181 -0
  35. package/server/services/ai/providers/base.js +40 -0
  36. package/server/services/ai/providers/google.js +187 -0
  37. package/server/services/ai/providers/grok.js +121 -0
  38. package/server/services/ai/providers/ollama.js +162 -0
  39. package/server/services/ai/providers/openai.js +167 -0
  40. package/server/services/ai/toolRunner.js +218 -0
  41. package/server/services/browser/controller.js +320 -0
  42. package/server/services/cli/executor.js +204 -0
  43. package/server/services/mcp/client.js +260 -0
  44. package/server/services/memory/embeddings.js +126 -0
  45. package/server/services/memory/manager.js +431 -0
  46. package/server/services/messaging/base.js +23 -0
  47. package/server/services/messaging/discord.js +238 -0
  48. package/server/services/messaging/manager.js +328 -0
  49. package/server/services/messaging/telegram.js +243 -0
  50. package/server/services/messaging/telnyx.js +693 -0
  51. package/server/services/messaging/whatsapp.js +304 -0
  52. package/server/services/scheduler/cron.js +312 -0
  53. package/server/services/websocket.js +191 -0
  54. 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 };