@sym-bot/sym 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const http = require('http');
5
+ const crypto = require('crypto');
6
+ const { SymNode } = require('../../lib/node');
7
+
8
+ // ── Configuration ──────────────────────────────────────────────
9
+
10
+ const token = process.env.TELEGRAM_BOT_TOKEN;
11
+ if (!token) {
12
+ console.error('Set TELEGRAM_BOT_TOKEN environment variable');
13
+ process.exit(1);
14
+ }
15
+
16
+ const WEBHOOK_URL = process.env.WEBHOOK_URL;
17
+ if (!WEBHOOK_URL) {
18
+ console.error('Set WEBHOOK_URL environment variable (e.g. https://sym-telegram-bot.onrender.com)');
19
+ process.exit(1);
20
+ }
21
+
22
+ const PORT = Number(process.env.PORT) || 3000;
23
+ const RELAY_URL = process.env.SYM_RELAY_URL || 'wss://sym-relay.onrender.com';
24
+ const RELAY_TOKEN = process.env.SYM_RELAY_TOKEN || null;
25
+ const MAX_SESSIONS = Number(process.env.MAX_SESSIONS) || 100;
26
+ const IDLE_TIMEOUT_MS = Number(process.env.IDLE_TIMEOUT_MS) || 30 * 60 * 1000; // 30 min
27
+
28
+ // Webhook secret path — hash of bot token prevents unauthorized posts.
29
+ const WEBHOOK_PATH = `/webhook/${crypto.createHash('sha256').update(token).digest('hex').slice(0, 16)}`;
30
+
31
+ const TG_API = `https://api.telegram.org/bot${token}`;
32
+
33
+ // ── Telegram Bot API (zero dependencies) ──────────────────────
34
+
35
+ async function tgSendMessage(chatId, text) {
36
+ try {
37
+ await fetch(`${TG_API}/sendMessage`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ chat_id: chatId, text }),
41
+ });
42
+ } catch {}
43
+ }
44
+
45
+ async function tgSetWebhook(url) {
46
+ const res = await fetch(`${TG_API}/setWebhook`, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({ url }),
50
+ });
51
+ const data = await res.json();
52
+ if (!data.ok) throw new Error(data.description || 'setWebhook failed');
53
+ }
54
+
55
+ // ── Message Router ────────────────────────────────────────────
56
+
57
+ function handleUpdate(update) {
58
+ const msg = update.message;
59
+ if (!msg || !msg.text) return;
60
+
61
+ const chatId = msg.chat.id;
62
+ const text = msg.text;
63
+
64
+ // Route commands
65
+ if (text.startsWith('/start')) return handleStart(chatId);
66
+ if (text === '/stop') return handleStop(chatId);
67
+ if (text === '/peers') return handlePeers(chatId);
68
+ if (text.startsWith('/mood ')) return handleMood(chatId, text.slice(6));
69
+ if (text.startsWith('/remember ')) return handleRemember(chatId, text.slice(10));
70
+ if (text.startsWith('/recall ')) return handleRecall(chatId, text.slice(8));
71
+ if (!text.startsWith('/')) return handlePlainText(chatId, text);
72
+ }
73
+
74
+ // ── Session Manager ────────────────────────────────────────────
75
+
76
+ const sessions = new Map(); // chatId → { node, lastActivity, couplingDecisions }
77
+
78
+ function getSession(chatId) {
79
+ const session = sessions.get(chatId);
80
+ if (session) session.lastActivity = Date.now();
81
+ return session || null;
82
+ }
83
+
84
+ async function createSession(chatId) {
85
+ // Evict oldest idle session if at capacity
86
+ if (sessions.size >= MAX_SESSIONS) {
87
+ let oldest = null;
88
+ for (const [id, s] of sessions) {
89
+ if (!oldest || s.lastActivity < oldest.activity) {
90
+ oldest = { id, activity: s.lastActivity };
91
+ }
92
+ }
93
+ if (oldest) await destroySession(oldest.id);
94
+ }
95
+
96
+ const nodeName = `tg-${chatId}`;
97
+ const node = new SymNode({
98
+ name: nodeName,
99
+ cognitiveProfile: 'Telegram user mesh bridge — relays mood signals and messages between a Telegram user and the SYM mesh',
100
+ relay: RELAY_URL,
101
+ relayToken: RELAY_TOKEN,
102
+ moodThreshold: 0.8,
103
+ silent: true,
104
+ });
105
+
106
+ const session = {
107
+ node,
108
+ lastActivity: Date.now(),
109
+ couplingDecisions: new Map(),
110
+ };
111
+
112
+ // ── Mesh Events → This Chat Only ──────────────────────────
113
+ node.on('peer-joined', ({ name }) => {
114
+ tgSendMessage(chatId, `[mesh] ${name} joined`);
115
+ });
116
+
117
+ node.on('peer-left', ({ name }) => {
118
+ tgSendMessage(chatId, `[mesh] ${name} left`);
119
+ });
120
+
121
+ node.on('message', (from, content) => {
122
+ tgSendMessage(chatId, `[${from}] ${content}`);
123
+ });
124
+
125
+ node.on('mood-accepted', ({ from, mood, drift }) => {
126
+ tgSendMessage(chatId, `[mood] ${from}: ${mood} (drift: ${drift.toFixed(3)})`);
127
+ });
128
+
129
+ node.on('memory-received', ({ from, entry }) => {
130
+ tgSendMessage(chatId, `[memory] ${from}: ${entry.content}`);
131
+ });
132
+
133
+ node.on('coupling-decision', ({ peer, decision, drift }) => {
134
+ const prev = session.couplingDecisions.get(peer);
135
+ if (prev === decision) return;
136
+ session.couplingDecisions.set(peer, decision);
137
+ tgSendMessage(chatId, `[coupling] ${peer}: ${decision} (drift: ${drift.toFixed(3)})`);
138
+ });
139
+
140
+ sessions.set(chatId, session);
141
+ await node.start();
142
+ console.log(`Session started: ${nodeName} (${sessions.size}/${MAX_SESSIONS})`);
143
+ return session;
144
+ }
145
+
146
+ async function destroySession(chatId) {
147
+ const session = sessions.get(chatId);
148
+ if (!session) return;
149
+ session.node.stop();
150
+ sessions.delete(chatId);
151
+ console.log(`Session stopped: tg-${chatId} (${sessions.size}/${MAX_SESSIONS})`);
152
+ }
153
+
154
+ // ── Idle Cleanup ───────────────────────────────────────────────
155
+
156
+ setInterval(() => {
157
+ const now = Date.now();
158
+ for (const [chatId, session] of sessions) {
159
+ if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
160
+ tgSendMessage(chatId, 'Mesh session idle — disconnected. Send /start to reconnect.');
161
+ destroySession(chatId);
162
+ }
163
+ }
164
+ }, 60_000);
165
+
166
+ // ── Command Handlers ──────────────────────────────────────────
167
+
168
+ async function handleStart(chatId) {
169
+ let session = getSession(chatId);
170
+ if (!session) {
171
+ session = await createSession(chatId);
172
+ }
173
+
174
+ tgSendMessage(chatId, [
175
+ `Connected to SYM mesh as "${session.node.name}".`,
176
+ '',
177
+ 'Commands:',
178
+ '/peers — connected mesh peers',
179
+ '/mood <text> — broadcast mood to mesh',
180
+ '/remember <text> — store memory in mesh',
181
+ '/recall <query> — search mesh memories',
182
+ '/stop — disconnect from mesh',
183
+ '',
184
+ 'Or just type how you feel — it broadcasts to the mesh.',
185
+ ].join('\n'));
186
+ }
187
+
188
+ async function handleStop(chatId) {
189
+ if (!sessions.has(chatId)) {
190
+ tgSendMessage(chatId, 'No active session. Send /start to connect.');
191
+ return;
192
+ }
193
+ await destroySession(chatId);
194
+ tgSendMessage(chatId, 'Disconnected from SYM mesh.');
195
+ }
196
+
197
+ function handlePeers(chatId) {
198
+ const session = getSession(chatId);
199
+ if (!session) { tgSendMessage(chatId, 'Send /start first.'); return; }
200
+
201
+ const peers = session.node.peers();
202
+ if (peers.length === 0) {
203
+ tgSendMessage(chatId, 'No peers connected.');
204
+ return;
205
+ }
206
+ const lines = peers.map(p =>
207
+ `${p.name} — ${p.coupling} (drift: ${p.drift ?? 'pending'})`
208
+ );
209
+ tgSendMessage(chatId, lines.join('\n'));
210
+ }
211
+
212
+ function handleMood(chatId, mood) {
213
+ const session = getSession(chatId);
214
+ if (!session) { tgSendMessage(chatId, 'Send /start first.'); return; }
215
+
216
+ session.node.broadcastMood(mood);
217
+ session.node.remember(`User mood: ${mood}`, { tags: ['mood', 'telegram'] });
218
+ const peers = session.node.peers();
219
+ tgSendMessage(chatId, `Mood broadcast to ${peers.length} peer(s): "${mood}"`);
220
+ }
221
+
222
+ function handleRemember(chatId, content) {
223
+ const session = getSession(chatId);
224
+ if (!session) { tgSendMessage(chatId, 'Send /start first.'); return; }
225
+
226
+ const entry = session.node.remember(content, { tags: ['telegram'] });
227
+ const peers = session.node.peers();
228
+ const coupled = peers.filter(p => p.coupling !== 'rejected');
229
+ tgSendMessage(chatId, `Stored and shared with ${coupled.length}/${peers.length} peer(s). Key: ${entry.key}`);
230
+ }
231
+
232
+ async function handleRecall(chatId, query) {
233
+ const session = getSession(chatId);
234
+ if (!session) { tgSendMessage(chatId, 'Send /start first.'); return; }
235
+
236
+ // 1. Check Supabase for latest feed
237
+ const { digest, folder } = await getLatestFeed();
238
+
239
+ if (digest) {
240
+ tgSendMessage(chatId, digest);
241
+ return;
242
+ }
243
+
244
+ if (folder) {
245
+ // No digest yet — broadcast need to the mesh
246
+ tgSendMessage(chatId, 'Generating digest...');
247
+ session.node.send(`digest-needed:${folder}`);
248
+
249
+ // Listen for digest-ready from a mesh peer
250
+ const onMessage = async (from, content) => {
251
+ if (content === `digest-ready:${folder}`) {
252
+ session.node.removeListener('message', onMessage);
253
+ clearTimeout(timeout);
254
+ const { digest: d } = await getLatestFeed();
255
+ if (d) {
256
+ tgSendMessage(chatId, d);
257
+ } else {
258
+ tgSendMessage(chatId, 'Digest generated but could not be retrieved.');
259
+ }
260
+ }
261
+ };
262
+ session.node.on('message', onMessage);
263
+
264
+ // Timeout after 60s
265
+ const timeout = setTimeout(() => {
266
+ session.node.removeListener('message', onMessage);
267
+ tgSendMessage(chatId, 'Digest generation timed out. Try again later.');
268
+ }, 60000);
269
+ return;
270
+ }
271
+
272
+ // 2. Fall back to local mesh memories
273
+ const results = session.node.recall(query);
274
+ if (results.length === 0) {
275
+ tgSendMessage(chatId, 'No feed available yet today. Next fetch runs every 6 hours.');
276
+ return;
277
+ }
278
+ const lines = results.map(r => {
279
+ const source = r._source || r.source || 'unknown';
280
+ const tags = (r.tags || []).length > 0 ? ` (${r.tags.join(', ')})` : '';
281
+ return `[${source}] ${r.content}${tags}`;
282
+ });
283
+ tgSendMessage(chatId, lines.join('\n'));
284
+ }
285
+
286
+ // ── Supabase Knowledge Feed ──────────────────────────────────
287
+
288
+ const SUPABASE_URL = process.env.SUPABASE_URL;
289
+ const SUPABASE_KEY = process.env.SUPABASE_KEY;
290
+ const SUPABASE_BUCKET = process.env.SUPABASE_BUCKET || 'sym-knowledge-feed';
291
+
292
+ function supabaseHeaders() {
293
+ return { 'Authorization': `Bearer ${SUPABASE_KEY}`, 'apikey': SUPABASE_KEY };
294
+ }
295
+
296
+ function todayPrefix() {
297
+ const now = new Date();
298
+ return now.getFullYear().toString()
299
+ + String(now.getMonth() + 1).padStart(2, '0')
300
+ + String(now.getDate()).padStart(2, '0');
301
+ }
302
+
303
+ /**
304
+ * Get the latest feed folder and its digest (if available).
305
+ * Returns { folder, digest } — digest is null if not yet generated.
306
+ */
307
+ async function getLatestFeed() {
308
+ if (!SUPABASE_URL || !SUPABASE_KEY) return { folder: null, digest: null };
309
+
310
+ try {
311
+ const listRes = await fetch(
312
+ `${SUPABASE_URL}/storage/v1/object/list/${SUPABASE_BUCKET}`,
313
+ {
314
+ method: 'POST',
315
+ headers: { ...supabaseHeaders(), 'Content-Type': 'application/json' },
316
+ body: JSON.stringify({ prefix: 'twitter/', limit: 100, sortBy: { column: 'name', order: 'desc' } }),
317
+ }
318
+ );
319
+ if (!listRes.ok) return { folder: null, digest: null };
320
+ const folders = await listRes.json();
321
+ const todayFolders = folders.map(f => f.name).filter(n => n.startsWith(todayPrefix()));
322
+ if (todayFolders.length === 0) return { folder: null, digest: null };
323
+
324
+ const latest = todayFolders[0];
325
+
326
+ // Check for digest
327
+ const res = await fetch(
328
+ `${SUPABASE_URL}/storage/v1/object/${SUPABASE_BUCKET}/twitter/${latest}/digest.md`,
329
+ { headers: supabaseHeaders() }
330
+ );
331
+ if (res.ok) {
332
+ const text = await res.text();
333
+ return { folder: latest, digest: text };
334
+ }
335
+
336
+ return { folder: latest, digest: null };
337
+ } catch {
338
+ return { folder: null, digest: null };
339
+ }
340
+ }
341
+
342
+ function handlePlainText(chatId, text) {
343
+ const session = getSession(chatId);
344
+ if (!session) return;
345
+
346
+ session.node.broadcastMood(text);
347
+ session.node.remember(`User mood: ${text}`, { tags: ['mood', 'telegram'] });
348
+ const peers = session.node.peers();
349
+ tgSendMessage(chatId, `Mood broadcast to ${peers.length} peer(s)`);
350
+ }
351
+
352
+ // ── HTTP Server (webhook receiver + health) ───────────────────
353
+
354
+ const server = http.createServer((req, res) => {
355
+ if (req.method === 'GET' && req.url === '/health') {
356
+ res.writeHead(200, { 'Content-Type': 'application/json' });
357
+ res.end(JSON.stringify({
358
+ status: 'ok',
359
+ sessions: sessions.size,
360
+ maxSessions: MAX_SESSIONS,
361
+ uptime: process.uptime(),
362
+ }));
363
+ return;
364
+ }
365
+
366
+ if (req.method === 'POST' && req.url === WEBHOOK_PATH) {
367
+ let body = '';
368
+ req.on('data', (chunk) => { body += chunk; });
369
+ req.on('end', () => {
370
+ try {
371
+ const update = JSON.parse(body);
372
+ handleUpdate(update);
373
+ } catch (err) {
374
+ console.error('Failed to process webhook update:', err.message);
375
+ }
376
+ res.writeHead(200);
377
+ res.end('ok');
378
+ });
379
+ return;
380
+ }
381
+
382
+ res.writeHead(404);
383
+ res.end();
384
+ });
385
+
386
+ // ── Startup ────────────────────────────────────────────────────
387
+
388
+ server.listen(PORT, async () => {
389
+ const webhookUrl = `${WEBHOOK_URL}${WEBHOOK_PATH}`;
390
+ try {
391
+ await tgSetWebhook(webhookUrl);
392
+ console.log(`Webhook registered: ${WEBHOOK_URL}${WEBHOOK_PATH.slice(0, 20)}...`);
393
+ } catch (err) {
394
+ console.error('Failed to set webhook:', err.message);
395
+ process.exit(1);
396
+ }
397
+
398
+ console.log(`SYM Telegram bridge running (webhook mode, multi-tenant)`);
399
+ console.log(`HTTP server: port ${PORT}`);
400
+ console.log(`Relay: ${RELAY_URL}`);
401
+ console.log(`Max sessions: ${MAX_SESSIONS}, idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
402
+ });
403
+
404
+ // ── Graceful Shutdown ──────────────────────────────────────────
405
+
406
+ async function shutdown() {
407
+ console.log('Shutting down...');
408
+ // Keep webhook registered — Render free tier spins down on idle.
409
+ // Telegram's next POST will wake the instance, which re-registers on startup.
410
+ server.close();
411
+ for (const [, session] of sessions) {
412
+ session.node.stop();
413
+ }
414
+ process.exit(0);
415
+ }
416
+
417
+ process.on('SIGTERM', shutdown);
418
+ process.on('SIGINT', shutdown);