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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/{agent-loader.js → agents/loader.js} +6 -8
- package/lib/{approvals.js → approvals/store.js} +28 -5
- package/lib/{approval-ui.js → approvals/ui.js} +1 -17
- package/lib/config.js +121 -0
- package/lib/{error-classify.js → error/classify.js} +25 -34
- package/lib/handlers/abort.js +89 -0
- package/lib/handlers/approvals.js +361 -0
- package/lib/handlers/autosteer.js +94 -0
- package/lib/handlers/config-callback.js +118 -0
- package/lib/handlers/config-ui.js +104 -0
- package/lib/handlers/dispatcher.js +263 -0
- package/lib/handlers/download.js +182 -0
- package/lib/handlers/extract-attachments.js +97 -0
- package/lib/handlers/ipc-send.js +80 -0
- package/lib/handlers/poll.js +140 -0
- package/lib/handlers/record-inbound.js +88 -0
- package/lib/handlers/slash-commands.js +319 -0
- package/lib/handlers/voice.js +107 -0
- package/lib/pm-interface.js +27 -29
- package/lib/sdk/build-options.js +177 -0
- package/lib/sdk/callbacks.js +213 -0
- package/lib/{process-manager-sdk.js → sdk/process-manager.js} +19 -31
- package/lib/{telegram.js → telegram/api.js} +2 -2
- package/lib/{telegram-prompt.js → telegram/display-hint.js} +0 -14
- package/lib/{stream-reply.js → telegram/streamer.js} +4 -4
- package/package.json +2 -3
- package/polygram.js +347 -2581
- package/scripts/doctor.js +1 -1
- package/scripts/ipc-smoke.js +1 -10
- package/bin/approval-hook.js +0 -113
- package/lib/approval-waiters.js +0 -201
- package/lib/pm-router.js +0 -201
- package/lib/process-manager.js +0 -806
- /package/lib/{auto-resume.js → db/auto-resume.js} +0 -0
- /package/lib/{inbox.js → db/inbox.js} +0 -0
- /package/lib/{pairings.js → db/pairings.js} +0 -0
- /package/lib/{replay-window.js → db/replay-window.js} +0 -0
- /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
- /package/lib/{sessions.js → db/sessions.js} +0 -0
- /package/lib/{net-errors.js → error/net.js} +0 -0
- /package/lib/{ipc-client.js → ipc/client.js} +0 -0
- /package/lib/{ipc-file-validator.js → ipc/file-validator.js} +0 -0
- /package/lib/{ipc-server.js → ipc/server.js} +0 -0
- /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
- /package/lib/{deliver.js → telegram/deliver.js} +0 -0
- /package/lib/{telegram-format.js → telegram/format.js} +0 -0
- /package/lib/{parse-response.js → telegram/parse.js} +0 -0
- /package/lib/{status-reactions.js → telegram/reactions.js} +0 -0
- /package/lib/{typing-indicator.js → telegram/typing.js} +0 -0
- /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 };
|