polygram 0.8.0 → 0.9.0-rc.2

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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/{agent-loader.js → agents/loader.js} +6 -8
  3. package/lib/{approvals.js → approvals/store.js} +28 -5
  4. package/lib/{approval-ui.js → approvals/ui.js} +1 -17
  5. package/lib/config.js +121 -0
  6. package/lib/{error-classify.js → error/classify.js} +25 -34
  7. package/lib/handlers/abort.js +89 -0
  8. package/lib/handlers/approvals.js +361 -0
  9. package/lib/handlers/autosteer.js +94 -0
  10. package/lib/handlers/config-callback.js +118 -0
  11. package/lib/handlers/config-ui.js +104 -0
  12. package/lib/handlers/dispatcher.js +263 -0
  13. package/lib/handlers/download.js +182 -0
  14. package/lib/handlers/extract-attachments.js +97 -0
  15. package/lib/handlers/ipc-send.js +80 -0
  16. package/lib/handlers/poll.js +140 -0
  17. package/lib/handlers/record-inbound.js +88 -0
  18. package/lib/handlers/slash-commands.js +319 -0
  19. package/lib/handlers/voice.js +107 -0
  20. package/lib/pm-interface.js +27 -29
  21. package/lib/sdk/build-options.js +177 -0
  22. package/lib/sdk/callbacks.js +213 -0
  23. package/lib/{process-manager-sdk.js → sdk/process-manager.js} +19 -31
  24. package/lib/{telegram.js → telegram/api.js} +2 -2
  25. package/lib/{telegram-prompt.js → telegram/display-hint.js} +0 -14
  26. package/lib/{stream-reply.js → telegram/streamer.js} +4 -4
  27. package/package.json +2 -3
  28. package/polygram.js +347 -2581
  29. package/scripts/doctor.js +1 -1
  30. package/scripts/ipc-smoke.js +1 -10
  31. package/bin/approval-hook.js +0 -113
  32. package/lib/approval-waiters.js +0 -201
  33. package/lib/pm-router.js +0 -201
  34. package/lib/process-manager.js +0 -806
  35. /package/lib/{auto-resume.js → db/auto-resume.js} +0 -0
  36. /package/lib/{inbox.js → db/inbox.js} +0 -0
  37. /package/lib/{pairings.js → db/pairings.js} +0 -0
  38. /package/lib/{replay-window.js → db/replay-window.js} +0 -0
  39. /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
  40. /package/lib/{sessions.js → db/sessions.js} +0 -0
  41. /package/lib/{net-errors.js → error/net.js} +0 -0
  42. /package/lib/{ipc-client.js → ipc/client.js} +0 -0
  43. /package/lib/{ipc-file-validator.js → ipc/file-validator.js} +0 -0
  44. /package/lib/{ipc-server.js → ipc/server.js} +0 -0
  45. /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
  46. /package/lib/{deliver.js → telegram/deliver.js} +0 -0
  47. /package/lib/{telegram-format.js → telegram/format.js} +0 -0
  48. /package/lib/{parse-response.js → telegram/parse.js} +0 -0
  49. /package/lib/{status-reactions.js → telegram/reactions.js} +0 -0
  50. /package/lib/{typing-indicator.js → telegram/typing.js} +0 -0
  51. /package/lib/{voice.js → telegram/voice.js} +0 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Manual long-poll loop for Telegram updates + a watchdog for
3
+ * stalled poll loops.
4
+ *
5
+ * Why manual instead of grammy's built-in `bot.start()`: we need
6
+ * to (a) restore the polling offset from the DB on boot so a
7
+ * restart doesn't re-process the backlog Telegram accumulated
8
+ * during downtime, (b) persist the offset after each batch so a
9
+ * crash mid-batch only risks re-processing the unacked updates,
10
+ * (c) drive a stall watchdog that logs to events when the loop
11
+ * hasn't ticked in 2 minutes (network flap / Telegram 5xx).
12
+ *
13
+ * Long-poll discipline: Telegram holds the connection up to 25s
14
+ * waiting for updates. When something arrives it returns
15
+ * immediately; empty windows cost ~0 local CPU. Median inbound
16
+ * latency drops vs short-poll-every-1s.
17
+ *
18
+ * Allowed update types: message, edited_message, callback_query.
19
+ * Polygram doesn't process channel posts, polls, etc. — gating
20
+ * here trims server load + simplifies the dispatch.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const POLL_STALL_MS = 120_000;
26
+
27
+ function createPollLoop({
28
+ db,
29
+ dbWrite,
30
+ config,
31
+ botName,
32
+ isWellFormedMessage,
33
+ getTopicName,
34
+ logger = console,
35
+ } = {}) {
36
+ async function pollBot(bot) {
37
+ await bot.init();
38
+ bot._setBotUsername(bot.botInfo.username);
39
+ logger.log?.(`[${botName}] Bot @${bot.botInfo.username} ready`);
40
+
41
+ await bot.api.deleteWebhook();
42
+
43
+ // Restore polling offset from DB so a restart doesn't re-process
44
+ // the backlog Telegram accumulated while we were down.
45
+ let offset = 0;
46
+ try {
47
+ const saved = db?.getPollingOffset?.(botName);
48
+ if (saved && saved > 0) {
49
+ offset = saved + 1;
50
+ logger.log?.(`[${botName}] resuming polling from update_id ${saved}`);
51
+ }
52
+ } catch (err) {
53
+ logger.error?.(`[${botName}] getPollingOffset failed: ${err.message}`);
54
+ }
55
+ let running = true;
56
+ bot._lastPollTs = Date.now();
57
+ bot._stop = () => { running = false; };
58
+
59
+ while (running) {
60
+ try {
61
+ const updates = await bot.api.getUpdates({
62
+ offset,
63
+ timeout: 25,
64
+ allowed_updates: ['message', 'edited_message', 'callback_query'],
65
+ });
66
+ bot._lastPollTs = Date.now();
67
+
68
+ for (const update of updates) {
69
+ offset = update.update_id + 1;
70
+ if (update.message && isWellFormedMessage(update.message)) {
71
+ const m = update.message;
72
+ const chatId = m.chat.id.toString();
73
+ const chatConfig = config.chats[chatId];
74
+ const threadId = m.message_thread_id?.toString();
75
+ const topicName = threadId
76
+ ? (chatConfig ? getTopicName(chatConfig, threadId) : threadId)
77
+ : null;
78
+ const chatLabel = chatConfig?.name || chatId;
79
+ const label = topicName ? `${chatLabel}/${topicName}` : chatLabel;
80
+ logger.log?.(`[${botName}] ← ${label}: ${(m.text || m.caption || '(media)').slice(0, 60)}`);
81
+ }
82
+ try {
83
+ await bot.handleUpdate(update);
84
+ } catch (err) {
85
+ logger.error?.(`[${botName}] Handler error: ${err.message}`);
86
+ }
87
+ }
88
+ // Persist offset after batch dispatch — only on non-empty
89
+ // batches to avoid churning the row on every 25s idle poll.
90
+ if (updates.length > 0) {
91
+ dbWrite(() => db.savePollingOffset(botName, updates[updates.length - 1].update_id),
92
+ 'save polling offset');
93
+ }
94
+ // No sleep on the success path: long-poll already blocks
95
+ // up to 25s when idle.
96
+ } catch (err) {
97
+ if (!running) break;
98
+ if (err.error_code === 409) {
99
+ logger.log?.(`[${botName}] 409, waiting 3s...`);
100
+ await new Promise((r) => setTimeout(r, 3000));
101
+ } else {
102
+ logger.error?.(`[${botName}] Poll error: ${err.message}`);
103
+ await new Promise((r) => setTimeout(r, 3000));
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Watchdog: if the poll loop hasn't ticked in POLL_STALL_MS, log
111
+ * an event so external monitoring (or `events` queries) can see
112
+ * it. We don't exit here — launchd restarts the process on
113
+ * death, but a stalled poll is usually transient.
114
+ */
115
+ function startPollWatchdog(bot, { logEvent } = {}) {
116
+ let stalled = false;
117
+ return setInterval(() => {
118
+ const now = Date.now();
119
+ const age = now - (bot._lastPollTs || 0);
120
+ if (age > POLL_STALL_MS) {
121
+ if (!stalled) {
122
+ logger.error?.(`[${botName}] poll-stalled: no tick in ${Math.round(age / 1000)}s`);
123
+ if (logEvent) logEvent('poll-stalled', { bot: botName, stall_ms: age });
124
+ stalled = true;
125
+ }
126
+ } else if (stalled) {
127
+ logger.log?.(`[${botName}] poll-recovered after stall`);
128
+ if (logEvent) logEvent('poll-recovered', { bot: botName });
129
+ stalled = false;
130
+ }
131
+ }, 30_000);
132
+ }
133
+
134
+ return { pollBot, startPollWatchdog };
135
+ }
136
+
137
+ module.exports = {
138
+ createPollLoop,
139
+ POLL_STALL_MS,
140
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Record an inbound Telegram message + its attachments to SQLite.
3
+ *
4
+ * Atomic: message + N-attachment writes wrapped in
5
+ * `db.raw.transaction` so a crash mid-write can't leave a message
6
+ * row with zero (or partial) attachment rows that boot-replay
7
+ * would silently treat as "no media."
8
+ *
9
+ * Edit-safe: Telegram fires recordInbound again for edited_message
10
+ * events (same chat_id + msg_id). If attachments already exist,
11
+ * we skip the attachment-insert pass — re-inserting would duplicate
12
+ * rows AND reset download_status back to 'pending', losing the
13
+ * local_path we already fetched.
14
+ *
15
+ * Best-effort: dbWrite swallows errors; recordInbound never
16
+ * throws. Late-arriving inbounds during shutdown (after db.raw.close())
17
+ * are explicitly short-circuited at the top.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ function createRecordInbound({
23
+ db,
24
+ dbWrite,
25
+ config,
26
+ botName,
27
+ extractAttachments,
28
+ } = {}) {
29
+ return function recordInbound(msg) {
30
+ if (!db) return;
31
+ const chatId = msg.chat.id.toString();
32
+ const threadId = msg.message_thread_id?.toString() || null;
33
+ const user = msg.from?.first_name || msg.from?.username || null;
34
+ const attachments = extractAttachments(msg);
35
+ const chatConfig = config.chats[chatId];
36
+ const ts = (msg.date || Math.floor(Date.now() / 1000)) * 1000;
37
+
38
+ // Atomic message + attachments write. db.raw.transaction
39
+ // collapses N+1 fsyncs into one commit — perf win for media
40
+ // groups (7-attachment album: 8 sync writes → 1).
41
+ const writeInbound = db.raw.transaction(() => {
42
+ db.insertMessage({
43
+ chat_id: chatId,
44
+ thread_id: threadId,
45
+ msg_id: msg.message_id,
46
+ user,
47
+ user_id: msg.from?.id || null,
48
+ text: msg.text || msg.caption || '',
49
+ reply_to_id: msg.reply_to_message?.message_id || null,
50
+ direction: 'in',
51
+ source: 'polygram',
52
+ bot_name: botName,
53
+ model: chatConfig?.model || null,
54
+ effort: chatConfig?.effort || null,
55
+ ts,
56
+ });
57
+
58
+ if (!attachments.length) return;
59
+ // Look up the just-inserted message row id so attachments
60
+ // can FK to it. lastInsertRowid is unreliable across the
61
+ // upsert path; explicit lookup is cheap and always correct.
62
+ const messageId = db.getInboundMessageId({ chat_id: chatId, msg_id: msg.message_id });
63
+ if (!messageId) return;
64
+ // Edit-safety: skip if attachments already persisted.
65
+ if (db.getAttachmentsByMessage(messageId).length > 0) return;
66
+ for (const att of attachments) {
67
+ db.insertAttachment({
68
+ message_id: messageId,
69
+ chat_id: chatId,
70
+ msg_id: msg.message_id,
71
+ thread_id: threadId,
72
+ bot_name: botName,
73
+ file_id: att.file_id,
74
+ file_unique_id: att.file_unique_id,
75
+ kind: att.kind,
76
+ name: att.name,
77
+ mime_type: att.mime_type,
78
+ size_bytes: att.size,
79
+ ts,
80
+ });
81
+ }
82
+ });
83
+
84
+ dbWrite(() => writeInbound(), `insert inbound ${chatId}/${msg.message_id}`);
85
+ };
86
+ }
87
+
88
+ module.exports = { createRecordInbound };
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Slash command dispatcher.
3
+ *
4
+ * Polygram supports these chat commands (gated on
5
+ * config.bot.allowConfigCommands except /pair which is its own auth):
6
+ *
7
+ * /context — on-demand SDK context-usage report
8
+ * /compact [hint] — manual SDK compaction with optional preserve hint
9
+ * /reload — close+respawn Query, preserves session_id
10
+ * /new, /reset — fresh session (resetSession clears session_id)
11
+ * /model X — switch model (X ∈ opus|sonnet|haiku)
12
+ * /effort X — switch effort (X ∈ low|medium|high|xhigh|max)
13
+ * /pair-code … — admin: issue a pairing code
14
+ * /pairings — admin: list active pairings
15
+ * /unpair <user> — admin: revoke pairings for a user
16
+ * /pair <code> — claim a pairing code (open, code is the auth)
17
+ *
18
+ * Returns true when the message was a recognized command (caller
19
+ * short-circuits handleMessage); false otherwise.
20
+ *
21
+ * Why a single factory: each handler shares the same runtime
22
+ * context (config, db, dbWrite, pm, pairings, sendReply, logEvent,
23
+ * etc.) and they're naturally co-located by command-style anyway.
24
+ * Splitting into one-file-per-command would 5× the wiring without
25
+ * gain.
26
+ */
27
+
28
+ 'use strict';
29
+
30
+ function createSlashCommands({
31
+ config,
32
+ db,
33
+ dbWrite,
34
+ pm,
35
+ pairings,
36
+ parsePairingTtl,
37
+ contextHintShown,
38
+ formatContextReply,
39
+ getClaudeSessionId,
40
+ getOrSpawnForChat,
41
+ parsePairCodeArgs,
42
+ modelVersionsDesc,
43
+ botName,
44
+ logEvent,
45
+ logger = console,
46
+ } = {}) {
47
+
48
+ return async function dispatchSlashCommand(ctx) {
49
+ const {
50
+ text, sessionKey, chatId, threadIdStr, chatConfig,
51
+ cmdUser, cmdUserId, label, sendReply,
52
+ } = ctx;
53
+ const botAllowsCommands = !!config.bot?.allowConfigCommands;
54
+
55
+ // /context
56
+ if (botAllowsCommands && text === '/context') {
57
+ const entry = pm.get(sessionKey);
58
+ const q = entry?.query;
59
+ if (!q || typeof q.getContextUsage !== 'function') {
60
+ await sendReply('📚 No active session yet — send a message first, then /context.');
61
+ return true;
62
+ }
63
+ try {
64
+ const u = await q.getContextUsage();
65
+ await sendReply(formatContextReply(u));
66
+ } catch (err) {
67
+ logger.error?.(`[${label}] /context failed: ${err.message}`);
68
+ await sendReply(`📚 Couldn't fetch context info: ${err.message}`);
69
+ }
70
+ return true;
71
+ }
72
+
73
+ // /compact [hint] — manual SDK compaction. Push the literal
74
+ // "/compact ..." into the input controller; SDK parses leading
75
+ // "/" as a slash command and triggers compaction. If session
76
+ // was LRU-evicted but DB has a saved session_id, auto-spawn
77
+ // with --resume so /compact has something to work with.
78
+ if (botAllowsCommands && text.startsWith('/compact')) {
79
+ let entry = pm.get(sessionKey);
80
+ if (!entry) {
81
+ const savedSessionId = getClaudeSessionId(db, sessionKey);
82
+ if (!savedSessionId) {
83
+ await sendReply('🗜️ No conversation to compact yet. Send a message first, then /compact.');
84
+ return true;
85
+ }
86
+ try {
87
+ entry = await getOrSpawnForChat(sessionKey);
88
+ } catch (err) {
89
+ logger.error?.(`[${label}] /compact spawn-resume: ${err.message}`);
90
+ await sendReply(`🗜️ Couldn't load session for compaction: ${err.message}`);
91
+ return true;
92
+ }
93
+ if (!entry) {
94
+ await sendReply('🗜️ Session not loadable (config missing).');
95
+ return true;
96
+ }
97
+ logEvent('compact-spawn-resumed', {
98
+ chat_id: chatId, thread_id: threadIdStr, session_key: sessionKey,
99
+ resumed_session_id: savedSessionId,
100
+ });
101
+ }
102
+ if (!entry?.inputController?.push) {
103
+ await sendReply('🗜️ Session not ready for /compact (no input controller).');
104
+ return true;
105
+ }
106
+ try {
107
+ entry.inputController.push({
108
+ type: 'user',
109
+ message: { role: 'user', content: text },
110
+ parent_tool_use_id: null,
111
+ });
112
+ logEvent('compact-command', {
113
+ chat_id: chatId, thread_id: threadIdStr, session_key: sessionKey,
114
+ text_len: text.length,
115
+ // rc.65: store full text so boot-time orphan recovery can
116
+ // silently re-push after a deploy interrupted compaction.
117
+ text,
118
+ user: cmdUser, user_id: cmdUserId,
119
+ });
120
+ const hasHint = text.length > '/compact'.length + 1;
121
+ await sendReply(hasHint ? '🗜️ Compacting with your hint…' : '🗜️ Compacting…');
122
+ } catch (err) {
123
+ logger.error?.(`[${label}] /compact push: ${err.message}`);
124
+ await sendReply(`🗜️ Couldn't trigger compact: ${err.message}`);
125
+ }
126
+ return true;
127
+ }
128
+
129
+ // /reload — close+respawn Query while PRESERVING session_id.
130
+ // Difference vs /new:
131
+ // /new → resetSession clears session_id → fresh conversation
132
+ // /reload → kill closes Query, session_id preserved → same
133
+ // conversation continues with fresh agent/skill code
134
+ if (botAllowsCommands && text === '/reload') {
135
+ if (pm.has(sessionKey)) {
136
+ try { await pm.kill(sessionKey); }
137
+ catch (err) { logger.error?.(`[${label}] kill on /reload: ${err.message}`); }
138
+ }
139
+ logEvent('session-reload-command', {
140
+ chat_id: chatId, command: text,
141
+ user: cmdUser, user_id: cmdUserId,
142
+ });
143
+ await sendReply('🔄 Reloaded. Next message picks up the conversation with fresh skills/agents.');
144
+ return true;
145
+ }
146
+
147
+ // /new + /reset — fresh session
148
+ if (botAllowsCommands && (text === '/new' || text === '/reset')) {
149
+ let drained = 0;
150
+ try {
151
+ const r = await pm.resetSession(sessionKey, { reason: text.slice(1) });
152
+ drained = r?.drainedPendings ?? 0;
153
+ } catch (err) {
154
+ logger.error?.(`[${label}] resetSession ${text}: ${err.message}`);
155
+ }
156
+ contextHintShown.delete(sessionKey);
157
+ logEvent('session-reset-command', {
158
+ chat_id: chatId, command: text, drained_pendings: drained,
159
+ user: cmdUser, user_id: cmdUserId,
160
+ });
161
+ await sendReply('✨ Started a fresh session.');
162
+ return true;
163
+ }
164
+
165
+ // SDK pm applies model/effort changes live via setModel /
166
+ // applyFlagSettings — no respawn. Returns whether there was a
167
+ // live session to push the change into; chatConfig is updated
168
+ // either way (next cold spawn picks it up).
169
+ const applyConfigChange = async (setting, value) => {
170
+ let applied = false;
171
+ if (setting === 'effort') {
172
+ applied = await pm.applyFlagSettings(sessionKey, { effortLevel: value });
173
+ } else if (setting === 'model') {
174
+ applied = await pm.setModel(sessionKey, value);
175
+ }
176
+ return { anyActive: !applied };
177
+ };
178
+
179
+ // /model X
180
+ if (botAllowsCommands && text.startsWith('/model ')) {
181
+ const newModel = text.slice(7).trim();
182
+ if (['opus', 'sonnet', 'haiku'].includes(newModel)) {
183
+ const oldModel = chatConfig.model;
184
+ chatConfig.model = newModel;
185
+ dbWrite(() => db.logConfigChange({
186
+ chat_id: chatId, thread_id: threadIdStr, field: 'model',
187
+ old_value: oldModel, new_value: newModel,
188
+ user: cmdUser, user_id: cmdUserId, source: 'command',
189
+ }), 'log model change');
190
+ const { anyActive } = await applyConfigChange('model', newModel);
191
+ const ver = (modelVersionsDesc && modelVersionsDesc[newModel]) || newModel;
192
+ const suffix = anyActive ? ` — I'll switch when I finish` : '';
193
+ await sendReply(`Model → ${newModel} (${ver})${suffix}`);
194
+ } else {
195
+ await sendReply(`Unknown model. Use: opus, sonnet, haiku`);
196
+ }
197
+ return true;
198
+ }
199
+
200
+ // /effort X
201
+ if (botAllowsCommands && text.startsWith('/effort ')) {
202
+ const newEffort = text.slice(8).trim();
203
+ if (['low', 'medium', 'high', 'xhigh', 'max'].includes(newEffort)) {
204
+ const oldEffort = chatConfig.effort;
205
+ chatConfig.effort = newEffort;
206
+ dbWrite(() => db.logConfigChange({
207
+ chat_id: chatId, thread_id: threadIdStr, field: 'effort',
208
+ old_value: oldEffort, new_value: newEffort,
209
+ user: cmdUser, user_id: cmdUserId, source: 'command',
210
+ }), 'log effort change');
211
+ const { anyActive } = await applyConfigChange('effort', newEffort);
212
+ const suffix = anyActive ? ` — I'll switch when I finish` : '';
213
+ await sendReply(`Effort → ${newEffort}${suffix}`);
214
+ } else {
215
+ await sendReply(`Unknown effort. Use: low, medium, high, xhigh, max`);
216
+ }
217
+ return true;
218
+ }
219
+
220
+ // Admin-only pairing commands — chat must match config.bot.adminChatId.
221
+ // allowConfigCommands alone is NOT sufficient: that flag gates
222
+ // /model and /effort which only affect the current chat. Pairing
223
+ // issues cross-chat trust and must be narrowed further.
224
+ const adminChatId = config.bot?.adminChatId ? String(config.bot.adminChatId) : null;
225
+ const isAdminChat = adminChatId && String(chatId) === adminChatId;
226
+
227
+ if (botAllowsCommands && text.startsWith('/pair-code')) {
228
+ if (!isAdminChat) { await sendReply('Pairing commands are admin-only; run from the admin chat.'); return true; }
229
+ const issuerId = cmdUserId;
230
+ if (!issuerId) { await sendReply('No user id on request'); return true; }
231
+ const args = parsePairCodeArgs(text);
232
+ try {
233
+ const out = pairings.issueCode({
234
+ bot_name: botName,
235
+ chat_id: args.chat || null,
236
+ scope: args.scope || 'user',
237
+ issued_by_user_id: issuerId,
238
+ ttlMs: args.ttl ? parsePairingTtl(args.ttl) : undefined,
239
+ note: args.note || null,
240
+ });
241
+ logEvent('pair-code-issued', {
242
+ bot: botName, by: issuerId, scope: out.scope,
243
+ chat_id: out.chat_id, note: out.note,
244
+ });
245
+ const ttlLabel = args.ttl || '10m';
246
+ const chatLabel = out.chat_id ? `chat ${out.chat_id}` : 'any chat';
247
+ await sendReply(
248
+ `Code: ${out.code}\nexpires: ${ttlLabel}\nscope: ${out.scope} (${chatLabel})${out.note ? `\nnote: ${out.note}` : ''}\n\nShare with user:\n/pair ${out.code}`,
249
+ );
250
+ } catch (err) {
251
+ await sendReply(`Could not issue code: ${err.message}`);
252
+ }
253
+ return true;
254
+ }
255
+
256
+ if (botAllowsCommands && text.startsWith('/pairings')) {
257
+ if (!isAdminChat) { await sendReply('Pairing commands are admin-only; run from the admin chat.'); return true; }
258
+ const rows = pairings.listActive(botName);
259
+ if (!rows.length) { await sendReply('No active pairings.'); return true; }
260
+ const lines = rows.map((r) => {
261
+ const chat = r.chat_id ? `chat ${r.chat_id}` : 'any chat';
262
+ const granted = new Date(r.granted_ts).toISOString().slice(0, 16).replace('T', ' ');
263
+ const note = r.note ? ` — ${r.note}` : '';
264
+ return `• user ${r.user_id} — ${chat} — ${granted}${note}`;
265
+ });
266
+ await sendReply(`Active pairings (${rows.length}):\n${lines.join('\n')}`);
267
+ return true;
268
+ }
269
+
270
+ if (botAllowsCommands && text.startsWith('/unpair ')) {
271
+ if (!isAdminChat) { await sendReply('Pairing commands are admin-only; run from the admin chat.'); return true; }
272
+ const arg = text.slice(8).trim();
273
+ const targetId = parseInt(arg, 10);
274
+ if (!Number.isFinite(targetId)) {
275
+ await sendReply('Usage: /unpair <user_id>');
276
+ return true;
277
+ }
278
+ const n = pairings.revokeByUser({ bot_name: botName, user_id: targetId });
279
+ logEvent('pair-revoked', {
280
+ bot: botName, user_id: targetId, by: cmdUserId, count: n,
281
+ });
282
+ await sendReply(n
283
+ ? `Revoked ${n} pairing(s) for user ${targetId}.`
284
+ : `No active pairings for user ${targetId}.`);
285
+ return true;
286
+ }
287
+
288
+ // /pair <CODE> — open to anyone, no admin gate (the code IS the auth).
289
+ if (text.startsWith('/pair ') && !text.startsWith('/pair-code') && !text.startsWith('/pairings')) {
290
+ if (!cmdUserId) { await sendReply('No user id on request'); return true; }
291
+ const code = text.slice(6).trim();
292
+ const res = pairings.claimCode({
293
+ code, claimer_user_id: cmdUserId,
294
+ chat_id: chatId, bot_name: botName,
295
+ });
296
+ logEvent('pair-claim-attempt', {
297
+ bot: botName, user_id: cmdUserId, chat_id: chatId,
298
+ ok: res.ok, reason: res.reason,
299
+ });
300
+ if (res.ok) {
301
+ const chatLabel = res.chat_id ? `chat ${res.chat_id}` : `every chat ${botName} is in`;
302
+ await sendReply(`Paired. You can use me in ${chatLabel}.${res.note ? `\n(${res.note})` : ''}`);
303
+ return true;
304
+ }
305
+ // Collapse failure reasons into "invalid or expired" to
306
+ // prevent enumeration. The pair-claim-attempt event above
307
+ // logs the precise reason for operator audit.
308
+ const userMsg = res.reason === 'rate-limited'
309
+ ? 'Too many attempts. Try again later.'
310
+ : 'That code is invalid or expired.';
311
+ await sendReply(userMsg);
312
+ return true;
313
+ }
314
+
315
+ return false;
316
+ };
317
+ }
318
+
319
+ module.exports = { createSlashCommands };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Voice attachment transcription handler.
3
+ *
4
+ * Per-message Telegram voice/audio attachments get transcribed via
5
+ * the configured provider (OpenAI Whisper today). The transcript is
6
+ * persisted both per-attachment (JSON in attachments.transcription)
7
+ * AND combined into messages.text so chat-search picks up "what
8
+ * Maria said" via the normal text path.
9
+ *
10
+ * Also fires a one-time 👂 reaction so the user knows the voice was
11
+ * heard before transcription completes; the caller is told to
12
+ * suppress the QUEUED 👀 (otherwise both flash visibly).
13
+ *
14
+ * Factory pattern: polygram.js wires the runtime deps (config, db,
15
+ * dbWrite, tg, logEvent, transcribeVoice impl, isVoiceAttachment)
16
+ * once and the returned function does the per-call work.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ function createTranscribeVoiceAttachments({
22
+ config,
23
+ db,
24
+ dbWrite,
25
+ tg,
26
+ logEvent,
27
+ transcribeVoice,
28
+ isVoiceAttachment,
29
+ botName,
30
+ logger = console,
31
+ } = {}) {
32
+
33
+ return async function transcribeVoiceAttachments(downloaded, { chatId, msgId, label, botApi /* , threadId */ }) {
34
+ const voiceCfg = config.bot?.voice || config.voice;
35
+ if (!voiceCfg?.enabled) return { ackEmitted: false };
36
+ const provider = voiceCfg.provider || 'openai';
37
+ const providerCfg = voiceCfg[provider] || {};
38
+ const targets = downloaded.filter((a) => isVoiceAttachment(a) && a.path);
39
+ if (!targets.length) return { ackEmitted: false };
40
+
41
+ // Acknowledge receipt with a reaction so the user knows we heard
42
+ // them. Cheap, robust (no state), and survives transcription
43
+ // failure. ackEmitted=true tells the caller to skip the reactor's
44
+ // QUEUED → 👀 transition (otherwise 👂 + 👀 flash visibly).
45
+ const ack = voiceCfg.ackReaction || '👂';
46
+ let ackEmitted = false;
47
+ if (ack && botApi) {
48
+ ackEmitted = true;
49
+ tg(botApi, 'setMessageReaction', {
50
+ chat_id: chatId, message_id: msgId,
51
+ reaction: [{ type: 'emoji', emoji: ack }],
52
+ }, { source: 'voice-ack', botName }).catch((err) => {
53
+ logger.error?.(`[${label}] voice ack reaction failed: ${err.message}`);
54
+ });
55
+ }
56
+
57
+ await Promise.all(targets.map(async (a) => {
58
+ try {
59
+ const opts = {
60
+ provider,
61
+ ...providerCfg,
62
+ language: voiceCfg.language || 'auto',
63
+ maxDurationSec: voiceCfg.maxDurationSec,
64
+ maxDurationBytesPerSec: voiceCfg.maxDurationBytesPerSec,
65
+ };
66
+ const r = await transcribeVoice(a.path, opts);
67
+ a.transcription = r;
68
+ logger.log?.(`[${label}] transcribed ${a.kind} (${r.duration_sec?.toFixed?.(1) || '?'}s, ${r.text.length} chars)`);
69
+ logEvent('voice-transcribed', {
70
+ chat_id: chatId, msg_id: msgId,
71
+ provider: r.provider, language: r.language,
72
+ duration_sec: r.duration_sec, chars: r.text.length,
73
+ cost_usd: r.cost_usd,
74
+ });
75
+ } catch (err) {
76
+ logger.error?.(`[${label}] transcribe failed for ${a.name}: ${err.message}`);
77
+ logEvent('voice-transcribe-failed', {
78
+ chat_id: chatId, msg_id: msgId, name: a.name, error: err.message,
79
+ });
80
+ }
81
+ }));
82
+
83
+ // Persist transcription:
84
+ // - Per-attachment: setAttachmentTranscription stores the
85
+ // full object (text + language + duration + provider) as
86
+ // JSON in attachments.transcription. buildVoiceTags
87
+ // parses it back when building the prompt.
88
+ // - Message-level: setMessageText updates messages.text with
89
+ // the combined transcript so FTS finds "what Maria said"
90
+ // via the normal chat search path.
91
+ const successful = targets.filter((a) => a.transcription?.text);
92
+ if (!successful.length) return { ackEmitted };
93
+ for (const a of successful) {
94
+ if (a.id != null) {
95
+ dbWrite(() => db.setAttachmentTranscription(a.id, JSON.stringify(a.transcription)),
96
+ `setAttachmentTranscription ${a.id}`);
97
+ }
98
+ }
99
+ const combinedText = successful.map((a) => a.transcription.text).join(' ').trim();
100
+ dbWrite(() => db.setMessageText({
101
+ chat_id: chatId, msg_id: msgId, text: combinedText,
102
+ }), 'persist voice transcription');
103
+ return { ackEmitted };
104
+ };
105
+ }
106
+
107
+ module.exports = { createTranscribeVoiceAttachments };