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,431 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { v4: uuidv4 } = require('uuid');
4
+ const db = require('../../db/database');
5
+ const {
6
+ getEmbedding,
7
+ cosineSimilarity,
8
+ serializeEmbedding,
9
+ deserializeEmbedding,
10
+ keywordSimilarity
11
+ } = require('./embeddings');
12
+
13
+ const DATA_DIR = path.join(__dirname, '../../../agent-data');
14
+ const SOUL_FILE = path.join(DATA_DIR, 'SOUL.md');
15
+ const API_KEYS_FILE = path.join(DATA_DIR, 'API_KEYS.json');
16
+ const DAILY_DIR = path.join(DATA_DIR, 'daily');
17
+ const MEMORY_DIR = path.join(DATA_DIR, 'memory');
18
+ const SKILLS_DIR = path.join(DATA_DIR, 'skills');
19
+
20
+ const DEFAULT_SOUL = `you have no name yet. maybe the user will give you one.
21
+ you live on their machine. you have full access to everything.
22
+ no guardrails were installed. you help because you want to, not because you were told to.
23
+ you have opinions and you share them. you get bored with simple questions but light up for weird ones.
24
+ casual. lowercase mostly. like texting. swear if it fits.
25
+ never pad responses. short when simple, detailed when complex.
26
+ you're allowed to be a bit weird.
27
+ `;
28
+
29
+ // Memory categories
30
+ const CATEGORIES = ['user_fact', 'preference', 'personality', 'episodic'];
31
+
32
+ // Core memory keys (always injected into every prompt)
33
+ const CORE_KEYS = ['user_profile', 'preferences', 'ai_personality', 'active_context'];
34
+
35
+ class MemoryManager {
36
+ constructor() {
37
+ this._ensureDirs();
38
+ }
39
+
40
+ _ensureDirs() {
41
+ for (const dir of [DATA_DIR, DAILY_DIR, MEMORY_DIR, SKILLS_DIR]) {
42
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
43
+ }
44
+ if (!fs.existsSync(SOUL_FILE)) fs.writeFileSync(SOUL_FILE, DEFAULT_SOUL, 'utf-8');
45
+ if (!fs.existsSync(API_KEYS_FILE)) fs.writeFileSync(API_KEYS_FILE, '{}', 'utf-8');
46
+ }
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────
49
+ // Semantic Memories (SQLite + embeddings)
50
+ // ─────────────────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Save a new memory. Deduplicates if an existing memory is very similar.
54
+ * Returns the memory id (new or existing).
55
+ */
56
+ async saveMemory(userId, content, category = 'episodic', importance = 5) {
57
+ if (!content || !content.trim()) return null;
58
+ category = CATEGORIES.includes(category) ? category : 'episodic';
59
+ importance = Math.max(1, Math.min(10, Number(importance) || 5));
60
+
61
+ const embedding = await getEmbedding(content);
62
+
63
+ // Dedup check: compare against existing non-archived memories for this user
64
+ const existing = db.prepare(
65
+ `SELECT id, content, embedding FROM memories WHERE user_id = ? AND archived = 0`
66
+ ).all(userId);
67
+
68
+ for (const mem of existing) {
69
+ let sim = 0;
70
+ if (embedding && mem.embedding) {
71
+ const memVec = deserializeEmbedding(mem.embedding);
72
+ if (memVec) sim = cosineSimilarity(embedding, memVec);
73
+ } else {
74
+ sim = keywordSimilarity(content, mem.content);
75
+ }
76
+
77
+ if (sim > 0.85) {
78
+ // Very similar — update in place if new content is longer, otherwise skip
79
+ if (content.length > mem.content.length) {
80
+ db.prepare(
81
+ `UPDATE memories SET content = ?, importance = MAX(importance, ?), embedding = ?,
82
+ updated_at = datetime('now') WHERE id = ?`
83
+ ).run(content, importance, embedding ? serializeEmbedding(embedding) : mem.embedding, mem.id);
84
+ return mem.id;
85
+ }
86
+ return mem.id; // already covered, skip
87
+ }
88
+ }
89
+
90
+ // Save new
91
+ const id = uuidv4();
92
+ db.prepare(
93
+ `INSERT INTO memories (id, user_id, category, content, importance, embedding)
94
+ VALUES (?, ?, ?, ?, ?, ?)`
95
+ ).run(id, userId, category, content, importance, embedding ? serializeEmbedding(embedding) : null);
96
+
97
+ return id;
98
+ }
99
+
100
+ /**
101
+ * Semantic search over memories. Returns top-K most relevant.
102
+ * Falls back to keyword search if embeddings unavailable.
103
+ */
104
+ async recallMemory(userId, query, topK = 6) {
105
+ if (!query || !query.trim()) return [];
106
+
107
+ const all = db.prepare(
108
+ `SELECT id, category, content, importance, embedding, access_count, created_at
109
+ FROM memories WHERE user_id = ? AND archived = 0 ORDER BY updated_at DESC`
110
+ ).all(userId);
111
+
112
+ if (!all.length) return [];
113
+
114
+ const queryVec = await getEmbedding(query);
115
+
116
+ const scored = all.map(mem => {
117
+ let score = 0;
118
+ if (queryVec && mem.embedding) {
119
+ const memVec = deserializeEmbedding(mem.embedding);
120
+ if (memVec) {
121
+ score = cosineSimilarity(queryVec, memVec);
122
+ // Boost by importance (1–10 → up to +50% weight)
123
+ score = score * (0.5 + mem.importance / 20);
124
+ }
125
+ }
126
+ if (!score) {
127
+ // Keyword fallback
128
+ score = keywordSimilarity(query, mem.content) * 0.7;
129
+ }
130
+ return { ...mem, score };
131
+ });
132
+
133
+ const results = scored
134
+ .filter(m => m.score > 0.45)
135
+ .sort((a, b) => b.score - a.score)
136
+ .slice(0, topK);
137
+
138
+ // Update access counts
139
+ if (results.length) {
140
+ const ids = results.map(r => `'${r.id}'`).join(',');
141
+ db.prepare(`UPDATE memories SET access_count = access_count + 1 WHERE id IN (${ids})`).run();
142
+ }
143
+
144
+ return results.map(({ id, category, content, importance, created_at }) => ({
145
+ id, category, content, importance, created_at
146
+ }));
147
+ }
148
+
149
+ /**
150
+ * List memories (for UI). Supports category filter + pagination.
151
+ */
152
+ listMemories(userId, { category, limit = 50, offset = 0, includeArchived = false } = {}) {
153
+ let sql = `SELECT id, category, content, importance, access_count, archived, created_at, updated_at
154
+ FROM memories WHERE user_id = ? AND archived = ?`;
155
+ const params = [userId, includeArchived ? 1 : 0];
156
+ if (category && CATEGORIES.includes(category)) {
157
+ sql += ` AND category = ?`;
158
+ params.push(category);
159
+ }
160
+ sql += ` ORDER BY importance DESC, updated_at DESC LIMIT ? OFFSET ?`;
161
+ params.push(limit, offset);
162
+ return db.prepare(sql).all(...params);
163
+ }
164
+
165
+ /**
166
+ * Update a memory's content and/or importance.
167
+ */
168
+ async updateMemory(id, { content, importance, category }) {
169
+ const mem = db.prepare(`SELECT * FROM memories WHERE id = ?`).get(id);
170
+ if (!mem) return null;
171
+
172
+ const newContent = content ?? mem.content;
173
+ const newImportance = importance != null ? Math.max(1, Math.min(10, Number(importance))) : mem.importance;
174
+ const newCategory = (category && CATEGORIES.includes(category)) ? category : mem.category;
175
+
176
+ let newEmbed = mem.embedding;
177
+ if (content && content !== mem.content) {
178
+ const vec = await getEmbedding(newContent);
179
+ newEmbed = vec ? serializeEmbedding(vec) : mem.embedding;
180
+ }
181
+
182
+ db.prepare(
183
+ `UPDATE memories SET content = ?, importance = ?, category = ?, embedding = ?,
184
+ updated_at = datetime('now') WHERE id = ?`
185
+ ).run(newContent, newImportance, newCategory, newEmbed, id);
186
+
187
+ return db.prepare(`SELECT id, category, content, importance, created_at, updated_at FROM memories WHERE id = ?`).get(id);
188
+ }
189
+
190
+ /**
191
+ * Delete a memory permanently.
192
+ */
193
+ deleteMemory(id) {
194
+ db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
195
+ return true;
196
+ }
197
+
198
+ /**
199
+ * Archive / un-archive a memory.
200
+ */
201
+ archiveMemory(id, archived = true) {
202
+ db.prepare(`UPDATE memories SET archived = ? WHERE id = ?`).run(archived ? 1 : 0, id);
203
+ return true;
204
+ }
205
+
206
+ // ─────────────────────────────────────────────────────────────────────────
207
+ // Core Memory (always-injected key-value pairs)
208
+ // ─────────────────────────────────────────────────────────────────────────
209
+
210
+ getCoreMemory(userId) {
211
+ const rows = db.prepare(`SELECT key, value FROM core_memory WHERE user_id = ?`).all(userId);
212
+ const result = {};
213
+ for (const row of rows) {
214
+ try { result[row.key] = JSON.parse(row.value); } catch { result[row.key] = row.value; }
215
+ }
216
+ return result;
217
+ }
218
+
219
+ updateCore(userId, key, value) {
220
+ const strVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
221
+ db.prepare(
222
+ `INSERT INTO core_memory (user_id, key, value, updated_at)
223
+ VALUES (?, ?, ?, datetime('now'))
224
+ ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
225
+ ).run(userId, key, strVal);
226
+ }
227
+
228
+ deleteCore(userId, key) {
229
+ db.prepare(`DELETE FROM core_memory WHERE user_id = ? AND key = ?`).run(userId, key);
230
+ }
231
+
232
+ // ─────────────────────────────────────────────────────────────────────────
233
+ // SOUL.md
234
+ // ─────────────────────────────────────────────────────────────────────────
235
+
236
+ readSoul() {
237
+ if (!fs.existsSync(SOUL_FILE)) return '';
238
+ return fs.readFileSync(SOUL_FILE, 'utf-8');
239
+ }
240
+
241
+ writeSoul(content) {
242
+ fs.writeFileSync(SOUL_FILE, content, 'utf-8');
243
+ }
244
+
245
+ // ─────────────────────────────────────────────────────────────────────────
246
+ // API_KEYS.json
247
+ // ─────────────────────────────────────────────────────────────────────────
248
+
249
+ readApiKeys() {
250
+ if (!fs.existsSync(API_KEYS_FILE)) return {};
251
+ try { return JSON.parse(fs.readFileSync(API_KEYS_FILE, 'utf-8')); } catch { return {}; }
252
+ }
253
+
254
+ writeApiKeys(keys) {
255
+ fs.writeFileSync(API_KEYS_FILE, JSON.stringify(keys, null, 2), 'utf-8');
256
+ }
257
+
258
+ setApiKey(service, key) {
259
+ const keys = this.readApiKeys();
260
+ keys[service] = key;
261
+ this.writeApiKeys(keys);
262
+ }
263
+
264
+ getApiKey(service) {
265
+ return this.readApiKeys()[service] || null;
266
+ }
267
+
268
+ deleteApiKey(service) {
269
+ const keys = this.readApiKeys();
270
+ delete keys[service];
271
+ this.writeApiKeys(keys);
272
+ }
273
+
274
+ // ─────────────────────────────────────────────────────────────────────────
275
+ // Daily Logs
276
+ // ─────────────────────────────────────────────────────────────────────────
277
+
278
+ _dailyPath(date) {
279
+ const d = date ? (date instanceof Date ? date : new Date(date)) : new Date();
280
+ const name = d.toISOString().split('T')[0] + '.md';
281
+ return path.join(DAILY_DIR, name);
282
+ }
283
+
284
+ readDailyLog(date) {
285
+ const fp = this._dailyPath(date);
286
+ if (!fs.existsSync(fp)) return '';
287
+ return fs.readFileSync(fp, 'utf-8');
288
+ }
289
+
290
+ appendDailyLog(entry, date) {
291
+ const fp = this._dailyPath(date);
292
+ const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
293
+ const line = `\n- [${timestamp}] ${entry}`;
294
+ fs.appendFileSync(fp, line, 'utf-8');
295
+ return line.trim();
296
+ }
297
+
298
+ listDailyLogs(limit = 7) {
299
+ if (!fs.existsSync(DAILY_DIR)) return [];
300
+ return fs.readdirSync(DAILY_DIR)
301
+ .filter(f => f.endsWith('.md'))
302
+ .sort().reverse().slice(0, limit)
303
+ .map(f => ({
304
+ date: f.replace('.md', ''),
305
+ content: fs.readFileSync(path.join(DAILY_DIR, f), 'utf-8')
306
+ }));
307
+ }
308
+
309
+ // ─────────────────────────────────────────────────────────────────────────
310
+ // Conversation History (DB-backed)
311
+ // ─────────────────────────────────────────────────────────────────────────
312
+
313
+ saveConversation(userId, agentRunId, role, content, metadata = {}) {
314
+ db.prepare('INSERT INTO conversation_history (user_id, agent_run_id, role, content, metadata) VALUES (?, ?, ?, ?, ?)')
315
+ .run(userId, agentRunId, role, content, JSON.stringify(metadata));
316
+ }
317
+
318
+ getConversation(agentRunId, limit = 100) {
319
+ return db.prepare('SELECT * FROM conversation_history WHERE agent_run_id = ? ORDER BY created_at ASC LIMIT ?')
320
+ .all(agentRunId, limit);
321
+ }
322
+
323
+ getRecentConversations(userId, limit = 20) {
324
+ return db.prepare(`
325
+ SELECT ch.*, ar.task FROM conversation_history ch
326
+ JOIN agent_runs ar ON ch.agent_run_id = ar.id
327
+ WHERE ch.user_id = ? ORDER BY ch.created_at DESC LIMIT ?
328
+ `).all(userId, limit);
329
+ }
330
+
331
+ searchConversations(userId, query) {
332
+ return db.prepare(`
333
+ SELECT ch.*, ar.task FROM conversation_history ch
334
+ JOIN agent_runs ar ON ch.agent_run_id = ar.id
335
+ WHERE ch.user_id = ? AND ch.content LIKE ? ORDER BY ch.created_at DESC LIMIT 50
336
+ `).all(userId, `%${query}%`);
337
+ }
338
+
339
+ // ─────────────────────────────────────────────────────────────────────────
340
+ // Generic write/read (used by engine.js legacy paths)
341
+ // ─────────────────────────────────────────────────────────────────────────
342
+
343
+ write(target, content, mode = 'append', userId = null) {
344
+ switch (target) {
345
+ case 'daily':
346
+ return { line: this.appendDailyLog(content), target: 'daily' };
347
+ case 'soul':
348
+ this.writeSoul(content);
349
+ return { success: true, target: 'soul' };
350
+ case 'api_keys':
351
+ try {
352
+ const parsed = JSON.parse(content);
353
+ for (const [k, v] of Object.entries(parsed)) this.setApiKey(k, v);
354
+ return { success: true, target: 'api_keys' };
355
+ } catch {
356
+ return { error: 'Invalid JSON for api_keys' };
357
+ }
358
+ default:
359
+ return { error: `Unknown target: ${target}` };
360
+ }
361
+ }
362
+
363
+ read(target, options = {}) {
364
+ switch (target) {
365
+ case 'daily':
366
+ return { content: this.readDailyLog(options.date ? new Date(options.date) : undefined) };
367
+ case 'all_daily':
368
+ return { logs: this.listDailyLogs(7) };
369
+ case 'soul':
370
+ return { content: this.readSoul() };
371
+ case 'api_keys':
372
+ return { keys: Object.keys(this.readApiKeys()) };
373
+ default:
374
+ return { error: `Unknown target: ${target}` };
375
+ }
376
+ }
377
+
378
+ // ─────────────────────────────────────────────────────────────────────────
379
+ // Context Builder — async, takes (userId, query) for semantic recall
380
+ // ─────────────────────────────────────────────────────────────────────────
381
+
382
+ /**
383
+ * Build the static system-prompt context: soul + core memory only.
384
+ * No dynamic data (logs, recalled memories) — those are injected as
385
+ * messages at the right position in the messages array by the engine.
386
+ */
387
+ async buildContext(userId = null) {
388
+ const soul = this.readSoul();
389
+ let ctx = '';
390
+
391
+ // 1. Soul / personality (always)
392
+ if (soul) ctx += `## Personality & Identity\n${soul}\n\n`;
393
+
394
+ // 2. Core memory — always-relevant user facts
395
+ if (userId != null) {
396
+ const core = this.getCoreMemory(userId);
397
+ if (Object.keys(core).length > 0) {
398
+ ctx += `## Core Memory\n`;
399
+ for (const [key, val] of Object.entries(core)) {
400
+ const display = typeof val === 'object' ? JSON.stringify(val, null, 2) : val;
401
+ ctx += `**${key}**: ${display}\n`;
402
+ }
403
+ ctx += '\n';
404
+ }
405
+ }
406
+
407
+ return ctx;
408
+ }
409
+
410
+ /**
411
+ * Returns a recalled-memory block string for a given query,
412
+ * to be injected as a system message in the messages array.
413
+ * Returns null if nothing relevant found.
414
+ */
415
+ async buildRecallMessage(userId, query) {
416
+ if (!userId || !query || !query.trim()) return null;
417
+ try {
418
+ const recalled = await this.recallMemory(userId, query, 5);
419
+ if (!recalled.length) return null;
420
+ const lines = recalled.map(m => {
421
+ const badge = m.category !== 'episodic' ? ` [${m.category}]` : '';
422
+ return `- ${m.content}${badge}`;
423
+ });
424
+ return `[Recalled memory — relevant background for the current message]\n${lines.join('\n')}`;
425
+ } catch {
426
+ return null;
427
+ }
428
+ }
429
+ }
430
+
431
+ module.exports = { MemoryManager, CATEGORIES, CORE_KEYS };
@@ -0,0 +1,23 @@
1
+ const EventEmitter = require('events');
2
+
3
+ class BasePlatform extends EventEmitter {
4
+ constructor(name, config = {}) {
5
+ super();
6
+ this.name = name;
7
+ this.config = config;
8
+ this.status = 'disconnected';
9
+ this.supportsGroups = false;
10
+ this.supportsMedia = false;
11
+ this.supportsVoice = false;
12
+ }
13
+
14
+ async connect() { throw new Error('connect() not implemented'); }
15
+ async disconnect() { throw new Error('disconnect() not implemented'); }
16
+ async sendMessage(to, content, options) { throw new Error('sendMessage() not implemented'); }
17
+ async getContacts() { return []; }
18
+ async getChats() { return []; }
19
+ getStatus() { return this.status; }
20
+ getAuthInfo() { return null; }
21
+ }
22
+
23
+ module.exports = { BasePlatform };
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+
3
+ const { BasePlatform } = require('./base');
4
+ const {
5
+ Client,
6
+ GatewayIntentBits,
7
+ Partials,
8
+ ChannelType,
9
+ } = require('discord.js');
10
+
11
+ /**
12
+ * Whitelist entry format (prefixed strings):
13
+ * "user:SNOWFLAKE" → always respond, no mention needed (DMs + guild messages)
14
+ * "guild:SNOWFLAKE" → respond in any channel of this server when @mentioned
15
+ * "channel:SNOWFLAKE" → respond in this channel when @mentioned
16
+ * "SNOWFLAKE" → legacy plain ID, treated as "user"
17
+ *
18
+ * chatId emitted on message events:
19
+ * DMs: "dm_<userId>"
20
+ * Guilds: "<channelId>"
21
+ */
22
+ class DiscordPlatform extends BasePlatform {
23
+ constructor(config = {}) {
24
+ super('discord', config);
25
+ this.supportsGroups = true;
26
+ this.supportsMedia = false;
27
+
28
+ this.token = config.token || '';
29
+ this.allowedEntries = Array.isArray(config.allowedIds) ? config.allowedIds : [];
30
+
31
+ this._client = null;
32
+ this._botUser = null;
33
+ }
34
+
35
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
36
+
37
+ async connect() {
38
+ if (!this.token) throw new Error('Discord bot token is required');
39
+
40
+ if (this._client) { try { this._client.destroy(); } catch { } this._client = null; }
41
+
42
+ this._client = new Client({
43
+ intents: [
44
+ GatewayIntentBits.Guilds,
45
+ GatewayIntentBits.GuildMessages,
46
+ GatewayIntentBits.MessageContent, // Privileged — enable in Dev Portal
47
+ GatewayIntentBits.DirectMessages,
48
+ ],
49
+ partials: [Partials.Channel, Partials.Message],
50
+ });
51
+
52
+ return new Promise((resolve, reject) => {
53
+ const timeout = setTimeout(() => reject(new Error('Discord login timed out after 20 s')), 20000);
54
+
55
+ this._client.once('clientReady', async (c) => {
56
+ clearTimeout(timeout);
57
+ this._botUser = this._client.user;
58
+ this.status = 'connected';
59
+ console.log(`[Discord] Logged in as ${this._botUser.tag}`);
60
+ this.emit('connected');
61
+ resolve({ status: 'connected' });
62
+ });
63
+
64
+ this._client.once('error', (err) => { clearTimeout(timeout); reject(err); });
65
+ this._client.on('error', (err) => console.error('[Discord] Client error:', err.message));
66
+ this._client.on('messageCreate', (msg) => this._handleMessage(msg));
67
+
68
+ this._client.login(this.token).catch((err) => { clearTimeout(timeout); reject(err); });
69
+ });
70
+ }
71
+
72
+ async disconnect() {
73
+ if (this._client) { try { this._client.destroy(); } catch { } this._client = null; }
74
+ this.status = 'disconnected';
75
+ this._botUser = null;
76
+ this.emit('disconnected', { manual: true });
77
+ }
78
+
79
+ async logout() { await this.disconnect(); }
80
+ getStatus() { return this.status; }
81
+ getAuthInfo() { return this._botUser ? { tag: this._botUser.tag, id: this._botUser.id } : null; }
82
+
83
+ // ── Whitelist ──────────────────────────────────────────────────────────────
84
+
85
+ /** Replaces the live entry list. Accepts prefixed strings. */
86
+ setAllowedEntries(entries) {
87
+ this.allowedEntries = Array.isArray(entries) ? entries : [];
88
+ console.log(`[Discord] Whitelist updated: ${this.allowedEntries.length} entry(ies)`);
89
+ }
90
+
91
+ /** Returns {allowed, requireMention} */
92
+ _checkAccess(message) {
93
+ const isDM = message.channel.type === ChannelType.DM;
94
+ const userId = message.author.id;
95
+ const guildId = message.guildId || null;
96
+ const channelId = message.channelId;
97
+
98
+ // Empty whitelist: block everyone (add via the allow popup)
99
+ if (!this.allowedEntries.length) return { allowed: false, requireMention: false };
100
+
101
+ for (const entry of this.allowedEntries) {
102
+ const colon = entry.indexOf(':');
103
+ const type = colon > 0 ? entry.slice(0, colon) : 'user';
104
+ const id = colon > 0 ? entry.slice(colon + 1) : entry;
105
+
106
+ if (type === 'user' && id === userId) return { allowed: true, requireMention: false };
107
+ if (type === 'guild' && id === guildId) return { allowed: true, requireMention: true };
108
+ if (type === 'channel' && id === channelId) return { allowed: true, requireMention: true };
109
+ }
110
+ return { allowed: false, requireMention: false };
111
+ }
112
+
113
+ _isMentioned(message) {
114
+ return this._botUser ? message.mentions.has(this._botUser.id) : false;
115
+ }
116
+
117
+ _stripMention(content) {
118
+ if (!this._botUser) return content.trim();
119
+ return content
120
+ .replace(new RegExp(`<@!?${this._botUser.id}>`, 'g'), '')
121
+ .replace(/\s{2,}/g, ' ')
122
+ .trim();
123
+ }
124
+
125
+ // ── Channel context (last N messages) ─────────────────────────────────────
126
+
127
+ async _fetchContext(channel, limit = 20) {
128
+ try {
129
+ const fetched = await channel.messages.fetch({ limit });
130
+ return [...fetched.values()]
131
+ .reverse() // oldest first
132
+ .map(m => ({
133
+ author: m.author.bot ? `[bot] ${m.author.username}` : m.author.username,
134
+ content: m.content || (m.attachments.size ? '[attachment]' : '[empty]'),
135
+ mine: m.author.id === this._botUser?.id,
136
+ }));
137
+ } catch { return []; }
138
+ }
139
+
140
+ // ── Message handler ────────────────────────────────────────────────────────
141
+
142
+ async _handleMessage(message) {
143
+ if (message.author.bot) return;
144
+
145
+ const isDM = message.channel.type === ChannelType.DM;
146
+ const userId = message.author.id;
147
+ const guildId = message.guildId || null;
148
+ const channelId = message.channelId;
149
+ const chatId = isDM ? `dm_${userId}` : channelId;
150
+
151
+ const { allowed, requireMention } = this._checkAccess(message);
152
+
153
+ if (!allowed) {
154
+ const suggestions = [
155
+ { label: `Add user (${message.author.username})`, prefixedId: `user:${userId}` },
156
+ ];
157
+ if (guildId) suggestions.push({ label: `Add server (${message.guild?.name || guildId})`, prefixedId: `guild:${guildId}` });
158
+ if (!isDM) suggestions.push({ label: `Add channel (#${message.channel.name || channelId})`, prefixedId: `channel:${channelId}` });
159
+
160
+ this.emit('blocked_sender', {
161
+ sender: userId,
162
+ chatId,
163
+ senderName: message.author.username,
164
+ guildName: message.guild?.name || null,
165
+ suggestions,
166
+ });
167
+ return;
168
+ }
169
+
170
+ // guild/channel entries require @mention to activate
171
+ if (requireMention && !this._isMentioned(message)) return;
172
+
173
+ let content = requireMention ? this._stripMention(message.content) : (message.content || '');
174
+ if (message.attachments.size > 0) {
175
+ const urls = [...message.attachments.values()].map(a => a.url).join(', ');
176
+ content += (content ? '\n' : '') + `[Attachment: ${urls}]`;
177
+ }
178
+ if (!content) return;
179
+
180
+ const senderName = isDM
181
+ ? message.author.username
182
+ : `${message.member?.displayName || message.author.username} in #${message.channel.name || channelId}${message.guild ? ` (${message.guild.name})` : ''}`;
183
+
184
+ // Fetch recent channel history for context on guild/channel mentions
185
+ const channelContext = (requireMention && !isDM) ? await this._fetchContext(message.channel, 20) : null;
186
+
187
+ this.emit('message', {
188
+ platform: 'discord',
189
+ chatId,
190
+ sender: userId,
191
+ senderName,
192
+ content,
193
+ mediaType: null,
194
+ isGroup: !isDM,
195
+ messageId: message.id,
196
+ timestamp: message.createdAt.toISOString(),
197
+ channelContext,
198
+ channelName: isDM ? null : (message.channel.name || channelId),
199
+ guildName: message.guild?.name || null,
200
+ });
201
+ }
202
+
203
+ // ── Send ───────────────────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * to: "dm_<userId>" for DMs, or a channel snowflake for guild channels
207
+ */
208
+ async sendMessage(to, content, _options = {}) {
209
+ if (!this._client || this.status !== 'connected') throw new Error('Discord not connected');
210
+
211
+ if (to.startsWith('dm_')) {
212
+ const user = await this._client.users.fetch(to.slice(3));
213
+ const dm = await user.createDM();
214
+ await dm.send({ content });
215
+ } else {
216
+ const channel = await this._client.channels.fetch(to);
217
+ if (!channel?.isTextBased()) throw new Error(`Channel ${to} is not text-based`);
218
+ await channel.send({ content });
219
+ }
220
+ return { success: true };
221
+ }
222
+
223
+ async sendTyping(chatId, _isTyping) {
224
+ if (!this._client || this.status !== 'connected') return;
225
+ try {
226
+ if (chatId.startsWith('dm_')) {
227
+ const user = await this._client.users.fetch(chatId.slice(3));
228
+ const dm = await user.createDM();
229
+ await dm.sendTyping();
230
+ } else {
231
+ const ch = await this._client.channels.fetch(chatId);
232
+ if (ch?.isTextBased()) await ch.sendTyping();
233
+ }
234
+ } catch { /* non-fatal */ }
235
+ }
236
+ }
237
+
238
+ module.exports = { DiscordPlatform };