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.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/error/classify.js +14 -0
  3. package/lib/error/net.js +8 -0
  4. package/lib/handlers/config-callback.js +44 -18
  5. package/lib/handlers/config-ui.js +29 -2
  6. package/lib/handlers/dispatcher.js +54 -1
  7. package/lib/handlers/drop-redeliver.js +19 -0
  8. package/lib/handlers/should-handle.js +52 -0
  9. package/lib/media-group-buffer.js +29 -0
  10. package/lib/ops/auth-disabled-gate.js +43 -0
  11. package/lib/ops/heartbeat.js +56 -0
  12. package/lib/process/channels-tool-dispatcher.js +12 -1
  13. package/lib/sdk/build-options.js +10 -2
  14. package/lib/sdk/callbacks.js +2 -1
  15. package/lib/secret-detect.js +13 -1
  16. package/lib/session-key.js +7 -16
  17. package/lib/telegram/api.js +12 -1
  18. package/lib/telegram/chunk.js +20 -6
  19. package/lib/telegram/display-hint.js +47 -12
  20. package/lib/telegram/format.js +10 -1
  21. package/lib/telegram/process-agent-reply.js +5 -3
  22. package/lib/telegram/rich-edit.js +94 -0
  23. package/lib/telegram/rich.js +397 -0
  24. package/lib/telegram/streamer.js +157 -11
  25. package/package.json +2 -1
  26. package/polygram.js +260 -44
  27. package/lib/async-lock.js +0 -49
  28. package/lib/claude-bin.js +0 -246
  29. package/lib/compaction-warn.js +0 -59
  30. package/lib/context-usage.js +0 -93
  31. package/lib/process/channels-bridge-protocol.js +0 -199
  32. package/lib/process/channels-bridge-server.js +0 -274
  33. package/lib/process/channels-bridge.mjs +0 -477
  34. package/lib/process/cli-process.js +0 -4029
  35. package/lib/process/factory.js +0 -215
  36. package/lib/process/hook-event-tail.js +0 -162
  37. package/lib/process/hook-settings.js +0 -181
  38. package/lib/process/polygram-hook-append.js +0 -71
  39. package/lib/process/process.js +0 -215
  40. package/lib/process/sdk-process.js +0 -880
  41. package/lib/process-guard.js +0 -296
  42. package/lib/process-manager.js +0 -628
  43. package/lib/tmux/log-tail.js +0 -334
  44. package/lib/tmux/orphan-sweep.js +0 -79
  45. package/lib/tmux/poll-scheduler.js +0 -110
  46. package/lib/tmux/startup-gate.js +0 -250
  47. package/lib/tmux/tmux-runner.js +0 -412
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.11.0-rc.15",
4
+ "version": "0.18.0",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -236,6 +236,20 @@ const CODES = {
236
236
  isTransient: false,
237
237
  autoRecover: null,
238
238
  },
239
+ // AUTH_DISABLED: Anthropic has disabled Claude Code / subscription access on
240
+ // this account (e.g. non-payment) — orchestra's cli-process.js detects the
241
+ // streamed policy-string signature and rejects every pending turn
242
+ // immediately (see docs/AUTH_DISABLED_HANDLING_SPEC.md) instead of letting
243
+ // it wedge for 10 minutes into a generic TURN_TIMEOUT. Distinct from
244
+ // AUTH_EXPIRED (a token refresh problem, user-recoverable by re-login):
245
+ // this is an infra/billing condition on our end, so userMessage is null —
246
+ // the operator gets notified (dispatcher.js), the chat does not.
247
+ AUTH_DISABLED: {
248
+ kind: 'authDisabled',
249
+ userMessage: null,
250
+ isTransient: false,
251
+ autoRecover: null,
252
+ },
239
253
  };
240
254
 
241
255
  /**
package/lib/error/net.js CHANGED
@@ -156,6 +156,13 @@ function redactBotToken(s) {
156
156
  .replace(/(bot_token=)[^&\s]+/gi, '$1<redacted>');
157
157
  }
158
158
 
159
+ // redactBotToken() does not cover URL basic auth. Operator-configured API
160
+ // roots may contain userinfo, which must be stripped before logging.
161
+ function stripUrlCredentials(url) {
162
+ if (!url) return url;
163
+ return String(url).replace(/^(https?:\/\/)[^/@\s]+@/i, '$1');
164
+ }
165
+
159
166
  module.exports = {
160
167
  PRE_CONNECT_ERROR_CODES,
161
168
  RECOVERABLE_ERROR_CODES,
@@ -167,4 +174,5 @@ module.exports = {
167
174
  extractName,
168
175
  extractMessage,
169
176
  redactBotToken,
177
+ stripUrlCredentials,
170
178
  };
@@ -17,6 +17,7 @@
17
17
  'use strict';
18
18
 
19
19
  const { toTelegramHtml } = require('../telegram/format');
20
+ const { resolveRichTextEnabled } = require('../telegram/rich');
20
21
  const { getTopicConfig, getConfigWriteScope } = require('../session-key');
21
22
 
22
23
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
@@ -37,10 +38,14 @@ function createHandleConfigCallback({
37
38
 
38
39
  return async function handleConfigCallback(ctx) {
39
40
  const data = ctx.callbackQuery?.data || '';
40
- const m = String(data).match(/^cfg:(model|effort):(\S+)$/);
41
+ const m = String(data).match(/^cfg:(model|effort|richtext):(\S+)$/);
41
42
  if (!m) return;
42
43
  const setting = m[1];
43
44
  const value = m[2];
45
+ // richText is stored as a boolean so inherited configuration can
46
+ // distinguish an explicit false value from an unset value.
47
+ const isRichText = setting === 'richtext';
48
+ const parsedValue = isRichText ? value === 'on' : value;
44
49
 
45
50
  const chatId = String(ctx.callbackQuery.message?.chat?.id || '');
46
51
  const chatConfig = config.chats[chatId];
@@ -53,10 +58,17 @@ function createHandleConfigCallback({
53
58
  return;
54
59
  }
55
60
 
56
- const validValues = setting === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS;
57
- if (!validValues.includes(value)) {
58
- await ctx.answerCallbackQuery({ text: `Invalid ${setting}` }).catch(() => {});
59
- return;
61
+ if (isRichText) {
62
+ if (value !== 'on' && value !== 'off') {
63
+ await ctx.answerCallbackQuery({ text: 'Invalid richtext value' }).catch(() => {});
64
+ return;
65
+ }
66
+ } else {
67
+ const validValues = setting === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS;
68
+ if (!validValues.includes(value)) {
69
+ await ctx.answerCallbackQuery({ text: `Invalid ${setting}` }).catch(() => {});
70
+ return;
71
+ }
60
72
  }
61
73
 
62
74
  // Write to the scope the card belongs to: a topic card targets THAT topic
@@ -65,14 +77,21 @@ function createHandleConfigCallback({
65
77
  // topic's effective value, not the chat root (2026-06-12 bug).
66
78
  const callbackThreadIdEarly = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
67
79
  const { scope: writeScope, threadId: writeThreadId } =
68
- getConfigWriteScope(chatConfig, callbackThreadIdEarly);
69
- const oldValue = writeScope[setting] != null ? writeScope[setting] : chatConfig[setting];
70
- if (oldValue === value) {
80
+ getConfigWriteScope(chatConfig, callbackThreadIdEarly, {
81
+ allowNonIsolatedTopic: setting === 'richtext',
82
+ });
83
+ // The persisted camel-case key differs from the lowercase callback
84
+ // label used alongside model and effort.
85
+ const configField = isRichText ? 'richText' : setting;
86
+ const oldValue = isRichText
87
+ ? resolveRichTextEnabled(config, chatId, callbackThreadIdEarly)
88
+ : (writeScope[configField] != null ? writeScope[configField] : chatConfig[configField]);
89
+ if (oldValue === parsedValue) {
71
90
  await ctx.answerCallbackQuery({ text: `Already ${value}` }).catch(() => {});
72
91
  return;
73
92
  }
74
93
 
75
- writeScope[setting] = value;
94
+ writeScope[configField] = parsedValue;
76
95
  // Persist to config.json so the change survives a restart — without this,
77
96
  // every /model tap was lost on the next deploy (2026-06-12). Best-effort:
78
97
  // never let a disk hiccup swallow the in-memory change + live application.
@@ -81,15 +100,19 @@ function createHandleConfigCallback({
81
100
  const cmdUserId = ctx.callbackQuery.from?.id || null;
82
101
  const cmdUser = ctx.callbackQuery.from?.first_name || ctx.callbackQuery.from?.username || null;
83
102
  dbWrite(() => db.logConfigChange({
84
- chat_id: chatId, thread_id: writeThreadId, field: setting,
85
- old_value: oldValue, new_value: value,
103
+ chat_id: chatId, thread_id: writeThreadId, field: configField,
104
+ old_value: oldValue, new_value: parsedValue,
86
105
  user: cmdUser, user_id: cmdUserId, source: 'inline-button',
87
- }), `log ${setting} change`);
106
+ }), `log ${configField} change`);
88
107
 
89
108
  // Graceful application to the topic's session. SDK pm applies live
90
109
  // via setModel / applyFlagSettings; chatConfig is already updated
91
110
  // on disk above so a missing live session still picks up the new
92
- // value on its next cold spawn.
111
+ // value on its next cold spawn. richText is NOT a spawn-time flag —
112
+ // delivery reads it live at send time (lib/telegram/rich.js), so
113
+ // there is nothing to apply to a running session; only the system-
114
+ // prompt authoring hint lags to the next spawn, which the rich-text
115
+ // acknowledgement explains separately from model/effort changes.
93
116
  const callbackThreadId = callbackThreadIdEarly;
94
117
  const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
95
118
  let applied = false;
@@ -111,8 +134,9 @@ function createHandleConfigCallback({
111
134
  // mirrors the /model command card (polygram.js). getTopicConfig returns {}
112
135
  // for the chat-level card.
113
136
  const _cbTopicCfg = getTopicConfig(chatConfig, callbackThreadId);
114
- const newInfo = formatConfigInfoText(chatConfig, showRow, chatId, _cbTopicCfg);
115
- const newKeyboard = buildConfigKeyboard(chatConfig, showRow, _cbTopicCfg);
137
+ const effectiveRichText = resolveRichTextEnabled(config, chatId, callbackThreadId);
138
+ const newInfo = formatConfigInfoText(chatConfig, showRow, chatId, _cbTopicCfg, effectiveRichText);
139
+ const newKeyboard = buildConfigKeyboard(chatConfig, showRow, _cbTopicCfg, effectiveRichText);
116
140
  try {
117
141
  const { text: html, parseMode } = toTelegramHtml(newInfo);
118
142
  await ctx.editMessageText(html, {
@@ -123,9 +147,11 @@ function createHandleConfigCallback({
123
147
  logger.error?.(`[${botName}] config-card edit failed: ${err.message}`);
124
148
  }
125
149
 
126
- const ackText = anyActive
127
- ? `${setting} ${value} switching when finished`
128
- : `${setting} ${value}`;
150
+ // Rich delivery changes immediately, but its authoring hint changes
151
+ // on the next session spawn; surface both timings in the acknowledgement.
152
+ const ackText = isRichText
153
+ ? (parsedValue ? 'Rich text → on (delivery live; agent authors for it next session)' : 'Rich text → off')
154
+ : (anyActive ? `${setting} → ${value} — switching when finished` : `${setting} → ${value}`);
129
155
  await ctx.answerCallbackQuery({ text: ackText }).catch(() => {});
130
156
  };
131
157
  }
@@ -19,6 +19,16 @@
19
19
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
20
20
  const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
21
21
 
22
+ // Runtime callers pass the value resolved from the full config hierarchy.
23
+ // Direct callers may omit it and retain the local topic/chat behavior.
24
+ function resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText) {
25
+ if (typeof effectiveRichText === 'boolean') return effectiveRichText;
26
+ const pick = (v) => (typeof v === 'boolean' ? v : undefined);
27
+ return pick(topicConfig?.richText)
28
+ ?? pick(chatConfig?.richText)
29
+ ?? false;
30
+ }
31
+
22
32
  // Mirrors what `claude --model <alias>` resolves to. Display only —
23
33
  // polygram passes the alias (opus / sonnet / haiku) and lets claude
24
34
  // resolve. Bump on Claude release.
@@ -35,7 +45,7 @@ const MODEL_VERSIONS_DESC = {
35
45
  * null for the chat-level card) wins over chatConfig so the ✓ matches what a
36
46
  * topic actually runs — mirrors the spawn-path precedence (topic > chat).
37
47
  */
38
- function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null) {
48
+ function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null, effectiveRichText) {
39
49
  const model = (topicConfig && topicConfig.model) || chatConfig.model;
40
50
  const effort = (topicConfig && topicConfig.effort) || chatConfig.effort;
41
51
  const rows = [];
@@ -51,6 +61,15 @@ function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null) {
51
61
  callback_data: `cfg:effort:${e}`,
52
62
  })));
53
63
  }
64
+ // Rich text is a boolean toggle on the full config card. Model-only
65
+ // and effort-only cards keep their focused layouts.
66
+ if (show === 'all') {
67
+ const on = resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText);
68
+ rows.push([{
69
+ text: on ? '✓ Rich text: on' : 'Rich text: off',
70
+ callback_data: `cfg:richtext:${on ? 'off' : 'on'}`,
71
+ }]);
72
+ }
54
73
  return { inline_keyboard: rows };
55
74
  }
56
75
 
@@ -64,7 +83,7 @@ function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null) {
64
83
  * @param {(db, sessionKey) => string|null} deps.getClaudeSessionId
65
84
  */
66
85
  function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
67
- return function formatConfigInfoText(chatConfig, show, sessionKey, topicConfig = null) {
86
+ return function formatConfigInfoText(chatConfig, show, sessionKey, topicConfig = null, effectiveRichText) {
68
87
  const alive = pm.has(sessionKey) && !pm.get(sessionKey).closed;
69
88
  // Per-topic overrides win over chat-level for the displayed values,
70
89
  // mirroring the spawn path (polygram.js: topicConfig.agent ||
@@ -93,9 +112,17 @@ function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
93
112
  const effortLine = (runEffort && runEffort !== effort)
94
113
  ? `Effort: ${runEffort} (running) → ${effort} (pending — applies on your next message)`
95
114
  : `Effort: ${effort}`;
115
+ // Delivery reads richText live, while the authoring hint is fixed at
116
+ // session spawn. Explain that lag separately from model/effort,
117
+ // which have concrete running and configured values to compare.
118
+ const richText = resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText);
119
+ const richTextLine = richText
120
+ ? 'Rich text: on (headings/tables/checklists on qualifying replies; agent authors for it once this session next (re)spawns)'
121
+ : 'Rich text: off';
96
122
  const head =
97
123
  `${modelLine}\n` +
98
124
  `${effortLine}\n` +
125
+ `${richTextLine}\n` +
99
126
  `Agent: ${agent}\n` +
100
127
  `Process: ${alive ? 'warm' : 'cold'}\n` +
101
128
  `Session: ${sess}`;
@@ -22,6 +22,8 @@
22
22
 
23
23
  'use strict';
24
24
 
25
+ const { createAuthDisabledGate } = require('../ops/auth-disabled-gate');
26
+
25
27
  const CONCURRENT_WARN_THRESHOLD_DEFAULT = 20;
26
28
 
27
29
  // Startup auto-retry (option a, 2026-06-04): a short breath before silently
@@ -58,6 +60,13 @@ function createDispatcher({
58
60
  // Delay before a silent startup auto-retry re-dispatches (TMUX_SESSION_GONE).
59
61
  // Injected so tests can drive it to 0; production uses STARTUP_RETRY_DELAY_MS.
60
62
  startupRetryDelayMs = STARTUP_RETRY_DELAY_MS,
63
+ // AUTH_DISABLED dedupe/re-arm gate (docs/AUTH_DISABLED_HANDLING_SPEC.md).
64
+ // Defaulted (rather than required) so a missing DI param at some future
65
+ // call site degrades to "always notify, never dedupe" instead of throwing
66
+ // — AUTH_DISABLED is account-wide, so an unguarded throw here would hit
67
+ // every concurrent chat within seconds and risk tripping the storm circuit
68
+ // breaker (polygram.js uncaughtException handler) on a wiring bug alone.
69
+ authDisabledGate = createAuthDisabledGate(),
61
70
  // State accessors (need late binding because polygram.js mutates):
62
71
  getIsShuttingDown, // () → boolean
63
72
  logger = console,
@@ -224,12 +233,56 @@ function createDispatcher({
224
233
  chat_id: chatId, session_key: sessionKey, msg_id: msg?.message_id, code: err.code,
225
234
  });
226
235
  }
236
+ // AUTH_DISABLED (docs/AUTH_DISABLED_HANDLING_SPEC.md): Anthropic has
237
+ // disabled Claude Code access on this account (e.g. non-payment).
238
+ // Runs unconditionally — independent of wasAborted/isReplay/
239
+ // isShuttingDown — because the account-disabled condition is real
240
+ // regardless of what else was happening to this particular message.
241
+ // The chat itself is never told (classify.js maps this code to
242
+ // userMessage: null, so errorReplyText(err) below naturally suppresses
243
+ // it) — this only notifies the operator, once per outage window.
244
+ if (err.code === 'AUTH_DISABLED') {
245
+ logger.error?.(`[auth] (${botName}) Claude access DISABLED by Anthropic — turn rejected immediately instead of wedging; check the account/billing.`);
246
+ logEvent('auth-disabled', {
247
+ chat_id: chatId, session_key: sessionKey, msg_id: msg?.message_id,
248
+ error: err.message?.slice(0, 500),
249
+ });
250
+ // Never let the gate itself become a new failure mode — AUTH_DISABLED
251
+ // is account-wide, so a bad gate would throw on every concurrent
252
+ // chat within seconds of each other (see the storm circuit breaker
253
+ // this repo already has for exactly that shape of cascading failure).
254
+ let shouldNotify = false;
255
+ try {
256
+ shouldNotify = authDisabledGate.noteFailure();
257
+ } catch (gateErr) {
258
+ logger.error?.(`[auth] authDisabledGate.noteFailure failed: ${gateErr.message}`);
259
+ }
260
+ if (shouldNotify) {
261
+ const adminChatId = config.bot?.approvals?.adminChatId;
262
+ if (adminChatId) {
263
+ tg(bot, 'sendMessage', {
264
+ chat_id: adminChatId,
265
+ text: `🚫 Claude Code access appears DISABLED for this account (Anthropic-side, e.g. non-payment) — turns are failing instead of replying. Check the Anthropic account/billing. (bot: ${botName})`,
266
+ }, { source: 'auth-disabled-notify', botName }).catch((notifyErr) => {
267
+ logger.error?.(`[auth] operator notify failed: ${notifyErr.message}`);
268
+ });
269
+ } else {
270
+ logger.error?.(`[auth] AUTH_DISABLED fired but no config.bot.approvals.adminChatId configured — operator was not notified`);
271
+ }
272
+ }
273
+ }
227
274
  // rc.55: surface replay failures with a meaningful message.
228
275
  // Pre-rc.55 any boot-replay turn that failed for ANY reason
229
276
  // was silently dropped. The rc.51-onward boot-replay path is
230
277
  // a recovery primitive, not stale-message handling — when it
231
278
  // fails, the user IS still waiting.
232
- if (isReplay && !wasAborted && !isShuttingDown) {
279
+ // AUTH_DISABLED is excluded: it's an unconditional block like this
280
+ // one, gated on isReplay rather than err.code, so without this
281
+ // exclusion a replayed message failing with AUTH_DISABLED would fall
282
+ // through into this hardcoded reply — contradicting the "chat is
283
+ // never told, only the operator is" contract classify.js establishes
284
+ // via userMessage: null (found in code review).
285
+ if (isReplay && !wasAborted && !isShuttingDown && err.code !== 'AUTH_DISABLED') {
233
286
  tg(bot, 'sendMessage', {
234
287
  chat_id: chatId,
235
288
  text: '⚠️ This turn was interrupted and didn\'t complete on retry — please rephrase or simplify, or split into smaller steps.',
@@ -59,6 +59,25 @@ function createDropRedeliverer({ db, redeliver, logEvent = () => {}, logger = co
59
59
  ...(row.thread_id && { message_thread_id: Number(row.thread_id) }),
60
60
  ...(row.reply_to_id && { reply_to_message: { message_id: row.reply_to_id } }),
61
61
  };
62
+ // Persisted attachments ride _mergedAttachments (boot-replay parity):
63
+ // the native photo/voice fields can't be restored from the DB, and
64
+ // without this an attachment-only drop is blocked by the presence
65
+ // gate while a text+media drop silently loses its attachments.
66
+ // Isolated in its own try/catch (review: reliability+adversarial): this
67
+ // redelivery only fires ONCE, so a lookup failure here must degrade to
68
+ // a text-only redelivery, not abandon the whole drop.
69
+ let attRows = [];
70
+ try {
71
+ attRows = db.getAttachmentsByMessage?.(row.id) || [];
72
+ } catch (err) {
73
+ logger.error?.(`[drop-redeliver] attachment lookup failed for msg ${row.id}: ${err?.message || err}`);
74
+ }
75
+ if (attRows.length) {
76
+ reconstructed._mergedAttachments = attRows.map((a) => ({
77
+ kind: a.kind, name: a.name, mime_type: a.mime_type,
78
+ size: a.size_bytes, file_id: a.file_id, file_unique_id: a.file_unique_id,
79
+ }));
80
+ }
62
81
  await redeliver({ chatId: String(chatId), msg: reconstructed, source: 'drop' });
63
82
  } catch (err) {
64
83
  logger.error?.(`[drop-redeliver] ${err?.message || err}`);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * shouldHandle — the content-presence + chat-allowlist + mention/pairing
3
+ * intake gate. Runs for every tier of the D5 unified gate (fresh, edit,
4
+ * redelivery — see lib/handlers/gate-inbound.js).
5
+ *
6
+ * Extracted from polygram.js as a factory so the logic is unit-testable;
7
+ * deps are getters because polygram wires config / pairings / BOT_NAME
8
+ * at boot, after module load.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ function createShouldHandle({ getConfig, getPairings, getBotName } = {}) {
14
+ return function shouldHandle(msg, chatConfig, botUsername) {
15
+ const config = getConfig();
16
+ // A replayed / drop-redelivered message is reconstructed from the DB and
17
+ // carries its attachments ONLY in _mergedAttachments — the native
18
+ // photo/document/voice fields can't be restored. Count them as content,
19
+ // or every attachment-only turn (voice note, caption-less photo) becomes
20
+ // unrecoverable after a crash: blocked here as "empty" before dispatch.
21
+ const hasAttachment = !!(msg.document || msg.photo || msg.voice || msg.audio || msg.video)
22
+ || (Array.isArray(msg._mergedAttachments) && msg._mergedAttachments.length > 0);
23
+ if (!msg.text && !msg.caption && !hasAttachment) return false;
24
+ const chatId = msg.chat.id.toString();
25
+ if (!config.chats[chatId]) return false;
26
+
27
+ if (chatConfig.requireMention && msg.chat.type !== 'private') {
28
+ const text = msg.text || msg.caption || '';
29
+ const isReplyToBot = msg.reply_to_message?.from?.username === botUsername;
30
+ const hasMention = text.includes(`@${botUsername}`);
31
+ // A reply targeting some other user (not the bot) is a strong signal
32
+ // "this message is for that person, not me". Paired users normally
33
+ // bypass requireMention, but not in this case — without the guard a
34
+ // paired user saying "Gotcha!" to a teammate gets processed by the
35
+ // bot just because the user is paired, which is what bit us in
36
+ // UMI Group on 0.5.9 (bot leaked reasoning as a reply to "Gotcha!").
37
+ const repliesToOtherUser = !!msg.reply_to_message
38
+ && msg.reply_to_message.from?.username !== botUsername;
39
+ // Paired users bypass requireMention — operator-trusted, no @ needed
40
+ // every time. Skipped when they're replying to a non-bot user (above).
41
+ const pairings = getPairings();
42
+ const paired = !repliesToOtherUser && pairings && msg.from?.id
43
+ ? pairings.hasLivePairing({ bot_name: getBotName(), user_id: msg.from.id, chat_id: chatId })
44
+ : false;
45
+ if (!isReplyToBot && !hasMention && !paired) return false;
46
+ }
47
+
48
+ return true;
49
+ };
50
+ }
51
+
52
+ module.exports = { createShouldHandle };
@@ -101,9 +101,38 @@ function createMediaGroupBuffer({
101
101
  }
102
102
  };
103
103
 
104
+ // Read-only view of everything still buffered, without flushing.
105
+ // Shutdown uses this to mark undispatched album siblings replay-pending
106
+ // in the DB — flushing at exit would race process.exit and lose them.
107
+ const peekAll = () => {
108
+ const out = [];
109
+ for (const entry of entries.values()) out.push(...entry.messages);
110
+ return out;
111
+ };
112
+
113
+ // Atomically cancel every group's pending flush timer AND remove it from
114
+ // the buffer, WITHOUT calling onFlush. Unlike peekAll(), the caller now
115
+ // OWNS these groups — none of them can self-flush afterward. Shutdown
116
+ // uses this (not peekAll()) so a timer firing during the shutdown drain
117
+ // can't double-process a group that's already been DB-marked
118
+ // replay-pending. Groups stay separated by key (not flattened) so the
119
+ // caller can coalesce each one onto a single primary row, matching the
120
+ // live onFlush path's own semantics.
121
+ const takeAllForShutdown = () => {
122
+ const out = [];
123
+ for (const [key, entry] of entries) {
124
+ if (entry.timer) clearTimerFn(entry.timer);
125
+ out.push({ key, messages: entry.messages });
126
+ }
127
+ entries.clear();
128
+ return out;
129
+ };
130
+
104
131
  return {
105
132
  add,
106
133
  flushAll,
134
+ peekAll,
135
+ takeAllForShutdown,
107
136
  get size() { return entries.size; },
108
137
  };
109
138
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Dedupe/re-arm gate for AUTH_DISABLED operator notifications
3
+ * (docs/AUTH_DISABLED_HANDLING_SPEC.md).
4
+ *
5
+ * Fires the operator DM once per outage window: the first AUTH_DISABLED
6
+ * occurrence after construction (or after the last successful Claude turn)
7
+ * notifies; every occurrence after that is deduped until a turn succeeds
8
+ * again. `noteSuccess()` must only be called on a genuine Claude-turn
9
+ * success (see polygram.js handleMessage's non-error branch) — calling it
10
+ * on unrelated bot traffic (slash commands, unconfigured chats) would
11
+ * falsely clear an ongoing outage and cause repeat pages.
12
+ *
13
+ * `count`/`lastAt` track every occurrence regardless of dedupe, for the
14
+ * heartbeat counter (lib/ops/heartbeat.js).
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ function createAuthDisabledGate({ now = Date.now } = {}) {
20
+ let armed = true;
21
+ let count = 0;
22
+ let lastAt = null;
23
+
24
+ function noteFailure() {
25
+ count += 1;
26
+ lastAt = now();
27
+ if (!armed) return false;
28
+ armed = false;
29
+ return true;
30
+ }
31
+
32
+ function noteSuccess() {
33
+ armed = true;
34
+ }
35
+
36
+ function snapshot() {
37
+ return { count, lastAt, armed };
38
+ }
39
+
40
+ return { noteFailure, noteSuccess, snapshot };
41
+ }
42
+
43
+ module.exports = { createAuthDisabledGate };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Minimal file-based heartbeat for AUTH_DISABLED Netdata visibility
3
+ * (docs/AUTH_DISABLED_HANDLING_SPEC.md, Layer 3.3).
4
+ *
5
+ * File-only equivalent of water's lib/ops/heartbeat.js pattern — no
6
+ * /healthz endpoint, since this repo has no HTTP server to hang a route on.
7
+ * Wiring heartbeat.json into an actual Netdata alert (netdata_watch_units /
8
+ * a filecheck-or-cron-parses-JSON entry) is a VPS-side ops/ansible change
9
+ * outside this repo's scope; this only builds the code-side signal.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+
17
+ function createHeartbeat({ dataDir, authDisabledGate, intervalMs = 60_000, now = Date.now }) {
18
+ const file = path.join(dataDir, 'heartbeat.json');
19
+ let timer = null;
20
+
21
+ function snapshot() {
22
+ const gate = authDisabledGate.snapshot();
23
+ return { ts: now(), authDisabled: gate.count, authDisabledLastAt: gate.lastAt };
24
+ }
25
+
26
+ function beat() {
27
+ const snap = snapshot();
28
+ try {
29
+ const tmp = `${file}.tmp`;
30
+ fs.writeFileSync(tmp, JSON.stringify(snap));
31
+ fs.renameSync(tmp, file); // atomic
32
+ } catch (err) {
33
+ // Best-effort (matches water's heartbeat.js) — must never throw into the
34
+ // interval timer or affect a turn. Logged (water's version doesn't) because
35
+ // this file's whole purpose is surfacing a silent failure; a heartbeat that
36
+ // can go silently stale defeats that purpose the same way the bug it's
37
+ // meant to catch does.
38
+ console.error(`[auth] heartbeat write failed: ${err.message}`);
39
+ }
40
+ return snap;
41
+ }
42
+
43
+ function start() {
44
+ beat();
45
+ timer = setInterval(beat, intervalMs);
46
+ timer.unref?.();
47
+ }
48
+
49
+ function stop() {
50
+ if (timer) clearInterval(timer);
51
+ }
52
+
53
+ return { start, stop, beat, snapshot, file };
54
+ }
55
+
56
+ module.exports = { createHeartbeat };
@@ -142,12 +142,23 @@ function createChannelsToolDispatcher({
142
142
  // session's bubble (review 2026-06-12). Bounded per session (insertion-order
143
143
  // eviction) so a long session can't grow it without limit.
144
144
  const ownedMessageIds = new Map(); // sessionKey → Set<number>
145
+ // The dispatcher is created once per daemon and shared by every CliProcess,
146
+ // so the OUTER map also needs a bound: one entry per sessionKey ever seen,
147
+ // never dropped on session kill/eviction → slow unbounded growth on a
148
+ // long-lived multi-chat bot. Insertion-order eviction; re-touching a key
149
+ // re-inserts it so active sessions stay resident.
150
+ const OWNED_SESSION_CAP = 128;
145
151
  const rememberOwned = (sk, id) => {
146
152
  if (id == null || sk == null) return;
147
153
  const n = Number(id);
148
154
  if (!Number.isFinite(n)) return;
149
155
  let set = ownedMessageIds.get(sk);
150
- if (!set) { set = new Set(); ownedMessageIds.set(sk, set); }
156
+ if (!set) { set = new Set(); }
157
+ else ownedMessageIds.delete(sk); // re-insert → freshest in iteration order
158
+ ownedMessageIds.set(sk, set);
159
+ while (ownedMessageIds.size > OWNED_SESSION_CAP) {
160
+ ownedMessageIds.delete(ownedMessageIds.keys().next().value);
161
+ }
151
162
  set.add(n);
152
163
  while (set.size > OWNED_MSG_CAP) set.delete(set.values().next().value);
153
164
  };
@@ -25,7 +25,8 @@
25
25
 
26
26
  const agentLoader = require('../agents/loader');
27
27
  const { getTopicConfig } = require('../session-key');
28
- const { appendDisplayHint } = require('../telegram/display-hint');
28
+ const { appendDisplayHint, buildPolygramDisplayHint } = require('../telegram/display-hint');
29
+ const { resolveRichTextEnabled } = require('../telegram/rich');
29
30
 
30
31
  // Env: SHADOW semantics — must enumerate every var the spawned
31
32
  // worker is allowed to see. Anything else is dropped.
@@ -162,7 +163,14 @@ function createBuildSdkOptions({
162
163
 
163
164
  // Append polygram's display constraints to the systemPrompt —
164
165
  // infrastructure-layer hint, not agent business logic.
165
- composed.systemPrompt = appendDisplayHint(composed.systemPrompt);
166
+ //
167
+ // Resolve the authoring hint through the same topic→chat→bot→default
168
+ // precedence as delivery. The hint is fixed at spawn, while delivery
169
+ // reads the value live for each send.
170
+ composed.systemPrompt = appendDisplayHint(
171
+ composed.systemPrompt,
172
+ buildPolygramDisplayHint(resolveRichTextEnabled(config, chatId, threadId)),
173
+ );
166
174
  return composed;
167
175
  };
168
176
  }
@@ -21,7 +21,7 @@
21
21
  'use strict';
22
22
 
23
23
  const { getTopicConfig } = require('../session-key');
24
- const { pickBackend } = require('../process/factory');
24
+ const { pickBackend } = require('@shumkov/orchestra');
25
25
 
26
26
  function createSdkCallbacks({
27
27
  db,
@@ -102,6 +102,7 @@ function createSdkCallbacks({
102
102
  effort: topicConfig.effort || chatConfig.effort || null,
103
103
  pm_backend: pickBackend({
104
104
  config, chatId: entry.chatId, threadId: entry.threadId || null,
105
+ pmDefault: 'sdk', // polygram's default backend (orchestra defaults to 'cli')
105
106
  }),
106
107
  }), `upsert session ${sessionKey}`);
107
108
  },
@@ -31,7 +31,19 @@ const RULES = [
31
31
  { name: 'github-pat', tier: 'high', re: /\bgithub_pat_[A-Za-z0-9_]{60,}\b/g },
32
32
  { name: 'github-token', tier: 'high', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
33
33
  { name: 'anthropic', tier: 'high', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
34
- { name: 'openai', tier: 'high', re: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
34
+ // OpenAI keys come in two real shapes: sk-proj-<token> (project keys,
35
+ // hyphens/underscores allowed in the body) and legacy sk-<base62, 40+>.
36
+ // The body class must NOT allow hyphens after a bare `sk-` — ordinary
37
+ // hyphenated slugs ("sk-refactor-the-database-layer") would auto-redact
38
+ // at high tier, irreversibly destroying user/bot text. `sk-ant-` and
39
+ // `sk-proj-` can't reach the legacy rule: their 3-4 alnum chars end at
40
+ // a hyphen long before the 40-char floor.
41
+ { name: 'openai', tier: 'high', re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/g },
42
+ // Review (security + adversarial): sk-proj- isn't the only hyphenated-
43
+ // prefix shape OpenAI issues — service-account and admin keys follow the
44
+ // same pattern and were slipping past undetected after the tighten above.
45
+ { name: 'openai', tier: 'high', re: /\bsk-(?:svcacct|admin)-[A-Za-z0-9_-]{20,}\b/g },
46
+ { name: 'openai', tier: 'high', re: /\bsk-[A-Za-z0-9]{40,}\b/g },
35
47
  { name: 'slack', tier: 'high', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
36
48
  { name: 'stripe', tier: 'high', re: /\bsk_live_[A-Za-z0-9]{20,}\b/g },
37
49
  { name: 'tg-bot-token', tier: 'high', re: /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/g },