polygram 0.17.11 → 0.17.12
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/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/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +188 -41
- 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.17.12",
|
|
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
|
/**
|
|
@@ -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/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 },
|
package/lib/telegram/chunk.js
CHANGED
|
@@ -124,6 +124,20 @@ function resolveChunkEarlyReturn(text, limit) {
|
|
|
124
124
|
return undefined;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// A hard cut at a fixed UTF-16 offset can land between the two code units
|
|
128
|
+
// of a surrogate pair (astral chars: emoji, rare CJK, math symbols) — the
|
|
129
|
+
// pair then renders as U+FFFD on both sides of the boundary. Back the cut
|
|
130
|
+
// off one unit when it would split a pair. Never returns 0 (a degenerate
|
|
131
|
+
// limit of 1 keeps the raw cut rather than looping forever on an empty
|
|
132
|
+
// chunk).
|
|
133
|
+
function surrogateSafeCut(text, idx) {
|
|
134
|
+
if (idx > 1 && idx < text.length) {
|
|
135
|
+
const c = text.charCodeAt(idx - 1);
|
|
136
|
+
if (c >= 0xD800 && c <= 0xDBFF) return idx - 1;
|
|
137
|
+
}
|
|
138
|
+
return idx;
|
|
139
|
+
}
|
|
140
|
+
|
|
127
141
|
// Generic break-resolver loop shared with markdown variant. The resolver
|
|
128
142
|
// receives a `window` (text.slice(0, limit)) and returns where to break.
|
|
129
143
|
// Negative / out-of-range break indices fall back to hard-cut at limit.
|
|
@@ -136,7 +150,7 @@ function chunkTextByBreakResolver(text, limit, resolveBreakIndex) {
|
|
|
136
150
|
const candidateBreak = resolveBreakIndex(remaining.slice(0, limit));
|
|
137
151
|
const breakIdx = Number.isFinite(candidateBreak) && candidateBreak > 0 && candidateBreak <= limit
|
|
138
152
|
? candidateBreak
|
|
139
|
-
: limit;
|
|
153
|
+
: surrogateSafeCut(remaining, limit);
|
|
140
154
|
const chunk = remaining.slice(0, breakIdx).trimEnd();
|
|
141
155
|
if (chunk.length > 0) chunks.push(chunk);
|
|
142
156
|
// If we broke on a separator (whitespace), consume it — don't carry it
|
|
@@ -202,7 +216,7 @@ function chunkMarkdownText(text, limit) {
|
|
|
202
216
|
while (remaining.length > limit) {
|
|
203
217
|
const spans = parseFenceSpans(remaining);
|
|
204
218
|
const softBreak = pickSafeBreakIndex(remaining.slice(0, limit), spans);
|
|
205
|
-
let breakIdx = softBreak > 0 ? softBreak : limit;
|
|
219
|
+
let breakIdx = softBreak > 0 ? softBreak : surrogateSafeCut(remaining, limit);
|
|
206
220
|
const initialFence = isSafeFenceBreak(spans, breakIdx) ? undefined : findFenceSpanAt(spans, breakIdx);
|
|
207
221
|
let fenceToSplit = initialFence;
|
|
208
222
|
if (initialFence) {
|
|
@@ -215,7 +229,7 @@ function chunkMarkdownText(text, limit) {
|
|
|
215
229
|
// Caller will see a malformed chunk, but that's a degenerate
|
|
216
230
|
// input case (limit smaller than the close marker).
|
|
217
231
|
fenceToSplit = undefined;
|
|
218
|
-
breakIdx = limit;
|
|
232
|
+
breakIdx = surrogateSafeCut(remaining, limit);
|
|
219
233
|
} else {
|
|
220
234
|
// Look for a newline inside the fence body that's late enough
|
|
221
235
|
// to make progress (past the open line + at least one body line)
|
|
@@ -243,11 +257,11 @@ function chunkMarkdownText(text, limit) {
|
|
|
243
257
|
// No safe in-fence newline found and no room to add one —
|
|
244
258
|
// give up on splitting this fence; hard-cut at limit.
|
|
245
259
|
fenceToSplit = undefined;
|
|
246
|
-
breakIdx = limit;
|
|
260
|
+
breakIdx = surrogateSafeCut(remaining, limit);
|
|
247
261
|
} else {
|
|
248
262
|
// Force the break; chunker will append a synthetic newline
|
|
249
263
|
// before the close line.
|
|
250
|
-
breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline);
|
|
264
|
+
breakIdx = surrogateSafeCut(remaining, Math.max(minProgressIdx, maxIdxIfNeedNewline));
|
|
251
265
|
}
|
|
252
266
|
}
|
|
253
267
|
}
|
|
@@ -294,7 +308,7 @@ function chunkMarkdownText(text, limit) {
|
|
|
294
308
|
// limit (cheap heuristic — much better than mid-word).
|
|
295
309
|
const window = rest.slice(0, limit);
|
|
296
310
|
const lastNl = window.lastIndexOf('\n', limit - 1);
|
|
297
|
-
const cutAt = lastNl > limit - 200 ? lastNl + 1 : limit;
|
|
311
|
+
const cutAt = lastNl > limit - 200 ? lastNl + 1 : surrogateSafeCut(rest, limit);
|
|
298
312
|
safe.push(rest.slice(0, cutAt));
|
|
299
313
|
rest = rest.slice(cutAt);
|
|
300
314
|
}
|
|
@@ -51,7 +51,9 @@
|
|
|
51
51
|
* @param {object} opts.bot — grammy bot instance
|
|
52
52
|
* @param {Function} opts.tg — (bot, method, params, meta) → Promise<res>
|
|
53
53
|
* @param {string|number} opts.chatId
|
|
54
|
-
* @param {?number} opts.threadId
|
|
54
|
+
* @param {?(number|string)} opts.threadId — Telegram message_thread_id (or null).
|
|
55
|
+
* Treated as opaque, deliver.js-style: the cli backend passes a STRING, so an
|
|
56
|
+
* integer-only guard would drop the id and send stickers to the General topic.
|
|
55
57
|
* @param {?number} opts.replyToMessageId — inbound msg_id this reply targets; null for unsolicited
|
|
56
58
|
* @param {boolean} [opts.applyReactions] — apply [react:EMOJI] tags to replyToMessageId. False for
|
|
57
59
|
* unsolicited (autonomous-wakeup) where there IS no target;
|
|
@@ -173,7 +175,7 @@ async function processAndDeliverAgentText({
|
|
|
173
175
|
await tg(bot, 'sendSticker', {
|
|
174
176
|
chat_id: chatId,
|
|
175
177
|
sticker: parsed.sticker,
|
|
176
|
-
...(
|
|
178
|
+
...(threadId != null && { message_thread_id: threadId }),
|
|
177
179
|
}, {
|
|
178
180
|
...meta,
|
|
179
181
|
source: `${source}-sticker`,
|
|
@@ -192,7 +194,7 @@ async function processAndDeliverAgentText({
|
|
|
192
194
|
await tg(bot, 'sendSticker', {
|
|
193
195
|
chat_id: chatId,
|
|
194
196
|
sticker: s.fileId,
|
|
195
|
-
...(
|
|
197
|
+
...(threadId != null && { message_thread_id: threadId }),
|
|
196
198
|
}, {
|
|
197
199
|
...meta,
|
|
198
200
|
source: `${source}-inline-sticker`,
|
package/lib/telegram/streamer.js
CHANGED
|
@@ -121,7 +121,12 @@ function createStreamer({
|
|
|
121
121
|
// which preserves all content without any byte-cut.
|
|
122
122
|
function truncateForLive(s) {
|
|
123
123
|
if (s.length <= maxLen) return s;
|
|
124
|
-
|
|
124
|
+
// Back off one unit if the cut would split a surrogate pair (emoji /
|
|
125
|
+
// astral chars) — a lone high surrogate renders as � in the live bubble.
|
|
126
|
+
let cut = maxLen - 3;
|
|
127
|
+
const cc = s.charCodeAt(cut - 1);
|
|
128
|
+
if (cc >= 0xD800 && cc <= 0xDBFF) cut -= 1;
|
|
129
|
+
return s.slice(0, cut) + '...';
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
// rc.67: scrub recognised inline tags BEFORE the streamer commits text
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.12",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc/client.js",
|
|
6
6
|
"bin": {
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@anthropic-ai/claude-agent-sdk": "0.2.123",
|
|
65
65
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
66
|
+
"@shumkov/orchestra": "^0.4.0",
|
|
66
67
|
"better-sqlite3": "^12.9.0",
|
|
67
68
|
"grammy": "^1.42.0",
|
|
68
69
|
"markdown-it": "^14.1.1",
|