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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
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",
|
package/lib/error/classify.js
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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[
|
|
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:
|
|
85
|
-
old_value: oldValue, new_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 ${
|
|
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
|
|
115
|
-
const
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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();
|
|
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
|
};
|
package/lib/sdk/build-options.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/sdk/callbacks.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
'use strict';
|
|
22
22
|
|
|
23
23
|
const { getTopicConfig } = require('../session-key');
|
|
24
|
-
const { pickBackend } = require('
|
|
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
|
},
|
package/lib/secret-detect.js
CHANGED
|
@@ -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
|
-
|
|
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 },
|