polygram 0.8.0-rc.56 → 0.8.0-rc.58
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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/lib/ipc-file-validator.js +75 -0
- package/lib/process-manager-sdk.js +49 -0
- package/lib/replay-window.js +53 -0
- package/lib/typing-indicator.js +6 -1
- package/package.json +1 -1
- package/polygram.js +32 -10
- package/skills/polygram-send/SKILL.md +154 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "polygram",
|
|
12
|
-
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Bundles /polygram:status|logs|pair-code|approvals admin commands and
|
|
12
|
+
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. 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.",
|
|
13
13
|
"category": "integration",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"homepage": "https://github.com/shumkov/polygram"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.8.0-rc.
|
|
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 and
|
|
4
|
+
"version": "0.8.0-rc.58",
|
|
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",
|
|
8
8
|
"openclaw",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rc.58: validate the `document`/`photo`/etc file param on IPC sends
|
|
3
|
+
* before relaying to Telegram, so agents get a clear error instead
|
|
4
|
+
* of Telegram's cryptic "Wrong port number specified" rejection.
|
|
5
|
+
*
|
|
6
|
+
* Discovery: 2026-05-05 — agent generated an Artisan invoice .docx,
|
|
7
|
+
* tried to deliver via polygram-ipc with `document: 'http://...'`
|
|
8
|
+
* pointing at a localhost HTTP server it spun up. Telegram rejected
|
|
9
|
+
* because (1) it can't reach the agent's Mac, (2) port format was
|
|
10
|
+
* malformed. The rejection error read "Wrong port number specified"
|
|
11
|
+
* which gave neither agent nor operator a useful clue.
|
|
12
|
+
*
|
|
13
|
+
* What's accepted:
|
|
14
|
+
* - { source: '/abs/path' } envelope — preferred for local files
|
|
15
|
+
* - public HTTPS URL — Telegram fetches it
|
|
16
|
+
* - Telegram file_id — short alphanumeric string, no scheme/slashes
|
|
17
|
+
*
|
|
18
|
+
* What's rejected at the IPC layer (clear error returned to caller):
|
|
19
|
+
* - localhost / 127.0.0.1 URLs (Telegram can't reach)
|
|
20
|
+
* - http:// URLs (Telegram requires https)
|
|
21
|
+
* - bare absolute paths (must wrap in { source: ... })
|
|
22
|
+
* - non-https schemes
|
|
23
|
+
*
|
|
24
|
+
* What's PASSED THROUGH (caller's responsibility):
|
|
25
|
+
* - { source: '/abs/path' } — grammy handles multipart upload
|
|
26
|
+
* - https URL string — Telegram validates reachability itself
|
|
27
|
+
* - file_id-shaped string — Telegram resolves
|
|
28
|
+
* - Buffer / Stream / other grammy InputFile shapes
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const FILE_PARAM_BY_METHOD = {
|
|
34
|
+
sendDocument: 'document',
|
|
35
|
+
sendPhoto: 'photo',
|
|
36
|
+
sendAudio: 'audio',
|
|
37
|
+
sendAnimation: 'animation',
|
|
38
|
+
sendVideo: 'video',
|
|
39
|
+
sendVoice: 'voice',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} method - IPC method name (e.g. 'sendDocument')
|
|
44
|
+
* @param {object} params - the params payload
|
|
45
|
+
* @returns {string|null} human-readable error if the file param is
|
|
46
|
+
* malformed, null if the params look fine (or the method has no
|
|
47
|
+
* file param to check).
|
|
48
|
+
*/
|
|
49
|
+
function validateIpcFileParam(method, params = {}) {
|
|
50
|
+
const fileParam = FILE_PARAM_BY_METHOD[method];
|
|
51
|
+
if (!fileParam) return null;
|
|
52
|
+
const val = params[fileParam];
|
|
53
|
+
if (typeof val !== 'string') return null; // envelope/Buffer/etc — pass through
|
|
54
|
+
if (val.length === 0) return `polygram IPC: ${fileParam} is empty`;
|
|
55
|
+
|
|
56
|
+
const looksUrl = /^(https?|ftp):\/\//i.test(val);
|
|
57
|
+
const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(\b|[:/?#])/i.test(val);
|
|
58
|
+
const isAbsPath = val.startsWith('/');
|
|
59
|
+
|
|
60
|
+
if (isLocalhost) {
|
|
61
|
+
return `polygram IPC: localhost URLs unreachable from Telegram (got: ${val.slice(0, 80)}). Use { source: '/abs/path' } for local files, or a publicly reachable HTTPS URL.`;
|
|
62
|
+
}
|
|
63
|
+
if (looksUrl && !/^https:\/\//i.test(val)) {
|
|
64
|
+
return `polygram IPC: only HTTPS URLs supported for ${fileParam} (got: ${val.slice(0, 80)}). Use https://, { source: '/abs/path' } for local files, or a Telegram file_id.`;
|
|
65
|
+
}
|
|
66
|
+
if (isAbsPath && !looksUrl) {
|
|
67
|
+
return `polygram IPC: bare file path not accepted (got: ${val.slice(0, 80)}). Wrap it: { ${fileParam}: { source: '${val}' } } so grammy uploads as multipart.`;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
validateIpcFileParam,
|
|
74
|
+
FILE_PARAM_BY_METHOD,
|
|
75
|
+
};
|
|
@@ -648,11 +648,58 @@ class ProcessManagerSdk {
|
|
|
648
648
|
|
|
649
649
|
let idleTimer = null;
|
|
650
650
|
let maxTimer = null;
|
|
651
|
+
let visibilityTimer = null;
|
|
651
652
|
let activated = false;
|
|
652
653
|
|
|
654
|
+
// rc.58: visibility heartbeat for long silent tool runs.
|
|
655
|
+
//
|
|
656
|
+
// The SDK can stay silent for minutes when the agent is running
|
|
657
|
+
// a single long Bash/Playwright tool (gcloud auth + Chrome
|
|
658
|
+
// OAuth login dance, multi-step Playwright session, etc.).
|
|
659
|
+
// During the silence:
|
|
660
|
+
// - polygram receives no onChunk, no onToolUse, no onResult
|
|
661
|
+
// - reactor's stall (30s) and freeze (180s) timers fire IF
|
|
662
|
+
// the reactor was in a STALL_PROMOTABLE state at last
|
|
663
|
+
// heartbeat — but if it was just stopped or in AUTOSTEERED
|
|
664
|
+
// terminal state, no visible signal fires
|
|
665
|
+
// - user sees "stuck" with no emoji/typing change for minutes
|
|
666
|
+
//
|
|
667
|
+
// This timer pings the head pending's reactor every 30s while
|
|
668
|
+
// the pending is in flight. It does NOT change the reactor's
|
|
669
|
+
// visible state (heartbeat is idempotent — re-arms timers
|
|
670
|
+
// without flushing) — but it ensures the stall/freeze cycle
|
|
671
|
+
// keeps producing visible promotions even if SDK silence
|
|
672
|
+
// would otherwise let the reactor go fully quiet.
|
|
673
|
+
//
|
|
674
|
+
// Discovery: 2026-05-05 09:58:38 → 10:10:36 in Shumabit@UMI —
|
|
675
|
+
// 12-min silent gap during a Playwright/Chrome OAuth dance,
|
|
676
|
+
// ZERO reactor-state events logged for that chat in that
|
|
677
|
+
// window. User reported "no typing, no reactions". Adding
|
|
678
|
+
// this heartbeat fixes the silent-window class of UX gap
|
|
679
|
+
// without changing agent behavior.
|
|
680
|
+
const VISIBILITY_HEARTBEAT_MS = 30 * 1000;
|
|
681
|
+
const armVisibilityTimer = () => {
|
|
682
|
+
if (visibilityTimer) clearInterval(visibilityTimer);
|
|
683
|
+
visibilityTimer = setInterval(() => {
|
|
684
|
+
if (!entry.pendingQueue.includes(pending)) {
|
|
685
|
+
// Pending no longer in queue → resolved/rejected/dropped.
|
|
686
|
+
// Defensive: clear ourselves even though clearTimers()
|
|
687
|
+
// SHOULD have been called.
|
|
688
|
+
if (visibilityTimer) { clearInterval(visibilityTimer); visibilityTimer = null; }
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const r = pending.context?.reactor;
|
|
692
|
+
if (r && typeof r.heartbeat === 'function') {
|
|
693
|
+
try { r.heartbeat(); } catch { /* defensive */ }
|
|
694
|
+
}
|
|
695
|
+
}, VISIBILITY_HEARTBEAT_MS);
|
|
696
|
+
visibilityTimer.unref?.();
|
|
697
|
+
};
|
|
698
|
+
|
|
653
699
|
const clearTimers = () => {
|
|
654
700
|
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
655
701
|
if (maxTimer) { clearTimeout(maxTimer); maxTimer = null; }
|
|
702
|
+
if (visibilityTimer) { clearInterval(visibilityTimer); visibilityTimer = null; }
|
|
656
703
|
};
|
|
657
704
|
|
|
658
705
|
const pending = {
|
|
@@ -728,6 +775,8 @@ class ProcessManagerSdk {
|
|
|
728
775
|
maxTurnMs,
|
|
729
776
|
);
|
|
730
777
|
pending.maxTimer = maxTimer;
|
|
778
|
+
// rc.58: arm the visibility heartbeat at activation.
|
|
779
|
+
armVisibilityTimer();
|
|
731
780
|
try { context?.onActivate?.(); }
|
|
732
781
|
catch (err) { this.logger.error?.(`[${entry.label}] onActivate: ${err.message}`); }
|
|
733
782
|
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rc.57: pure resolver for the boot-replay window in milliseconds.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of polygram.js's main() so the derivation rule can be
|
|
5
|
+
* unit-tested without spinning up the daemon.
|
|
6
|
+
*
|
|
7
|
+
* Precedence:
|
|
8
|
+
* 1. config.bot.replayWindowMs — explicit operator override (any
|
|
9
|
+
* positive integer in ms).
|
|
10
|
+
* 2. Auto-derive from max(maxTurn) × 1.2 across all configured chats
|
|
11
|
+
* (and defaults.maxTurn). Reasoning: if a chat allows turns up to
|
|
12
|
+
* maxTurn seconds, an interrupted turn could be that old when
|
|
13
|
+
* polygram restarts; replay window should outlast it. ×1.2 adds
|
|
14
|
+
* buffer.
|
|
15
|
+
* 3. If no maxTurn is configured anywhere, return undefined (db.js
|
|
16
|
+
* uses its 3-min default).
|
|
17
|
+
*
|
|
18
|
+
* Floor at 3 min (legacy default — never tighter than what we shipped
|
|
19
|
+
* before). Cap at 2h (sanity bound — replaying anything older is
|
|
20
|
+
* almost certainly stale work the user already moved on from).
|
|
21
|
+
*
|
|
22
|
+
* Discovery: msg 151 in Shumabit@UMI thread :24 (chat -1003369922517)
|
|
23
|
+
* was sent 2026-05-05 01:55:14, polygram restarted for rc.56 at
|
|
24
|
+
* 02:17 (22 min later). Pre-rc.57 the 3-min default discarded msg 151
|
|
25
|
+
* as too old; the agent's 7-hour Xero-template-build task was
|
|
26
|
+
* abandoned silently. Shumabit@UMI has maxTurn=3600 (60 min); 1.2×
|
|
27
|
+
* = 72 min replay window now keeps long turns alive across deploys.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const FLOOR_MS = 3 * 60 * 1000; // 3 min
|
|
33
|
+
const CAP_MS = 2 * 60 * 60 * 1000; // 2 h
|
|
34
|
+
const BUFFER = 1.2; // ×
|
|
35
|
+
|
|
36
|
+
function resolveReplayWindowMs(config) {
|
|
37
|
+
const explicit = Number(config?.bot?.replayWindowMs);
|
|
38
|
+
if (Number.isInteger(explicit) && explicit > 0) return explicit;
|
|
39
|
+
const chatMaxes = Object.values(config?.chats || {})
|
|
40
|
+
.map((c) => Number(c?.maxTurn) || 0);
|
|
41
|
+
const defaultMax = Number(config?.defaults?.maxTurn) || 0;
|
|
42
|
+
const maxTurnSec = Math.max(0, ...chatMaxes, defaultMax);
|
|
43
|
+
if (maxTurnSec === 0) return undefined;
|
|
44
|
+
const derivedMs = Math.round(maxTurnSec * BUFFER * 1000);
|
|
45
|
+
return Math.max(FLOOR_MS, Math.min(CAP_MS, derivedMs));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
resolveReplayWindowMs,
|
|
50
|
+
FLOOR_MS,
|
|
51
|
+
CAP_MS,
|
|
52
|
+
BUFFER,
|
|
53
|
+
};
|
package/lib/typing-indicator.js
CHANGED
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
* handful of 401s, not worth persisting.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// rc.58: tightened from 4000 → 3000ms. Telegram's typing indicator
|
|
23
|
+
// expires after ~5s; a 4s interval left a 1s margin where any tick
|
|
24
|
+
// delay (event loop pressure, network blip, sandbox throttling) could
|
|
25
|
+
// produce a brief visible flicker. 3s gives 2s margin and reduces
|
|
26
|
+
// flicker risk on long silent SDK turns.
|
|
27
|
+
const DEFAULT_INTERVAL_MS = 3000;
|
|
23
28
|
const DEFAULT_MAX_CONSECUTIVE_401 = 10;
|
|
24
29
|
const DEFAULT_MAX_BACKOFF_MS = 300_000; // 5 min — matches OpenClaw
|
|
25
30
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.8.0-rc.
|
|
3
|
+
"version": "0.8.0-rc.58",
|
|
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": {
|
package/polygram.js
CHANGED
|
@@ -66,6 +66,8 @@ const { createReactionManager, classifyToolName } = require('./lib/status-reacti
|
|
|
66
66
|
const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
|
|
67
67
|
const { classify: classifyError, isTransientHttpError } = require('./lib/error-classify');
|
|
68
68
|
const { createAutoResumeTracker, isAutoResumable } = require('./lib/auto-resume');
|
|
69
|
+
const { resolveReplayWindowMs } = require('./lib/replay-window');
|
|
70
|
+
const { validateIpcFileParam } = require('./lib/ipc-file-validator');
|
|
69
71
|
const {
|
|
70
72
|
createStore: createApprovalsStore,
|
|
71
73
|
matchesAnyPattern: matchesApprovalPattern,
|
|
@@ -1404,6 +1406,13 @@ async function handleSendOverIpc(req) {
|
|
|
1404
1406
|
throw new Error(`chat not owned by ${BOT_NAME}: ${chatId}`);
|
|
1405
1407
|
}
|
|
1406
1408
|
|
|
1409
|
+
// rc.58: catch the most common file-upload mistakes before Telegram
|
|
1410
|
+
// rejects with a confusing error. validateIpcFileParam returns a
|
|
1411
|
+
// human-readable error string when the file param is malformed,
|
|
1412
|
+
// null otherwise.
|
|
1413
|
+
const fileParamErr = validateIpcFileParam(method, params);
|
|
1414
|
+
if (fileParamErr) throw new Error(fileParamErr);
|
|
1415
|
+
|
|
1407
1416
|
const sendRes = await tg(bot, method, params, {
|
|
1408
1417
|
source: source || 'ipc',
|
|
1409
1418
|
botName: BOT_NAME,
|
|
@@ -3654,7 +3663,13 @@ async function pollBot(bot) {
|
|
|
3654
3663
|
const chatId = m.chat.id.toString();
|
|
3655
3664
|
const chatConfig = config.chats[chatId];
|
|
3656
3665
|
const threadId = m.message_thread_id?.toString();
|
|
3657
|
-
|
|
3666
|
+
// rc.57: use getTopicName() helper which handles BOTH legacy string
|
|
3667
|
+
// form and rc.48 object form. Pre-rc.57 the direct lookup
|
|
3668
|
+
// `chatConfig.topics[threadId]` template-literal'd into "[object Object]"
|
|
3669
|
+
// because rc.48 topics are objects like {name:"Music",agent:"...",...}.
|
|
3670
|
+
const topicName = threadId
|
|
3671
|
+
? (chatConfig ? getTopicName(chatConfig, threadId) : threadId)
|
|
3672
|
+
: null;
|
|
3658
3673
|
const chatLabel = chatConfig?.name || chatId;
|
|
3659
3674
|
const label = topicName ? `${chatLabel}/${topicName}` : chatLabel;
|
|
3660
3675
|
console.log(`[${BOT_NAME}] ← ${label}: ${(m.text || m.caption || '(media)').slice(0, 60)}`);
|
|
@@ -4166,18 +4181,25 @@ async function main() {
|
|
|
4166
4181
|
// Boot replay: re-dispatch any inbound turns that were interrupted by
|
|
4167
4182
|
// the previous polygram's shutdown or crash. These are rows marked
|
|
4168
4183
|
// 'dispatched', 'processing', or 'replay-pending' (set by the SIGTERM
|
|
4169
|
-
// handler) — all within the last `replayWindowMs`
|
|
4170
|
-
//
|
|
4171
|
-
//
|
|
4172
|
-
//
|
|
4173
|
-
//
|
|
4184
|
+
// handler) — all within the last `replayWindowMs` so we don't
|
|
4185
|
+
// resurrect ancient work. Dedupe against already-sent outbound
|
|
4186
|
+
// replies in case the previous instance DID answer before dying.
|
|
4187
|
+
//
|
|
4188
|
+
// rc.57: auto-derive replayWindowMs from max(maxTurn) * 1.2 when not
|
|
4189
|
+
// explicitly set. Pre-rc.57 the default was 3 min — but chats with
|
|
4190
|
+
// long agent tasks (Shumabit@UMI maxTurn=3600 = 60 min) would have
|
|
4191
|
+
// their interrupted turns silently dropped because the turn was
|
|
4192
|
+
// typically older than 3 min when polygram restarted. Discovery
|
|
4193
|
+
// context: msg 151 in Shumabit@UMI thread :24 on 2026-05-05 was
|
|
4194
|
+
// sent at 01:55:14, polygram restarted for rc.56 at 02:17 (22 min
|
|
4195
|
+
// later). msg 151 was 'replay-pending' but boot-replay's 3-min
|
|
4196
|
+
// window discarded it; the agent's 7-hour Xero task was abandoned.
|
|
4197
|
+
// Auto-derive: 1.2 × max(chatConfig.maxTurn) across all chats,
|
|
4198
|
+
// floored at 3 min (legacy default), capped at 2 hours (sanity).
|
|
4174
4199
|
try {
|
|
4175
4200
|
const chatIds = Object.keys(config.chats);
|
|
4176
4201
|
if (chatIds.length > 0) {
|
|
4177
|
-
const replayWindowMs = (
|
|
4178
|
-
const v = Number(config.bot?.replayWindowMs);
|
|
4179
|
-
return (Number.isInteger(v) && v > 0) ? v : undefined; // undefined → use db.js default
|
|
4180
|
-
})();
|
|
4202
|
+
const replayWindowMs = resolveReplayWindowMs(config);
|
|
4181
4203
|
const candidates = db.getReplayCandidates({ chatIds, ...(replayWindowMs && { olderThanMs: replayWindowMs }) });
|
|
4182
4204
|
let replayed = 0;
|
|
4183
4205
|
let skipped = 0;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: polygram-send
|
|
3
|
+
description: Send messages, photos, documents, or stickers from a script (or from agent Bash) into the same Telegram chat the bot is in. Use when an agent or cron job needs to post a file, photo, or out-of-band text to a polygram chat — typically Xero invoices (PDF/.docx), generated reports, screenshots, or status pings tied to long-running work. Do NOT use for normal turn replies (those go through the agent's text reply, polygram delivers automatically). Do NOT use Telegram MCP — polygram-send is the supported path.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# polygram-send
|
|
7
|
+
|
|
8
|
+
Polygram exposes a per-bot Unix socket at `/tmp/polygram-<bot>.sock`. Authorized callers (same UID, with the per-bot secret) can send Telegram API methods through it. Polygram routes them to the live bot connection, logs them in the `messages` table, and applies markdown / chunking / dedup.
|
|
9
|
+
|
|
10
|
+
This is the **supported path** for any agent or script that needs to deliver a file or out-of-band message into a polygram-managed chat. Telegram MCP is **not** wired up; agents that try to call Telegram directly will hit auth or routing issues.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- Agent generated a file (PDF, .docx, image, etc.) and the user asked for it in chat
|
|
15
|
+
- Cron job needs to post a status update to the relevant chat
|
|
16
|
+
- Long-running work needs an out-of-turn nudge (e.g. "build finished, here's the artifact")
|
|
17
|
+
|
|
18
|
+
## When NOT to use
|
|
19
|
+
|
|
20
|
+
- Your normal text reply to the user — return text from the turn, polygram delivers it through the streamed reply path. Don't double-send.
|
|
21
|
+
- Anything that needs the message attributed to "user" — IPC always sends as the bot.
|
|
22
|
+
- The Telegram MCP — explicitly NOT supported. Polygram blocks outbound message routing through anything other than its own bridge.
|
|
23
|
+
|
|
24
|
+
## Allowed methods
|
|
25
|
+
|
|
26
|
+
Polygram's IPC accepts (and only these):
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
sendMessage
|
|
30
|
+
sendPhoto
|
|
31
|
+
sendDocument
|
|
32
|
+
sendSticker
|
|
33
|
+
sendChatAction
|
|
34
|
+
editMessageText
|
|
35
|
+
setMessageReaction
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Anything else → `method not allowed` rejection.
|
|
39
|
+
|
|
40
|
+
## Usage from Bash (one-liner pattern)
|
|
41
|
+
|
|
42
|
+
The lightest path is a `node -e` invocation that requires `polygram/lib/ipc-client`:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
node -e '
|
|
46
|
+
const { tell } = require("/Users/shumabit/.npm-global/lib/node_modules/polygram/lib/ipc-client");
|
|
47
|
+
(async () => {
|
|
48
|
+
const r = await tell("shumabit", "sendDocument", {
|
|
49
|
+
chat_id: "-1003369922517",
|
|
50
|
+
message_thread_id: 24,
|
|
51
|
+
document: { source: "/absolute/path/to/invoice.docx" },
|
|
52
|
+
caption: "Artisan April invoice — ฿3,562.00",
|
|
53
|
+
}, { source: "agent:xero-invoice" });
|
|
54
|
+
console.log(JSON.stringify(r));
|
|
55
|
+
})().catch(e => { console.error(e.message); process.exit(1); });
|
|
56
|
+
'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Replace `shumabit` with `umi-assistant` or `shumorobot` for the other daemons. The `polygram` package path is wherever it was installed globally (run `npm root -g` to confirm).
|
|
60
|
+
|
|
61
|
+
## Critical: how to pass `document` (and `photo`)
|
|
62
|
+
|
|
63
|
+
For **sendDocument** and **sendPhoto**, the `document` (or `photo`) field has THREE valid forms. Pass the wrong one and Telegram rejects.
|
|
64
|
+
|
|
65
|
+
### ✅ Local file path via `{ source: "/abs/path" }` — preferred
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
document: { source: "/Users/shumabit/work/invoice.docx" }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This uses grammy's `InputFile` envelope. Polygram → grammy → multipart upload → Telegram. Works for any local file ≤50 MB. **Use this for any file you just generated.**
|
|
72
|
+
|
|
73
|
+
### ✅ Public HTTPS URL — for files already hosted publicly
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
document: "https://cdn.example.com/invoice.pdf"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Telegram fetches the URL directly. Must be **publicly reachable from Telegram's servers** (i.e. a public CDN, S3 bucket, or hosted endpoint). NOT localhost. NOT IP addresses Telegram can't reach. NOT HTTP — must be HTTPS.
|
|
80
|
+
|
|
81
|
+
### ✅ Telegram `file_id` — for files Telegram has already seen
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
document: "BAADAQADwAADBREAAVoQOnEZmRRJpmcE"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Re-use a file that polygram (or any bot) previously uploaded. Cheapest by far — no upload cost.
|
|
88
|
+
|
|
89
|
+
### ❌ Common failures
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
// WRONG: localhost URL — Telegram can't reach your Mac
|
|
93
|
+
document: "http://localhost:8080/file.pdf"
|
|
94
|
+
|
|
95
|
+
// WRONG: HTTP (not HTTPS) URL — Telegram requires HTTPS for URL form
|
|
96
|
+
document: "http://cdn.example.com/file.pdf"
|
|
97
|
+
|
|
98
|
+
// WRONG: malformed port (double colon, alpha chars)
|
|
99
|
+
document: "http://host::8080/file.pdf"
|
|
100
|
+
|
|
101
|
+
// WRONG: bare path string (Telegram will treat as a URL and reject)
|
|
102
|
+
document: "/Users/shumabit/work/invoice.docx"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
If you generated a file locally, ALWAYS use the `{ source: "/abs/path" }` form — never wrap it in a URL.
|
|
106
|
+
|
|
107
|
+
## Picking the right `chat_id` and `message_thread_id`
|
|
108
|
+
|
|
109
|
+
The chat the bot lives in is in `~/polygram/config.json` (shumabit-side) or `~/.polygram/config.json` (ivanshumkov-side). Each chat has:
|
|
110
|
+
|
|
111
|
+
- `chat_id` — the Telegram numeric ID (negative for groups, positive for DMs)
|
|
112
|
+
- `topics` — optional dict of `thread_id` → `{ name, ... }` (rc.48 form)
|
|
113
|
+
|
|
114
|
+
For threaded chats (where `chatConfig.isolateTopics === true` or topics are listed), pass `message_thread_id: <number>` to land in the right topic. Without it, the message goes to the chat's General topic.
|
|
115
|
+
|
|
116
|
+
**Don't hardcode thread IDs.** Read the current conversation's threadId from your invocation context, OR query the most recent inbound message in the relevant topic before sending. Hardcoding leaks state across deploys (the agent's view of "what topic we're in" can stale-bind to an old thread).
|
|
117
|
+
|
|
118
|
+
## Source label
|
|
119
|
+
|
|
120
|
+
Polygram tags every IPC send with a `source` string for forensics. Default is `cron:<scriptname>` if you don't pass one. **Always pass an explicit `source` from agent Bash** so it's visible in `messages.source` and forensic queries can attribute reliably:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
{ source: "agent:xero-invoice" } // Xero workflow
|
|
124
|
+
{ source: "agent:rekordbox-import-summary" } // music-curation outputs
|
|
125
|
+
{ source: "agent:status-ping" } // generic out-of-turn update
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Without an explicit source, the call shows up as `cron:unknown` and forensics can't tell whether it was a real cron job or an agent — which led to actual misdiagnosis on 2026-05-05 (rc.58 incident).
|
|
129
|
+
|
|
130
|
+
## Captions
|
|
131
|
+
|
|
132
|
+
`caption` accepts up to 1024 chars (vs 4096 for `sendMessage`). Polygram's lib/telegram-format.js handles markdown escape; agents passing markdown captions don't need to pre-escape.
|
|
133
|
+
|
|
134
|
+
For long descriptions, send the file with a short caption + follow up with a separate `sendMessage` for the full text. Don't try to cram a long description into the caption.
|
|
135
|
+
|
|
136
|
+
## Auth
|
|
137
|
+
|
|
138
|
+
The IPC socket validates a per-bot secret (`/tmp/polygram-<bot>.secret`, mode 0600, same UID readable). `tell()` reads it automatically. Cross-user / cross-UID calls fail at socket-permission level. Cross-bot calls fail at chat ownership check (`chat_id must belong to this bot`).
|
|
139
|
+
|
|
140
|
+
## Don't
|
|
141
|
+
|
|
142
|
+
- Don't call this for normal text replies. Just emit text from the turn — polygram delivers it.
|
|
143
|
+
- Don't use Telegram MCP.
|
|
144
|
+
- Don't pass localhost URLs to `document`/`photo`.
|
|
145
|
+
- Don't hardcode `message_thread_id` from previous sessions.
|
|
146
|
+
- Don't omit `source` from agent-triggered sends.
|
|
147
|
+
- Don't dedupe yourself — polygram's outbound dedupe + retry is already wired.
|
|
148
|
+
|
|
149
|
+
## Related
|
|
150
|
+
|
|
151
|
+
- IPC server: `lib/ipc-server.js` — handler registration, secret validation
|
|
152
|
+
- IPC client: `lib/ipc-client.js` — `tell()`, `call()`
|
|
153
|
+
- IPC method allowlist: `polygram.js` `IPC_SEND_ALLOWED_METHODS`
|
|
154
|
+
- Smoke test: `scripts/ipc-smoke.js`
|