polygram 0.17.11 → 0.18.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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/error/classify.js +14 -0
- package/lib/error/net.js +8 -0
- package/lib/handlers/config-callback.js +44 -18
- package/lib/handlers/config-ui.js +29 -2
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/sdk/build-options.js +10 -2
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/session-key.js +7 -16
- package/lib/telegram/api.js +12 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/display-hint.js +47 -12
- package/lib/telegram/format.js +10 -1
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/rich-edit.js +94 -0
- package/lib/telegram/rich.js +397 -0
- package/lib/telegram/streamer.js +157 -11
- package/package.json +2 -1
- package/polygram.js +260 -44
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- package/lib/tmux/tmux-runner.js +0 -412
package/polygram.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
const { Bot } = require('grammy');
|
|
23
23
|
const fs = require('fs');
|
|
24
24
|
const path = require('path');
|
|
25
|
-
const processGuard = require('
|
|
25
|
+
const processGuard = require('@shumkov/orchestra').processGuard;
|
|
26
26
|
const dbClient = require('./lib/db');
|
|
27
27
|
const {
|
|
28
28
|
migrateJsonToDb, getClaudeSessionId, resolveSessionForSpawn,
|
|
@@ -39,9 +39,10 @@ const { filterAttachments, resolveFileCaps, resolveMaxFileOverride, MAX_TOTAL_BY
|
|
|
39
39
|
// Process subclasses (SdkProcess now, TmuxProcess in Phase 2) provide
|
|
40
40
|
// per-session mechanics. The pre-0.10.0 monolithic ProcessManagerSdk
|
|
41
41
|
// is deleted; SdkProcess inherits its per-entry guts.
|
|
42
|
-
const { ProcessManager } = require('
|
|
43
|
-
const { createProcessFactory, pickBackend } = require('
|
|
44
|
-
const {
|
|
42
|
+
const { ProcessManager } = require('@shumkov/orchestra');
|
|
43
|
+
const { createProcessFactory, pickBackend } = require('@shumkov/orchestra');
|
|
44
|
+
const { checkClaudeAuthHealth } = require('@shumkov/orchestra').claudeBin;
|
|
45
|
+
const { extractAssistantText } = require('@shumkov/orchestra');
|
|
45
46
|
// 0.11.0: channels backend tool dispatcher — adapts CliProcess's reply
|
|
46
47
|
// tool callback into polygram's existing chunkText + deliverReplies primitives.
|
|
47
48
|
// ADV-14: chunkMarkdownText (fence-aware) is imported once below (~line 88)
|
|
@@ -49,8 +50,8 @@ const { extractAssistantText } = require('./lib/process/sdk-process');
|
|
|
49
50
|
// containing code blocks or HTML-style tags aren't split mid-element by the
|
|
50
51
|
// size cap.
|
|
51
52
|
const { createChannelsToolDispatcher } = require('./lib/process/channels-tool-dispatcher');
|
|
52
|
-
const { createTmuxRunner } = require('
|
|
53
|
-
const { sweepTmuxOrphans } = require('
|
|
53
|
+
const { createTmuxRunner } = require('@shumkov/orchestra');
|
|
54
|
+
const { sweepTmuxOrphans } = require('@shumkov/orchestra').orphanSweep;
|
|
54
55
|
// rc.42: autosteer-buffer module deleted. Native SDK priority push
|
|
55
56
|
// (pm.injectUserMessage) replaces the buffer + PostToolBatch detour.
|
|
56
57
|
const { createAutosteeredRefs } = require('./lib/autosteered-refs');
|
|
@@ -68,6 +69,7 @@ const { createAutosteerHandlers } = require('./lib/handlers/autosteer');
|
|
|
68
69
|
const { createEditCorrectionInjector } = require('./lib/handlers/edit-correction');
|
|
69
70
|
const { createEditRedelivery } = require('./lib/handlers/edit-redelivery');
|
|
70
71
|
const { createGateInbound, ADMIN_CMD_RE, PAIR_CLAIM_RE } = require('./lib/handlers/gate-inbound');
|
|
72
|
+
const { createShouldHandle } = require('./lib/handlers/should-handle');
|
|
71
73
|
const { createRedeliver } = require('./lib/handlers/redeliver');
|
|
72
74
|
const { classifyReplay, executeReplayPlan } = require('./lib/handlers/replay-disposition');
|
|
73
75
|
const { createDropRedeliverer } = require('./lib/handlers/drop-redeliver');
|
|
@@ -87,13 +89,18 @@ const { formatContextReply, maybeContextFullHint } = require('./lib/context-form
|
|
|
87
89
|
const { createAbortGrace } = require('./lib/abort-grace');
|
|
88
90
|
const agentLoader = require('./lib/agents/loader');
|
|
89
91
|
const { createSender } = require('./lib/telegram/api');
|
|
90
|
-
const { createAsyncLock } = require('
|
|
92
|
+
const { createAsyncLock } = require('@shumkov/orchestra');
|
|
91
93
|
const { sweepInbox } = require('./lib/db/inbox');
|
|
92
94
|
const { parseBotArg, parseDbArg, filterConfigToBot, activeBotConfig } = require('./lib/config-scope');
|
|
93
95
|
const { createStore: createPairingsStore, parseTtl: parsePairingTtl } = require('./lib/db/pairings');
|
|
94
96
|
const { transcribe: transcribeVoice, isVoiceAttachment } = require('./lib/telegram/voice');
|
|
95
97
|
const { createStreamer } = require('./lib/telegram/streamer');
|
|
96
98
|
const { chunkMarkdownText } = require('./lib/telegram/chunk');
|
|
99
|
+
const {
|
|
100
|
+
toTelegramRichBlocks, resolveRichTextEnabled, isRichCapabilityError, isRichContentError,
|
|
101
|
+
} = require('./lib/telegram/rich');
|
|
102
|
+
const { createRichEditor } = require('./lib/telegram/rich-edit');
|
|
103
|
+
const { redactBotToken, stripUrlCredentials } = require('./lib/error/net');
|
|
97
104
|
// F#23: shared agent-reply helper. parseResponse + sanitizer + chunked
|
|
98
105
|
// delivery + inline sticker/react in one place. Wired into both the
|
|
99
106
|
// channels dispatcher (F#1) and the autonomous-wakeup handler (F#23).
|
|
@@ -110,6 +117,8 @@ const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
|
|
|
110
117
|
const { applyReactionToMessages } = require('./lib/telegram/album-reactions');
|
|
111
118
|
const { classify: classifyError, classifyTurnEndError, detectWedgedSessionError, isTransientHttpError } = require('./lib/error/classify');
|
|
112
119
|
const { createAutoResumeTracker, isAutoResumable } = require('./lib/db/auto-resume');
|
|
120
|
+
const { createAuthDisabledGate } = require('./lib/ops/auth-disabled-gate');
|
|
121
|
+
const { createHeartbeat } = require('./lib/ops/heartbeat');
|
|
113
122
|
const { resolveReplayWindowMs } = require('./lib/db/replay-window');
|
|
114
123
|
const { pruneEvents, resolveRetentionPolicy, validatePolicy } = require('./lib/db/events-retention');
|
|
115
124
|
const { sweepSecrets, resolveSecretSweepConfig } = require('./lib/db/secret-sweep');
|
|
@@ -169,6 +178,8 @@ let ipcCloser = null;
|
|
|
169
178
|
// single-valued), we keep them as plain module-level variables — not a map.
|
|
170
179
|
let BOT_NAME = null; // string, frozen after boot
|
|
171
180
|
let bot = null; // grammy Bot for BOT_NAME
|
|
181
|
+
let mediaBuffer = null; // media-group coalescing buffer, created in createBot;
|
|
182
|
+
// module-level so shutdown can replay-mark buffered albums
|
|
172
183
|
// 0.4.8 note: streamer + reactor are per-turn, not per-session. They live
|
|
173
184
|
// on the pending's `context` object in the pm pendingQueue, keyed to the
|
|
174
185
|
// specific turn (not the session). The old per-session Maps were a bug
|
|
@@ -293,6 +304,23 @@ function logEvent(kind, detail) {
|
|
|
293
304
|
dbWrite(() => db.logEvent(kind, detail), `log ${kind}`);
|
|
294
305
|
}
|
|
295
306
|
|
|
307
|
+
// One bot runs per process, so rich-message capability can be represented
|
|
308
|
+
// by one process-wide boolean rather than a per-connection map. Set once by
|
|
309
|
+
// isRichCapabilityError (endpoint missing/404) and never cleared for the
|
|
310
|
+
// process lifetime — retrying a permanently-missing endpoint on every
|
|
311
|
+
// future send is wasteful. Transient errors must not set the latch.
|
|
312
|
+
let richKnownUnsupported = false;
|
|
313
|
+
|
|
314
|
+
// Extracted to lib/telegram/rich-edit.js so the capability/content/
|
|
315
|
+
// transient error-classification + fallback wiring is directly unit-
|
|
316
|
+
// testable. Constructed in main() (like `tg` itself, just below),
|
|
317
|
+
// NOT here at module load time — BOT_NAME/config are `let`s still
|
|
318
|
+
// `null`/`undefined` at this point; capturing them by destructuring now
|
|
319
|
+
// would bake in stale values permanently, unlike `logEvent`, which is
|
|
320
|
+
// safe to reference early because it's a stable function that does its
|
|
321
|
+
// own live lookups internally when called.
|
|
322
|
+
let richEditMessageText;
|
|
323
|
+
|
|
296
324
|
// 0.15 secret redaction (agent-flagged path): the agent marks a secret it saw
|
|
297
325
|
// in the user's message with `[redact:<secret>]`. parseResponse / stripInlineTags
|
|
298
326
|
// strip the marker so nothing leaks to the user; here we wipe the literal from
|
|
@@ -473,7 +501,7 @@ function buildSpawnContext(sessionKey) {
|
|
|
473
501
|
const resolved = {
|
|
474
502
|
agent: topicConfig.agent || chatConfig.agent || null,
|
|
475
503
|
cwd: topicConfig.cwd || chatConfig.cwd || null,
|
|
476
|
-
backend: pickBackend({ config, chatId, threadId: threadId || null }),
|
|
504
|
+
backend: pickBackend({ config, chatId, threadId: threadId || null, pmDefault: 'sdk' }),
|
|
477
505
|
};
|
|
478
506
|
const r = resolveSessionForSpawn(db, sessionKey, resolved);
|
|
479
507
|
existingSessionId = r.existingSessionId;
|
|
@@ -502,7 +530,15 @@ function buildSpawnContext(sessionKey) {
|
|
|
502
530
|
// the inbound filter and the send() choke point use, so CliProcess's
|
|
503
531
|
// pre-check + system-prompt line can't drift from actual enforcement.
|
|
504
532
|
localApi: !!config.bot?.apiRoot,
|
|
505
|
-
|
|
533
|
+
// Pre-resolve through the SAME backend-aware resolver origin's CliProcess used, so
|
|
534
|
+
// orchestra (which now uses opts.outboundCapOverride verbatim, without its own
|
|
535
|
+
// resolveFileCaps clamp) gets the backend default (2GB local / 50MB cloud) AND the
|
|
536
|
+
// ceiling clamp — not a flat 100MB. resolveFileCaps always returns a number, so this
|
|
537
|
+
// is always non-null and orchestra's injected fallback never fires.
|
|
538
|
+
outboundCapOverride: resolveFileCaps({
|
|
539
|
+
localApi: !!config.bot?.apiRoot,
|
|
540
|
+
override: resolveMaxFileOverride(config, chatId, threadId || null),
|
|
541
|
+
}).outBytes,
|
|
506
542
|
};
|
|
507
543
|
}
|
|
508
544
|
|
|
@@ -595,6 +631,14 @@ let errorReplyText = null;
|
|
|
595
631
|
let queueWarnThreshold = null;
|
|
596
632
|
let inFlightHandlers = null;
|
|
597
633
|
|
|
634
|
+
// AUTH_DISABLED (docs/AUTH_DISABLED_HANDLING_SPEC.md): dedupe/re-arm gate for
|
|
635
|
+
// the operator notification (dispatcher.js) + Netdata-visibility heartbeat
|
|
636
|
+
// counter, both process-scoped like autoResumeTracker/contextHintShown above.
|
|
637
|
+
// heartbeat.start() is called in main() once DATA_DIR is settled; the gate
|
|
638
|
+
// needs no runtime config, so it's constructed here directly.
|
|
639
|
+
const authDisabledGate = createAuthDisabledGate();
|
|
640
|
+
const authDisabledHeartbeat = createHeartbeat({ dataDir: DATA_DIR, authDisabledGate });
|
|
641
|
+
|
|
598
642
|
// rc.59: once-per-cycle gate for the contextHint. A session is added
|
|
599
643
|
// to this Set when the hint fires, removed when the SDK emits
|
|
600
644
|
// compact_boundary (so the next cycle can fire its own hint). Without
|
|
@@ -797,8 +841,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
797
841
|
// showed "Agent: shumabit" instead of music-curation:music-curator
|
|
798
842
|
// (2026-06-03). getTopicConfig returns {} when there's no active topic.
|
|
799
843
|
const _cardTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
|
|
800
|
-
const
|
|
801
|
-
const
|
|
844
|
+
const effectiveRichText = resolveRichTextEnabled(config, chatId, threadIdStr || null);
|
|
845
|
+
const info = formatConfigInfoText(chatConfig, show, sessionKey, _cardTopicCfg, effectiveRichText);
|
|
846
|
+
const reply_markup = buildConfigKeyboard(chatConfig, show, _cardTopicCfg, effectiveRichText);
|
|
802
847
|
await sendReply(info, { params: { reply_markup } });
|
|
803
848
|
return;
|
|
804
849
|
}
|
|
@@ -811,6 +856,37 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
811
856
|
cmdUser, cmdUserId, label, sendReply,
|
|
812
857
|
})) return;
|
|
813
858
|
|
|
859
|
+
// Claude-auth gate: if the CLI login has expired, every claude turn wedges
|
|
860
|
+
// INVISIBLY — the 401 fires inside the subprocess, never reaches the
|
|
861
|
+
// classifier, and the turn degrades to a silent "⏱ went quiet" across every
|
|
862
|
+
// topic. Refuse up-front with a clear message + a loud log instead of
|
|
863
|
+
// spawning a doomed turn. Slash commands above don't need claude, so they
|
|
864
|
+
// still work. Free check (reads the credentials file, no API call). Guarded
|
|
865
|
+
// (fail-open to 'unknown' on throw): this sits on every turn's hot path, so
|
|
866
|
+
// a broken health check must never itself become the silent wedge this gate
|
|
867
|
+
// exists to catch. Applies to replays/redeliveries too (boot-replay,
|
|
868
|
+
// drop-redeliver, edit-redelivery) — the check is a cheap stateless file
|
|
869
|
+
// read, and a token can expire between an original dispatch and its later
|
|
870
|
+
// redelivery, so skipping it there would silently re-wedge exactly the
|
|
871
|
+
// messages most likely to hit an expired-auth window (post-restart backlog).
|
|
872
|
+
let auth;
|
|
873
|
+
try {
|
|
874
|
+
auth = checkClaudeAuthHealth();
|
|
875
|
+
} catch (err) {
|
|
876
|
+
console.error(`[auth] health check failed: ${err.message}`);
|
|
877
|
+
auth = { state: 'unknown' };
|
|
878
|
+
}
|
|
879
|
+
if (auth.state === 'expired') {
|
|
880
|
+
const expIso = new Date(auth.refreshTokenExpiresAt).toISOString();
|
|
881
|
+
console.error(`[auth] Claude login EXPIRED (refresh token ${expIso}) — refusing turn for ${label}; re-login on the host.`);
|
|
882
|
+
logEvent('auth-expired', {
|
|
883
|
+
session_key: sessionKey, chat_id: chatId, source: 'dispatch-gate',
|
|
884
|
+
refresh_token_expires_at: auth.refreshTokenExpiresAt,
|
|
885
|
+
});
|
|
886
|
+
await sendReply('🔑 Claude login has expired and needs to be re-authenticated. Messages can\'t be processed until the login is refreshed on the host.');
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
814
890
|
const t0 = Date.now();
|
|
815
891
|
|
|
816
892
|
const sessionCtx = !pm.has(sessionKey) ? await readSessionContext(sessionKey, chatConfig.cwd) : '';
|
|
@@ -928,11 +1004,35 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
928
1004
|
// eliminates the "stuck at 15min typing" complaint from the non-streaming
|
|
929
1005
|
// code path. For short responses the streamer stays idle and we fall
|
|
930
1006
|
// through to the normal send path via finalize() returning streamed=false.
|
|
1007
|
+
// Gate rich delivery on the per-chat/topic opt-in and the process-wide
|
|
1008
|
+
// capability latch. Returning null forces the streamer's plain-text
|
|
1009
|
+
// path — same contract for "not opted in", "capability known
|
|
1010
|
+
// unsupported", and "content doesn't need it" (needsRichRendering
|
|
1011
|
+
// inside toTelegramRichBlocks handles the last one).
|
|
1012
|
+
// Rich delivery is limited to this interactive streamer instance.
|
|
1013
|
+
// A reply that never crosses minChars (stays idle → delivered via
|
|
1014
|
+
// deliverReplies) and any non-streaming send path — autonomous-wakeup/
|
|
1015
|
+
// auto-resume/autosteer via lib/telegram/process-agent-reply.js, cron/
|
|
1016
|
+
// IPC sends — never render rich even when richText is on and the
|
|
1017
|
+
// content qualifies. Those delivery paths intentionally remain plain.
|
|
1018
|
+
// Re-read both controls for every flush so a config change or a
|
|
1019
|
+
// capability result applies to the turn already in progress.
|
|
1020
|
+
const toRichPayload = (text, opts) => {
|
|
1021
|
+
if (richKnownUnsupported || !resolveRichTextEnabled(config, chatId, threadId)) return null;
|
|
1022
|
+
return toTelegramRichBlocks(text, opts);
|
|
1023
|
+
};
|
|
1024
|
+
|
|
931
1025
|
const streamer = createStreamer({
|
|
932
1026
|
// rc.67: pre-process every chunk to strip recognised
|
|
933
1027
|
// [sticker:NAME] / [react:EMOJI] tags BEFORE the bubble or DB row
|
|
934
1028
|
// captures them. See stripInlineTagsForStreamer above.
|
|
935
1029
|
transformText: stripInlineTagsForStreamer,
|
|
1030
|
+
toRichPayload,
|
|
1031
|
+
onRichUpgrade: () => {
|
|
1032
|
+
// Record the visible transition when an existing plain bubble
|
|
1033
|
+
// becomes rich during streaming.
|
|
1034
|
+
logEvent('rich-streaming-upgrade', { chat_id: chatId, thread_id: threadId, bot: BOT_NAME });
|
|
1035
|
+
},
|
|
936
1036
|
send: async (text) => {
|
|
937
1037
|
const params = {
|
|
938
1038
|
chat_id: chatId, text,
|
|
@@ -948,7 +1048,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
948
1048
|
}
|
|
949
1049
|
return tg(bot, 'sendMessage', params, outMetaBase);
|
|
950
1050
|
},
|
|
951
|
-
edit: async (messageId,
|
|
1051
|
+
edit: async (messageId, payload) => {
|
|
1052
|
+
// The streamer emits either a plain string or a rich payload with
|
|
1053
|
+
// blocks and their source Markdown. Rich error classification and
|
|
1054
|
+
// fallback live in rich-edit.js so this wiring stays focused.
|
|
1055
|
+
if (payload && typeof payload === 'object' && payload.rich) {
|
|
1056
|
+
return richEditMessageText({
|
|
1057
|
+
bot, chatId, threadId, messageId,
|
|
1058
|
+
blocks: payload.blocks, sourceText: payload.sourceText,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
const text = payload;
|
|
952
1062
|
try {
|
|
953
1063
|
// Route edits through tg() so applyFormatting runs (MarkdownV2
|
|
954
1064
|
// + escape). Going direct to bot.api.editMessageText would
|
|
@@ -1307,6 +1417,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1307
1417
|
const chatCtxHint = chatConfig.contextHint != null
|
|
1308
1418
|
? chatConfig.contextHint
|
|
1309
1419
|
: config.bot?.contextHint;
|
|
1420
|
+
// AUTH_DISABLED re-arm (docs/AUTH_DISABLED_HANDLING_SPEC.md): only a
|
|
1421
|
+
// genuine, non-error turn result counts as "recovered" — slash
|
|
1422
|
+
// commands and other early-returns in handleMessage never reach this
|
|
1423
|
+
// branch, so they can't falsely clear an ongoing outage and cause the
|
|
1424
|
+
// operator to be re-paged before it's actually fixed.
|
|
1425
|
+
try { authDisabledGate.noteSuccess(); } catch (e) { console.error(`[auth] authDisabledGate.noteSuccess failed: ${e.message}`); }
|
|
1310
1426
|
// rc.59: gate the hint to once-per-cycle. Pre-rc.59 the hint
|
|
1311
1427
|
// fired on EVERY turn that landed over threshold — so a user
|
|
1312
1428
|
// saw "📚 70% full…" then "71% full…" then "72% full…"
|
|
@@ -1753,34 +1869,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1753
1869
|
|
|
1754
1870
|
// ─── Bot setup ──────────────────────────────────────────────────────
|
|
1755
1871
|
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
if (chatConfig.requireMention && msg.chat.type !== 'private') {
|
|
1763
|
-
const text = msg.text || msg.caption || '';
|
|
1764
|
-
const isReplyToBot = msg.reply_to_message?.from?.username === botUsername;
|
|
1765
|
-
const hasMention = text.includes(`@${botUsername}`);
|
|
1766
|
-
// A reply targeting some other user (not the bot) is a strong signal
|
|
1767
|
-
// "this message is for that person, not me". Paired users normally
|
|
1768
|
-
// bypass requireMention, but not in this case — without the guard a
|
|
1769
|
-
// paired user saying "Gotcha!" to a teammate gets processed by the
|
|
1770
|
-
// bot just because the user is paired, which is what bit us in
|
|
1771
|
-
// UMI Group on 0.5.9 (bot leaked reasoning as a reply to "Gotcha!").
|
|
1772
|
-
const repliesToOtherUser = !!msg.reply_to_message
|
|
1773
|
-
&& msg.reply_to_message.from?.username !== botUsername;
|
|
1774
|
-
// Paired users bypass requireMention — operator-trusted, no @ needed
|
|
1775
|
-
// every time. Skipped when they're replying to a non-bot user (above).
|
|
1776
|
-
const paired = !repliesToOtherUser && pairings && msg.from?.id
|
|
1777
|
-
? pairings.hasLivePairing({ bot_name: BOT_NAME, user_id: msg.from.id, chat_id: chatId })
|
|
1778
|
-
: false;
|
|
1779
|
-
if (!isReplyToBot && !hasMention && !paired) return false;
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
return true;
|
|
1783
|
-
}
|
|
1872
|
+
const shouldHandle = createShouldHandle({
|
|
1873
|
+
getConfig: () => config,
|
|
1874
|
+
getPairings: () => pairings,
|
|
1875
|
+
getBotName: () => BOT_NAME,
|
|
1876
|
+
});
|
|
1784
1877
|
|
|
1785
1878
|
function createBot(token) {
|
|
1786
1879
|
// Optional self-hosted Telegram Bot API server. When config.bot.apiRoot is
|
|
@@ -1945,7 +2038,7 @@ function createBot(token) {
|
|
|
1945
2038
|
// a single synthetic turn with all attachments merged. Timer resets on
|
|
1946
2039
|
// every new sibling, so as long as messages arrive faster than the
|
|
1947
2040
|
// DEFAULT_FLUSH_MS window apart they stay in the same bundle.
|
|
1948
|
-
|
|
2041
|
+
mediaBuffer = createMediaGroupBuffer({
|
|
1949
2042
|
onFlush: (messages) => {
|
|
1950
2043
|
if (!messages || messages.length === 0) return;
|
|
1951
2044
|
// Primary = the (usually first) message with text/caption; that's
|
|
@@ -2224,7 +2317,14 @@ async function main() {
|
|
|
2224
2317
|
// TmuxProcess.start() hits EEXIST on session spawn for any chat
|
|
2225
2318
|
// routed to pm:'tmux'. See lib/tmux/orphan-sweep.js for rationale.
|
|
2226
2319
|
try {
|
|
2227
|
-
|
|
2320
|
+
// Sweep must look for polygram's session prefix — orchestra's default runner
|
|
2321
|
+
// filters 'orchestra-<bot>-', which would never match our 'polygram-<bot>-'
|
|
2322
|
+
// sessions (dead safety net + EEXIST on cli respawn of a leaked session).
|
|
2323
|
+
const sweep = await sweepTmuxOrphans({
|
|
2324
|
+
botName: BOT_NAME,
|
|
2325
|
+
runner: createTmuxRunner({ logger: console, sessionPrefix: 'polygram' }),
|
|
2326
|
+
logger: console,
|
|
2327
|
+
});
|
|
2228
2328
|
if (sweep.swept.length > 0) {
|
|
2229
2329
|
console.log(`[orphan-sweep] killed ${sweep.swept.length} stale tmux session(s)`);
|
|
2230
2330
|
}
|
|
@@ -2236,6 +2336,18 @@ async function main() {
|
|
|
2236
2336
|
db = dbClient.open(DB_PATH);
|
|
2237
2337
|
console.log(`[db] opened ${DB_PATH}`);
|
|
2238
2338
|
tg = createSender(db, console, config);
|
|
2339
|
+
richEditMessageText = createRichEditor({
|
|
2340
|
+
tg,
|
|
2341
|
+
botName: BOT_NAME,
|
|
2342
|
+
logEvent,
|
|
2343
|
+
redactBotToken,
|
|
2344
|
+
isRichCapabilityError,
|
|
2345
|
+
isRichContentError,
|
|
2346
|
+
getRichKnownUnsupported: () => richKnownUnsupported,
|
|
2347
|
+
setRichKnownUnsupported: () => { richKnownUnsupported = true; },
|
|
2348
|
+
getApiRoot: () => config.bot?.apiRoot || null,
|
|
2349
|
+
stripUrlCreds: stripUrlCredentials,
|
|
2350
|
+
});
|
|
2239
2351
|
pairings = createPairingsStore(db.raw);
|
|
2240
2352
|
approvals = createApprovalsStore(db.raw);
|
|
2241
2353
|
const migration = migrateJsonToDb(db, SESSIONS_JSON_PATH, config.chats);
|
|
@@ -2324,6 +2436,38 @@ async function main() {
|
|
|
2324
2436
|
setInterval(() => runSecretSweep('interval'), secretSweepCfg.intervalMs).unref?.();
|
|
2325
2437
|
}
|
|
2326
2438
|
|
|
2439
|
+
// Claude-auth health monitor — a FREE credentials-file check (no API call, no
|
|
2440
|
+
// model spawn). An expired refresh token makes the CLI 401 silently and wedges
|
|
2441
|
+
// every channels turn; the handleMessage gate refuses turns + tells the user,
|
|
2442
|
+
// and this surfaces the same state loudly in the operator logs at boot + every
|
|
2443
|
+
// 30 min, warning a few days ahead so re-login happens before it breaks.
|
|
2444
|
+
const runAuthCheck = (trigger) => {
|
|
2445
|
+
try {
|
|
2446
|
+
const auth = checkClaudeAuthHealth();
|
|
2447
|
+
const expIso = auth.refreshTokenExpiresAt ? new Date(auth.refreshTokenExpiresAt).toISOString() : 'n/a';
|
|
2448
|
+
if (auth.state === 'expired') {
|
|
2449
|
+
console.error(`[auth] (${trigger}) Claude login EXPIRED (refresh token ${expIso}) — turns are being refused; re-login on the host.`);
|
|
2450
|
+
logEvent('auth-expired', { source: 'monitor', trigger, refresh_token_expires_at: auth.refreshTokenExpiresAt });
|
|
2451
|
+
} else if (auth.state === 'expiring') {
|
|
2452
|
+
console.warn(`[auth] (${trigger}) Claude login expires in ${auth.daysLeft}d (${expIso}) — re-login soon.`);
|
|
2453
|
+
logEvent('auth-expiring', { source: 'monitor', trigger, days_left: auth.daysLeft, refresh_token_expires_at: auth.refreshTokenExpiresAt });
|
|
2454
|
+
} else if (auth.state === 'unknown') {
|
|
2455
|
+
console.warn(`[auth] (${trigger}) Claude credentials check inconclusive: ${auth.reason}`);
|
|
2456
|
+
}
|
|
2457
|
+
} catch (err) {
|
|
2458
|
+
console.error(`[auth] health check failed (${trigger}): ${err.message}`);
|
|
2459
|
+
}
|
|
2460
|
+
};
|
|
2461
|
+
runAuthCheck('boot');
|
|
2462
|
+
setInterval(() => runAuthCheck('interval'), 30 * 60_000).unref?.();
|
|
2463
|
+
|
|
2464
|
+
// AUTH_DISABLED Netdata-visibility heartbeat (docs/AUTH_DISABLED_HANDLING_SPEC.md,
|
|
2465
|
+
// Layer 3.3) — writes <DATA_DIR>/heartbeat.json every 60s with the
|
|
2466
|
+
// authDisabledGate's occurrence counter. File-only (no HTTP server in this
|
|
2467
|
+
// repo to hang a /healthz route on); wiring the file into an actual Netdata
|
|
2468
|
+
// alert is a separate VPS-side ops change, out of scope here.
|
|
2469
|
+
authDisabledHeartbeat.start();
|
|
2470
|
+
|
|
2327
2471
|
// 0.8.0 Phase 1 step 11 + rc.50: defensive uncaughtException +
|
|
2328
2472
|
// unhandledRejection handlers. The new pm wraps every Query
|
|
2329
2473
|
// iteration in try/catch so SDK throws never leak — but if a
|
|
@@ -2482,7 +2626,7 @@ async function main() {
|
|
|
2482
2626
|
// tmux backend runner — one per daemon, shared across all TmuxProcess
|
|
2483
2627
|
// instances. Construction is cheap (no system call until first
|
|
2484
2628
|
// spawn/send). Only used if any chat in config has pm:'tmux'.
|
|
2485
|
-
const tmuxRunner = createTmuxRunner({ logger: console });
|
|
2629
|
+
const tmuxRunner = createTmuxRunner({ logger: console, sessionPrefix: 'polygram' });
|
|
2486
2630
|
// Verify the pinned claude CLI binary is present. The tmux
|
|
2487
2631
|
// backend spawns this exact binary by absolute path (see
|
|
2488
2632
|
// lib/claude-bin.js + TmuxProcess.start) — it never resolves
|
|
@@ -2498,7 +2642,13 @@ async function main() {
|
|
|
2498
2642
|
// auto-pruner (keeps only ~3 newest, deletes the rest) can't take cli chats
|
|
2499
2643
|
// down. Spawns from ~/.local/share/polygram/claude-bin/<version>, immune to
|
|
2500
2644
|
// pruning. Self-heals on boot (copy from the system install, else install).
|
|
2501
|
-
|
|
2645
|
+
// orchestra's claude-bin defaults its vendor dir to ~/.local/share/orchestra;
|
|
2646
|
+
// point it at polygram's EXISTING vendored dir so boot reuses the pinned binary
|
|
2647
|
+
// that's already there (no 223 MB re-vendor / `claude install` network call).
|
|
2648
|
+
if (!process.env.ORCHESTRA_CLAUDE_VENDOR_DIR) {
|
|
2649
|
+
process.env.ORCHESTRA_CLAUDE_VENDOR_DIR = require('node:path').join(require('node:os').homedir(), '.local', 'share', 'polygram', 'claude-bin');
|
|
2650
|
+
}
|
|
2651
|
+
const { CLAUDE_CLI_PINNED_VERSION, ensureVendoredClaudeBin } = require('@shumkov/orchestra').claudeBin;
|
|
2502
2652
|
const binCheck = ensureVendoredClaudeBin(CLAUDE_CLI_PINNED_VERSION, { logger: console });
|
|
2503
2653
|
if (binCheck.ok) {
|
|
2504
2654
|
console.log(
|
|
@@ -2542,6 +2692,23 @@ async function main() {
|
|
|
2542
2692
|
// channels backend
|
|
2543
2693
|
toolDispatcher: channelsToolDispatcher,
|
|
2544
2694
|
channelsClaudeBin,
|
|
2695
|
+
// orchestra identity — polygram's names so behavior is byte-identical to the
|
|
2696
|
+
// copied engine. bridge/tmux/hook/attachment prefixes, product/surface prose,
|
|
2697
|
+
// the SDK backend (injected), and the default backend all match polygram's.
|
|
2698
|
+
sessionPrefix: 'polygram',
|
|
2699
|
+
bridgeServerName: 'polygram-bridge',
|
|
2700
|
+
appDataDir: require('node:path').join(require('node:os').homedir(), '.polygram'),
|
|
2701
|
+
attachmentBase: require('node:path').join(require('node:os').tmpdir(), 'polygram-attachments'),
|
|
2702
|
+
productName: 'polygram',
|
|
2703
|
+
surfaceName: 'Telegram',
|
|
2704
|
+
pmDefault: 'sdk',
|
|
2705
|
+
// The CLI append-system-prompt's FIRST block — origin hard-required this into every
|
|
2706
|
+
// CliProcess. Without it, cli chats lose the Telegram table/markdown display rules.
|
|
2707
|
+
displayHint: require('./lib/telegram/display-hint').POLYGRAM_DISPLAY_HINT,
|
|
2708
|
+
// Backend-default outbound cap fallback (per-spawn buildSpawnContext override
|
|
2709
|
+
// normally supersedes this; kept so any context-less spawn still gets the backend
|
|
2710
|
+
// default rather than orchestra's neutral 100MB).
|
|
2711
|
+
maxOutboundFileBytes: resolveFileCaps({ localApi: !!config.bot?.apiRoot }).outBytes,
|
|
2545
2712
|
});
|
|
2546
2713
|
// Route in-process approval prompts through the SAME canUseTool plumbing
|
|
2547
2714
|
// that SDK chats use:
|
|
@@ -2633,6 +2800,7 @@ async function main() {
|
|
|
2633
2800
|
chunkMarkdownText, deliverReplies,
|
|
2634
2801
|
chunkBudget: TG_CHUNK_BUDGET,
|
|
2635
2802
|
getIsShuttingDown: () => isShuttingDown,
|
|
2803
|
+
authDisabledGate,
|
|
2636
2804
|
logger: console,
|
|
2637
2805
|
}));
|
|
2638
2806
|
// 0.12.0 post-turn edit re-delivery: constructed AFTER dispatchHandleMessage is assigned (above).
|
|
@@ -2737,6 +2905,53 @@ async function main() {
|
|
|
2737
2905
|
console.error(`[shutdown] question expiry failed: ${err.message}`);
|
|
2738
2906
|
}
|
|
2739
2907
|
|
|
2908
|
+
// 1.6 Album siblings still buffered (arrived <flushMs before shutdown,
|
|
2909
|
+
// never dispatched) sit at handler_status NULL — invisible to both
|
|
2910
|
+
// the drain below (not in inFlightHandlers) and recordCleanShutdown
|
|
2911
|
+
// (only re-marks dispatched/processing). Mark them replay-pending so
|
|
2912
|
+
// boot treats them like any interrupted turn: crash → recovered,
|
|
2913
|
+
// deliberate restart → the visible skip notice.
|
|
2914
|
+
//
|
|
2915
|
+
// takeAllForShutdown() (not peekAll()) atomically cancels each
|
|
2916
|
+
// group's pending flush timer as it reads it (review: adversarial)
|
|
2917
|
+
// — a live timer firing during the drain below could otherwise
|
|
2918
|
+
// self-flush a group already marked replay-pending here, double-
|
|
2919
|
+
// processing it. Each group is coalesced onto ONE primary row,
|
|
2920
|
+
// mirroring onFlush's own primary-selection + attachment-reassign
|
|
2921
|
+
// logic above (review: correctness) — marking every sibling
|
|
2922
|
+
// individually meant a crash recovered an album as N fragmented
|
|
2923
|
+
// single-photo turns instead of the one multi-photo turn the live
|
|
2924
|
+
// path guarantees.
|
|
2925
|
+
try {
|
|
2926
|
+
const groups = mediaBuffer?.takeAllForShutdown() || [];
|
|
2927
|
+
let markedCount = 0;
|
|
2928
|
+
for (const { messages } of groups) {
|
|
2929
|
+
if (!messages || messages.length === 0) continue;
|
|
2930
|
+
const primary = messages.find((m) => m.text || m.caption) || messages[0];
|
|
2931
|
+
const chatId = String(primary.chat.id);
|
|
2932
|
+
const siblingMsgIds = messages
|
|
2933
|
+
.filter((m) => m.message_id !== primary.message_id)
|
|
2934
|
+
.map((m) => m.message_id);
|
|
2935
|
+
if (siblingMsgIds.length) {
|
|
2936
|
+
const primaryDbId = db.getInboundMessageId({ chat_id: chatId, msg_id: primary.message_id });
|
|
2937
|
+
if (primaryDbId) {
|
|
2938
|
+
dbWrite(() => db.reassignAttachmentsToMessage({
|
|
2939
|
+
chat_id: chatId, msg_ids: siblingMsgIds, target_message_id: primaryDbId,
|
|
2940
|
+
}), 'shutdown-media-buffer-reassign');
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
dbWrite(() => db.setInboundHandlerStatus({
|
|
2944
|
+
chat_id: chatId, msg_id: primary.message_id, status: 'replay-pending',
|
|
2945
|
+
}), 'shutdown-media-buffer');
|
|
2946
|
+
markedCount += 1;
|
|
2947
|
+
}
|
|
2948
|
+
if (markedCount) {
|
|
2949
|
+
logEvent('shutdown-media-buffer-marked', { count: markedCount });
|
|
2950
|
+
}
|
|
2951
|
+
} catch (err) {
|
|
2952
|
+
console.error(`[shutdown] media-buffer replay-mark failed: ${err.message}`);
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2740
2955
|
// 2. Drain in-flight handlers. Wait for inFlightHandlers to empty or
|
|
2741
2956
|
// SHUTDOWN_DRAIN_MS to elapse. pm handlers resolve naturally when
|
|
2742
2957
|
// result events arrive; the dispatcher's .finally decrements.
|
|
@@ -2777,6 +2992,7 @@ async function main() {
|
|
|
2777
2992
|
// 4. Remaining shutdown: approvals sweeper, IPC, resolve hook waiters,
|
|
2778
2993
|
// kill pm subprocesses, close DB.
|
|
2779
2994
|
if (approvalSweepTimer) clearInterval(approvalSweepTimer);
|
|
2995
|
+
authDisabledHeartbeat.stop();
|
|
2780
2996
|
if (ipcCloser) ipcCloser.close().catch(() => {});
|
|
2781
2997
|
try { fs.unlinkSync(ipcServer.secretPathFor(BOT_NAME)); } catch {}
|
|
2782
2998
|
// Reject every parked canUseTool waiter so the SDK doesn't
|
package/lib/async-lock.js
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-key chain lock. Each acquire() returns a release function; the next
|
|
3
|
-
* acquire() awaits the previous one's release.
|
|
4
|
-
*
|
|
5
|
-
* Used by polygram to serialise stdin writes per session. Pre-work
|
|
6
|
-
* (attachment download, voice transcription, prompt formatting) runs
|
|
7
|
-
* concurrently; only the stdin write itself is serialised so Claude
|
|
8
|
-
* reads messages in arrival order and replies come out in the same
|
|
9
|
-
* order.
|
|
10
|
-
*
|
|
11
|
-
* Deliberately minimal — no timeouts, no cancellation, no fairness
|
|
12
|
-
* guarantees beyond FIFO. Callers are expected to ALWAYS call release,
|
|
13
|
-
* even on error paths, or the lock leaks (blocks all future acquires
|
|
14
|
-
* for that key forever).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
function createAsyncLock() {
|
|
18
|
-
const chains = new Map(); // key → Promise of last release
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
async acquire(key) {
|
|
22
|
-
const prev = chains.get(key) || Promise.resolve();
|
|
23
|
-
let release;
|
|
24
|
-
const next = new Promise((resolve) => { release = resolve; });
|
|
25
|
-
// Save the chain-entry promise so the cleanup branch can compare
|
|
26
|
-
// against the SAME reference. Pre-fix this re-evaluated
|
|
27
|
-
// `prev.then(() => next)` (a fresh promise each call), so the
|
|
28
|
-
// === compare was always false and the Map leaked one entry per
|
|
29
|
-
// unique key.
|
|
30
|
-
const myEntry = prev.then(() => next);
|
|
31
|
-
chains.set(key, myEntry);
|
|
32
|
-
await prev;
|
|
33
|
-
// Return a wrapper that also clears the chain entry when this is
|
|
34
|
-
// the last holder — avoids the Map growing unbounded across the
|
|
35
|
-
// lifetime of the process. Idempotent: a double-release call is
|
|
36
|
-
// harmless (release() is a Promise resolver; calling resolve
|
|
37
|
-
// twice is a no-op).
|
|
38
|
-
return () => {
|
|
39
|
-
if (chains.get(key) === myEntry) {
|
|
40
|
-
chains.delete(key);
|
|
41
|
-
}
|
|
42
|
-
release();
|
|
43
|
-
};
|
|
44
|
-
},
|
|
45
|
-
get size() { return chains.size; },
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
module.exports = { createAsyncLock };
|