polygram 0.3.5 → 0.4.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/README.md +23 -19
- package/config.example.json +10 -2
- package/lib/abort-detector.js +63 -0
- package/lib/db.js +15 -0
- package/lib/net-errors.js +94 -0
- package/lib/process-manager.js +77 -23
- package/lib/status-reactions.js +168 -0
- package/lib/stream-reply.js +5 -1
- package/lib/telegram-format.js +36 -0
- package/lib/telegram.js +98 -7
- package/lib/typing-indicator.js +143 -0
- package/migrations/005-polling-state.sql +14 -0
- package/package.json +5 -4
- package/polygram.js +251 -49
- package/scripts/doctor.js +324 -0
- package/scripts/smoke.js +0 -122
package/lib/telegram.js
CHANGED
|
@@ -20,6 +20,52 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const crypto = require('crypto');
|
|
23
|
+
const { toTelegramMarkdown } = require('./telegram-format');
|
|
24
|
+
const { isSafeToRetry } = require('./net-errors');
|
|
25
|
+
|
|
26
|
+
// Topic deletion race: a user can delete a forum topic while a turn is in
|
|
27
|
+
// flight, turning a valid `message_thread_id` into a 404. Telegram's error
|
|
28
|
+
// string is specific enough to pattern-match; on hit we retry without the
|
|
29
|
+
// thread param so the reply still lands in the chat root.
|
|
30
|
+
const THREAD_NOT_FOUND_RE = /(Bad Request:\s*message thread not found|TOPIC_DELETED)/i;
|
|
31
|
+
|
|
32
|
+
function isThreadNotFound(err) {
|
|
33
|
+
const msg = err && (err.description || err.message);
|
|
34
|
+
return typeof msg === 'string' && THREAD_NOT_FOUND_RE.test(msg);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Short linear backoff before the single pre-connect retry. 150ms is long
|
|
38
|
+
// enough for DNS / local network glitches to clear, short enough that a
|
|
39
|
+
// user turn finishing doesn't notice.
|
|
40
|
+
const PRE_CONNECT_RETRY_DELAY_MS = 150;
|
|
41
|
+
|
|
42
|
+
function sleep(ms) {
|
|
43
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Methods whose `text` / `caption` fields we auto-format into MarkdownV2.
|
|
47
|
+
// Anything else passes through untouched (setMessageReaction, sendSticker,
|
|
48
|
+
// deleteMessage, etc. have no text to format).
|
|
49
|
+
const FORMATTABLE_METHODS = new Set(['sendMessage', 'editMessageText']);
|
|
50
|
+
|
|
51
|
+
// Apply Claude-markdown → Telegram-MarkdownV2 conversion in-place on the
|
|
52
|
+
// params object. Skipped if:
|
|
53
|
+
// - Method doesn't carry formattable text.
|
|
54
|
+
// - Caller already set a parse_mode (respect explicit choice).
|
|
55
|
+
// - Caller opted out via meta.plainText.
|
|
56
|
+
// On any conversion failure we silently fall through to plain text.
|
|
57
|
+
function applyFormatting(method, params, meta) {
|
|
58
|
+
if (meta.plainText === true) return;
|
|
59
|
+
if (!FORMATTABLE_METHODS.has(method)) return;
|
|
60
|
+
if (params.parse_mode != null) return;
|
|
61
|
+
const field = params.text ? 'text' : (params.caption ? 'caption' : null);
|
|
62
|
+
if (!field) return;
|
|
63
|
+
const { text: converted, parseMode } = toTelegramMarkdown(params[field]);
|
|
64
|
+
if (parseMode) {
|
|
65
|
+
params[field] = converted;
|
|
66
|
+
params.parse_mode = parseMode;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
23
69
|
|
|
24
70
|
// Synthetic negative msg_id for a pending outbound row. 48 random bits — the
|
|
25
71
|
// birthday bound for collision within the (chat_id, msg_id) unique constraint
|
|
@@ -48,9 +94,15 @@ function deriveOutboundText(method, params, meta) {
|
|
|
48
94
|
async function send({ bot, method, params, db = null, meta = {}, logger = console }) {
|
|
49
95
|
const chatId = params.chat_id != null ? String(params.chat_id) : null;
|
|
50
96
|
const threadId = params.message_thread_id != null ? String(params.message_thread_id) : null;
|
|
97
|
+
// Capture outbound text BEFORE markdown-escaping so the transcript stays
|
|
98
|
+
// human-readable. "Mr. O'Brien said 3.14" is searchable; "Mr\. O'Brien
|
|
99
|
+
// said 3\.14" is not. The user's chat view shows the rendered text, which
|
|
100
|
+
// matches the DB row modulo heading/bullet downgrades.
|
|
51
101
|
const text = deriveOutboundText(method, params, meta);
|
|
52
102
|
const tracksMessage = !METHODS_WITHOUT_MSG.has(method);
|
|
53
103
|
|
|
104
|
+
applyFormatting(method, params, meta);
|
|
105
|
+
|
|
54
106
|
let rowId = null;
|
|
55
107
|
if (db && tracksMessage && chatId) {
|
|
56
108
|
const pendingId = nextPendingId();
|
|
@@ -73,16 +125,55 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
|
|
|
73
125
|
}
|
|
74
126
|
|
|
75
127
|
let res;
|
|
128
|
+
const attempt = async (p) => bot.api.raw[method](p);
|
|
76
129
|
try {
|
|
77
|
-
|
|
130
|
+
try {
|
|
131
|
+
res = await attempt(params);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Pre-connect errors (DNS flap, TCP refused, net unreach) never
|
|
134
|
+
// reached Telegram, so retrying can't double-send. Retry ONCE after
|
|
135
|
+
// a short delay before treating as fatal. Post-connect errors
|
|
136
|
+
// (ETIMEDOUT, EPIPE, 5xx) are NOT retried — the message might have
|
|
137
|
+
// landed server-side.
|
|
138
|
+
if (isSafeToRetry(err)) {
|
|
139
|
+
try { db?.logEvent('telegram-retry', { chat_id: chatId, method, code: err.code, name: err.name }); }
|
|
140
|
+
catch {}
|
|
141
|
+
await sleep(PRE_CONNECT_RETRY_DELAY_MS);
|
|
142
|
+
res = await attempt(params);
|
|
143
|
+
} else {
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
78
147
|
} catch (err) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
148
|
+
// Forum topic was deleted mid-turn — retry to chat root rather than
|
|
149
|
+
// failing the whole reply. Only for methods that accept a thread id
|
|
150
|
+
// (send*), and only once per call.
|
|
151
|
+
if (isThreadNotFound(err) && params.message_thread_id != null) {
|
|
152
|
+
const retryParams = { ...params };
|
|
153
|
+
delete retryParams.message_thread_id;
|
|
154
|
+
try {
|
|
155
|
+
logger.error?.(`[telegram] ${method}: thread gone, retrying without thread_id`);
|
|
156
|
+
res = await bot.api.raw[method](retryParams);
|
|
157
|
+
try { db?.logEvent('telegram-thread-fallback', { chat_id: chatId, method, original_thread_id: String(params.message_thread_id) }); }
|
|
158
|
+
catch {}
|
|
159
|
+
} catch (err2) {
|
|
160
|
+
if (rowId != null && db) {
|
|
161
|
+
try { db.markOutboundFailed(rowId, err2.message); }
|
|
162
|
+
catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
|
|
163
|
+
try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: err2.message }); }
|
|
164
|
+
catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
|
|
165
|
+
}
|
|
166
|
+
throw err2;
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
if (rowId != null && db) {
|
|
170
|
+
try { db.markOutboundFailed(rowId, err.message); }
|
|
171
|
+
catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
|
|
172
|
+
try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: err.message }); }
|
|
173
|
+
catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
|
|
174
|
+
}
|
|
175
|
+
throw err;
|
|
84
176
|
}
|
|
85
|
-
throw err;
|
|
86
177
|
}
|
|
87
178
|
|
|
88
179
|
if (rowId != null && db) {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typing indicator with circuit breaker.
|
|
3
|
+
*
|
|
4
|
+
* Problem: sendChatAction('typing') is called every 4s while a turn is in
|
|
5
|
+
* flight. If the bot was removed from a chat, blocked by a user, or the
|
|
6
|
+
* chat was deleted, the API returns 401 Forbidden. The naive `.catch(()=>{})`
|
|
7
|
+
* that polygram had before meant we'd keep hammering the API for the
|
|
8
|
+
* duration of the (already-doomed) turn — hundreds of failed requests that
|
|
9
|
+
* chip away at rate-limit budget and drown real signal in logs.
|
|
10
|
+
*
|
|
11
|
+
* Fix (mirrors OpenClaw's createTelegramSendChatActionHandler pattern):
|
|
12
|
+
* per-chat circuit breaker with exponential backoff. After N consecutive
|
|
13
|
+
* 401s we suspend for this chat entirely — no more typing pings until the
|
|
14
|
+
* next successful turn resets the counter.
|
|
15
|
+
*
|
|
16
|
+
* State is per-chat so one dead chat doesn't silence the bot everywhere.
|
|
17
|
+
* We keep it in-memory (not DB-persisted) — restart clears and we'll find
|
|
18
|
+
* out again the first time we try; the cost of being re-wrong is just a
|
|
19
|
+
* handful of 401s, not worth persisting.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const DEFAULT_INTERVAL_MS = 4000;
|
|
23
|
+
const DEFAULT_MAX_CONSECUTIVE_401 = 10;
|
|
24
|
+
const DEFAULT_MAX_BACKOFF_MS = 300_000; // 5 min — matches OpenClaw
|
|
25
|
+
|
|
26
|
+
// Shared state keyed by chat_id. Exported via resetChatTypingState() for tests.
|
|
27
|
+
const chatState = new Map();
|
|
28
|
+
|
|
29
|
+
function getState(chatId) {
|
|
30
|
+
let s = chatState.get(chatId);
|
|
31
|
+
if (!s) {
|
|
32
|
+
s = { failures: 0, suspendedUntil: 0 };
|
|
33
|
+
chatState.set(chatId, s);
|
|
34
|
+
}
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isAuthFailure(err) {
|
|
39
|
+
const code = err?.error_code ?? err?.status;
|
|
40
|
+
const desc = err?.description || err?.message || '';
|
|
41
|
+
return code === 401 || code === 403 || /Forbidden|Unauthorized|bot was blocked|chat not found/i.test(desc);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Exponential backoff: 1s, 2s, 4s, 8s, …, capped at maxBackoffMs.
|
|
45
|
+
function backoffDelay(failures, maxBackoffMs) {
|
|
46
|
+
const ms = Math.min(maxBackoffMs, 1000 * Math.pow(2, Math.max(0, failures - 1)));
|
|
47
|
+
return ms;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Start the typing-indicator loop for a chat. Returns a stop function.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} deps
|
|
54
|
+
* @param {import('grammy').Bot} deps.bot
|
|
55
|
+
* @param {string|number} deps.chatId
|
|
56
|
+
* @param {string} [deps.threadId]
|
|
57
|
+
* @param {number} [deps.intervalMs]
|
|
58
|
+
* @param {number} [deps.maxConsecutive401]
|
|
59
|
+
* @param {number} [deps.maxBackoffMs]
|
|
60
|
+
* @param {object} [deps.logger] - { error, log } — default console
|
|
61
|
+
* @param {(evt: {kind: string, chat_id: string, detail?: object}) => void} [deps.onEvent]
|
|
62
|
+
* Hook for polygram's `events` DB log.
|
|
63
|
+
*/
|
|
64
|
+
function startTyping({
|
|
65
|
+
bot, chatId, threadId,
|
|
66
|
+
intervalMs = DEFAULT_INTERVAL_MS,
|
|
67
|
+
maxConsecutive401 = DEFAULT_MAX_CONSECUTIVE_401,
|
|
68
|
+
maxBackoffMs = DEFAULT_MAX_BACKOFF_MS,
|
|
69
|
+
logger = console,
|
|
70
|
+
onEvent = null,
|
|
71
|
+
} = {}) {
|
|
72
|
+
const key = String(chatId);
|
|
73
|
+
const opts = threadId ? { message_thread_id: threadId } : {};
|
|
74
|
+
let timer = null;
|
|
75
|
+
let stopped = false;
|
|
76
|
+
|
|
77
|
+
const tick = async () => {
|
|
78
|
+
if (stopped) return;
|
|
79
|
+
const s = getState(key);
|
|
80
|
+
if (s.suspendedUntil > Date.now()) return;
|
|
81
|
+
try {
|
|
82
|
+
await bot.api.sendChatAction(chatId, 'typing', opts);
|
|
83
|
+
// Success — reset failure counter.
|
|
84
|
+
if (s.failures > 0) {
|
|
85
|
+
onEvent?.({ kind: 'typing-recovered', chat_id: key, detail: { after_failures: s.failures } });
|
|
86
|
+
}
|
|
87
|
+
s.failures = 0;
|
|
88
|
+
s.suspendedUntil = 0;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (!isAuthFailure(err)) {
|
|
91
|
+
// Other errors (network blip, 500, etc.): don't open the circuit.
|
|
92
|
+
// Let the next tick try again. Log once at high verbosity.
|
|
93
|
+
logger.error?.(`[typing] ${key}: ${err?.description || err?.message}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
s.failures += 1;
|
|
97
|
+
if (s.failures >= maxConsecutive401) {
|
|
98
|
+
// Circuit fully open — suspend for the maxBackoffMs window; won't
|
|
99
|
+
// try again until then. Successful turns (or a subsequent tick past
|
|
100
|
+
// the suspend window) will test the waters.
|
|
101
|
+
s.suspendedUntil = Date.now() + maxBackoffMs;
|
|
102
|
+
onEvent?.({ kind: 'typing-suspended', chat_id: key, detail: {
|
|
103
|
+
failures: s.failures, suspend_ms: maxBackoffMs,
|
|
104
|
+
} });
|
|
105
|
+
logger.error?.(`[typing] ${key}: ${s.failures} consecutive auth failures; suspending ${maxBackoffMs / 1000}s`);
|
|
106
|
+
} else {
|
|
107
|
+
// Partial open — back off for an exponentially growing window.
|
|
108
|
+
s.suspendedUntil = Date.now() + backoffDelay(s.failures, maxBackoffMs);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Fire once immediately, then every intervalMs.
|
|
114
|
+
tick();
|
|
115
|
+
timer = setInterval(tick, intervalMs);
|
|
116
|
+
timer.unref?.();
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
stopped = true;
|
|
120
|
+
if (timer) clearInterval(timer);
|
|
121
|
+
timer = null;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resetChatTypingState(chatId) {
|
|
126
|
+
if (chatId == null) chatState.clear();
|
|
127
|
+
else chatState.delete(String(chatId));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getChatTypingState(chatId) {
|
|
131
|
+
return chatState.get(String(chatId));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
startTyping,
|
|
136
|
+
resetChatTypingState,
|
|
137
|
+
getChatTypingState,
|
|
138
|
+
isAuthFailure,
|
|
139
|
+
backoffDelay,
|
|
140
|
+
DEFAULT_INTERVAL_MS,
|
|
141
|
+
DEFAULT_MAX_CONSECUTIVE_401,
|
|
142
|
+
DEFAULT_MAX_BACKOFF_MS,
|
|
143
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- Persist grammy's update offset so a polygram restart doesn't re-process
|
|
2
|
+
-- the entire getUpdates backlog from the last 24h. Grammy's in-memory
|
|
3
|
+
-- offset resets to 0 on boot; Telegram replies with every unconfirmed
|
|
4
|
+
-- update. For a bot that went down overnight with active chats, that can
|
|
5
|
+
-- mean re-running dozens of turns on stale messages.
|
|
6
|
+
--
|
|
7
|
+
-- One row per bot. Row is upserted on every successful getUpdates batch
|
|
8
|
+
-- that returned at least one update.
|
|
9
|
+
|
|
10
|
+
CREATE TABLE IF NOT EXISTS polling_state (
|
|
11
|
+
bot_name TEXT PRIMARY KEY,
|
|
12
|
+
last_update_id INTEGER NOT NULL,
|
|
13
|
+
ts INTEGER NOT NULL
|
|
14
|
+
);
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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": {
|
|
7
7
|
"polygram": "polygram.js",
|
|
8
8
|
"polygram-split-db": "scripts/split-db.js",
|
|
9
9
|
"polygram-ipc": "scripts/ipc-smoke.js",
|
|
10
|
-
"polygram-
|
|
10
|
+
"polygram-doctor": "scripts/doctor.js"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"polygram.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"migrations/",
|
|
17
17
|
"scripts/split-db.js",
|
|
18
18
|
"scripts/ipc-smoke.js",
|
|
19
|
-
"scripts/
|
|
19
|
+
"scripts/doctor.js",
|
|
20
20
|
"skills/",
|
|
21
21
|
"commands/",
|
|
22
22
|
".claude-plugin/",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"type": "commonjs",
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"better-sqlite3": "^12.9.0",
|
|
64
|
-
"grammy": "^1.42.0"
|
|
64
|
+
"grammy": "^1.42.0",
|
|
65
|
+
"telegramify-markdown": "^1.3.3"
|
|
65
66
|
}
|
|
66
67
|
}
|