polygram 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivan Shumkov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,287 @@
1
+ # polygram
2
+
3
+ A Telegram daemon for Claude Code that preserves the per-chat session model
4
+ from OpenClaw. Intended primarily as a migration path for users moving
5
+ their Telegram-based ops from OpenClaw to Claude Code.
6
+
7
+ ## Background
8
+
9
+ OpenClaw ran a Telegram agent with one conversation context per chat.
10
+ Each chat had its own persistent memory; topics in a forum chat could
11
+ optionally carry their own sub-context. The agent, cron scripts, and
12
+ human operators all wrote into a shared transcript.
13
+
14
+ OpenClaw no longer supports Claude. Migrating to Claude Code loses that
15
+ model unless it's rebuilt. The official `telegram@claude-plugins-official`
16
+ plugin is single-session — one Claude Code process, one bot, one shared
17
+ context across all chats. Third-party Claude Code Telegram bots usually
18
+ share one session across all users of a given bot instance.
19
+
20
+ `polygram` is the shape that keeps OpenClaw's per-chat-session
21
+ ergonomics while running on top of `claude` CLI.
22
+
23
+ ## What it is
24
+
25
+ - **One Node process per bot.** Required `--bot <name>` flag. N bots = N
26
+ processes. No "one process hosts many bots" mode — crash isolation is
27
+ the point.
28
+ - **Per-chat Claude sessions.** Each chat has its own `claude_session_id`,
29
+ resumed via `claude --resume`.
30
+ - **Per-topic sessions — opt-in** (`isolateTopics: true` in chat config).
31
+ Default is shared context across topics, since topics are usually
32
+ organisational. OpenClaw migrators who used per-topic separation can
33
+ keep it with one flag.
34
+ - **SQLite transcripts** (WAL, FTS5, numbered migrations, `user_version`).
35
+ - **Write-before-send atomicity.** Outbound messages hit the DB as
36
+ `pending` before the Telegram call, flip to `sent` or `failed` after.
37
+ Boot sweep resolves stale `pending` rows from the last crash.
38
+ - **Unix-socket IPC per bot.** Cron jobs and Claude Code approval hooks
39
+ talk to the bot process over `/tmp/polygram-<bot>.sock`. The bot
40
+ is the only writer to its own DB.
41
+ - **Inline-keyboard approvals.** Destructive tool calls gate on operator
42
+ click via Claude Code's `PreToolUse` hook; 5-minute auto-deny.
43
+ - **Voice transcription.** OpenAI Whisper API or local `whisper.cpp`,
44
+ selectable per bot. Transcriptions land in `messages.text` so FTS
45
+ finds them.
46
+ - **Content-addressed attachment storage** via Telegram's `file_unique_id`.
47
+ Same photo forwarded twice = one file on disk.
48
+ - **Prompt-injection hardening.** User text wrapped in `<untrusted-input>`
49
+ with xml-escape; attributes use `&quot;`. A partner typing
50
+ `</channel><system>...` sees it as literal text in the prompt.
51
+ - **Pairing codes** for guest onboarding without bridge restart
52
+ (`/pair-code`, `/pair <CODE>`, `/pairings`, `/unpair`).
53
+ - **Step-level streaming replies** (optional per bot). Telegram message
54
+ edits on each assistant step as Claude works through tool calls and
55
+ reasoning.
56
+
57
+ ## Relation to existing projects
58
+
59
+ | | Session unit | Bots per install | Persistence |
60
+ |---|---|---|---|
61
+ | [`telegram@claude-plugins-official`](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram) | one (bound to an open Claude Code session) | one | session memory only |
62
+ | [`ClaudeBot`](https://github.com/Jeffrey0117/ClaudeBot) | worktree path (≈ one per bot) | many (git worktrees) | `.sessions.json` |
63
+ | [`claudegram`](https://github.com/NachoSEO/claudegram) | chat (+ forum topic) | one | JSON files |
64
+ | **polygram** | chat (+ optional forum topic) | many (per-process) | SQLite WAL + FTS5 |
65
+
66
+ Practical differences that matter for migration:
67
+
68
+ - The official plugin dies with `/exit`, so it can't carry scheduled jobs
69
+ or replace a long-running ops bot.
70
+ - `ClaudeBot` puts many chats on one session per bot. For OpenClaw users
71
+ this feels wrong — a customer group and an ops group would share
72
+ memory unless each goes in its own worktree.
73
+ - `claudegram` gets the session model right but serves one bot per
74
+ install. Running five bots means five copies of the infra.
75
+ - `polygram` lands on the combination: multi-bot (one process per
76
+ bot) and per-chat/per-topic sessions. Scaling from one bot to many
77
+ doesn't change the mental model inherited from OpenClaw.
78
+
79
+ ## Install
80
+
81
+ Requires Node 20+.
82
+
83
+ ```bash
84
+ git clone https://github.com/shumkov/polygram.git
85
+ cd polygram
86
+ npm install
87
+ cp config.example.json config.json
88
+ # edit config.json: tokens from @BotFather, chat IDs, cwds
89
+ ```
90
+
91
+ ## Run
92
+
93
+ ```bash
94
+ node bridge.js --bot admin-bot # one bot, one process
95
+ node bridge.js --bot partner-bot # another bot, another process
96
+ ```
97
+
98
+ `--bot` is required. Each process creates `<bot>.db` next to `bridge.js`
99
+ on first run (migrations apply automatically) and opens a Unix socket at
100
+ `/tmp/polygram-<bot>.sock`.
101
+
102
+ For production, LaunchAgent plists are in `ops/`. See `ops/README.md`.
103
+
104
+ ## Configuration
105
+
106
+ Minimal:
107
+
108
+ ```json
109
+ {
110
+ "bots": {
111
+ "my-bot": { "token": "..." }
112
+ },
113
+ "chats": {
114
+ "123456789": {
115
+ "name": "My DM",
116
+ "bot": "my-bot",
117
+ "agent": "my-agent",
118
+ "model": "sonnet",
119
+ "effort": "low",
120
+ "cwd": "/Users/me/my-agent"
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ Per-chat flags:
127
+
128
+ - `isolateTopics: true` — each forum topic gets its own Claude session.
129
+ Default is shared.
130
+ - `requireMention: true` — group chats only respond to `@botname` or
131
+ replies to bot messages. Paired users bypass this.
132
+ - `topics: { "<thread_id>": "<name>" }` — human-readable topic labels
133
+ included in the prompt.
134
+
135
+ Per-bot flags:
136
+
137
+ - `allowConfigCommands: true` — enables `/model`, `/effort`, `/pair-code`,
138
+ `/pairings`, `/unpair`.
139
+ - `streamReplies: true` — live-edit the Telegram message as Claude works.
140
+ - `voice: { enabled, provider: "openai"|"local", ... }` — Whisper
141
+ transcription settings.
142
+ - `approvals: { adminChatId, timeoutMs, gatedTools, ... }` — which tool
143
+ calls require an inline-keyboard approval and where to post the card.
144
+
145
+ See `config.example.json` for the full schema.
146
+
147
+ ## Migrating from OpenClaw
148
+
149
+ See the design doc (`docs/polygram-design.md`) for the full trust model
150
+ and architectural choices. In practice:
151
+
152
+ 1. Install `polygram`, point chat `cwd` at your migrated agent
153
+ project.
154
+ 2. Copy OpenClaw's per-partner memory directories to their new chat
155
+ directories if you used them.
156
+ 3. For chats where each OpenClaw topic had its own context, set
157
+ `isolateTopics: true` in chat config.
158
+ 4. For cron/scheduled scripts, replace direct Telegram API calls with
159
+ `tell(bot, method, params, {source})` from `lib/ipc-client`. The
160
+ bot process writes to the transcript; the script just asks it to send.
161
+ 5. Use `scripts/split-db.js` if you're consolidating multiple OpenClaw
162
+ databases — otherwise per-bot SQLite files start fresh.
163
+
164
+ ## Cron → bot (IPC)
165
+
166
+ ```js
167
+ const { tell } = require('polygram/lib/ipc-client');
168
+
169
+ await tell('admin-bot', 'sendMessage', {
170
+ chat_id: '123456789',
171
+ text: 'Daily inventory report ready.',
172
+ }, { source: 'cron:inventory-report' });
173
+ ```
174
+
175
+ Allowed methods: `sendMessage`, `sendPhoto`, `sendDocument`, `sendSticker`,
176
+ `sendChatAction`, `editMessageText`, `setMessageReaction`. The socket
177
+ server rejects others. Cross-bot sends are rejected (chat must belong
178
+ to the bot on the other end of the socket).
179
+
180
+ If the bot process is down, the call throws. This is intentional — cron
181
+ failures should surface.
182
+
183
+ ## The `telegram-history` skill
184
+
185
+ A Claude skill that queries the transcript:
186
+
187
+ ```bash
188
+ node skills/telegram-history/scripts/query.js recent -1000000000001 --since 24h
189
+ node skills/telegram-history/scripts/query.js search "invoice" --user Maria
190
+ node skills/telegram-history/scripts/query.js around --chat -100... --msg-id 12345 --before 10
191
+ ```
192
+
193
+ Bot scope is derived from `process.cwd()` — the skill refuses to run if
194
+ the cwd doesn't match a chat in config, unless `BRIDGE_ADMIN=1` is set.
195
+ With per-bot DBs the skill opens only the current bot's file; in admin
196
+ mode it unions across all `<bot>.db` files.
197
+
198
+ ## Approvals
199
+
200
+ Config:
201
+
202
+ ```json
203
+ "approvals": {
204
+ "adminChatId": "123456789",
205
+ "timeoutMs": 300000,
206
+ "gatedTools": ["Bash(rm *)", "mcp__*__invoice_create"]
207
+ }
208
+ ```
209
+
210
+ Install the hook at the agent level (`settings.json`):
211
+
212
+ ```json
213
+ {
214
+ "hooks": {
215
+ "PreToolUse": [{
216
+ "matcher": "Bash|mcp__*",
217
+ "hooks": [{
218
+ "type": "command",
219
+ "command": "/abs/path/to/polygram/bin/bridge-approval-hook.js"
220
+ }]
221
+ }]
222
+ }
223
+ }
224
+ ```
225
+
226
+ When Claude attempts a matched tool, the hook blocks, the daemon posts
227
+ `[Approve]/[Deny]` buttons to `adminChatId`, and the tool runs (or is
228
+ denied) after the click. Tokens in `callback_data` defeat replay;
229
+ foreign-chat clicks are rejected. Default-deny on IPC error.
230
+
231
+ ## Development
232
+
233
+ ```bash
234
+ npm test # 336 tests, 72 suites, node:test, no external services
235
+ npm start -- --bot my-bot
236
+ npm run split-db -- --config config.json --dry-run
237
+ npm run ipc-smoke -- my-bot
238
+ ```
239
+
240
+ Layout:
241
+
242
+ ```
243
+ bridge.js main daemon
244
+ bin/bridge-approval-hook.js PreToolUse hook
245
+ lib/ core modules (db, prompt, telegram,
246
+ process-manager, sessions, history,
247
+ attachments, inbox, voice, approvals,
248
+ pairings, ipc-{server,client},
249
+ session-key, stream-reply, ...)
250
+ migrations/NNN-*.sql applied at boot, guarded by user_version
251
+ skills/telegram-history/ Claude skill
252
+ ops/ LaunchAgent plists
253
+ scripts/split-db.js one-time shared-DB → per-bot migration
254
+ tests/*.test.js node:test
255
+ ```
256
+
257
+ ## Status and non-goals
258
+
259
+ - Used in production by the author for a retail ops workflow.
260
+ - No horizontal scale-out. One machine, shared filesystem. If you need
261
+ bot A in Bangkok and bot B on AWS, swap SQLite for something networked;
262
+ that's not on the roadmap.
263
+ - Claude Code only. No abstraction over other AIs.
264
+ - macOS LaunchAgent plists included; Linux systemd units are not (easy
265
+ to adapt).
266
+ - No marketplace plugin wrapper yet. See roadmap.
267
+
268
+ ## Roadmap
269
+
270
+ - Pairings phase 2: auto-create DM chat entries for paired users in
271
+ unknown chats.
272
+ - Approvals phase 2: deny-with-reason, per-user quotas.
273
+ - Voice phase 2: `/replay-voice` to re-transcribe with a language hint.
274
+ - `/replay-pending` admin command for crashed-mid-send rows.
275
+ - Marketplace plugin wrapper with slash commands for admin.
276
+
277
+ ## Licence
278
+
279
+ MIT — see [LICENSE](./LICENSE).
280
+
281
+ ## Acknowledgements
282
+
283
+ - [grammy](https://grammy.dev) for the Telegram client.
284
+ - [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) for the
285
+ storage layer.
286
+ - OpenClaw for the per-chat session ergonomics this project aims to
287
+ preserve.
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code PreToolUse hook -> bridge daemon approval round-trip.
4
+ *
5
+ * Installed into an agent's settings.json:
6
+ * { "hooks": { "PreToolUse": [
7
+ * { "matcher": "Bash|WebFetch|mcp__*", "hooks": [
8
+ * { "type": "command",
9
+ * "command": "/Users/YOURNAME/polygram/bin/bridge-approval-hook.js" }
10
+ * ]}
11
+ * ]}}
12
+ *
13
+ * Environment (set by the bridge when spawning Claude):
14
+ * BRIDGE_BOT - bot name owning this session (socket suffix)
15
+ * BRIDGE_CHAT_ID - chat whose message triggered this turn (for the card)
16
+ * BRIDGE_TURN_ID - optional; helps dedupe re-fires on Claude retries
17
+ *
18
+ * Contract (Claude Code):
19
+ * stdin JSON: { session_id, hook_event_name: "PreToolUse",
20
+ * tool_name, tool_input, ... }
21
+ * stdout JSON reply for PreToolUse: either pass-through (exit 0 empty stdout),
22
+ * or a block decision:
23
+ * {"hookSpecificOutput": {"hookEventName":"PreToolUse",
24
+ * "permissionDecision":"allow"|"deny"|"ask",
25
+ * "permissionDecisionReason":"..."}}
26
+ * Exit codes:
27
+ * 0 - allow (empty stdout) or structured decision in stdout
28
+ * 2 - block (deny)
29
+ *
30
+ * Failure policy: on IPC error (bridge down, socket missing, timeout) we
31
+ * deny by default. Better to block a legitimate tool call than to let a
32
+ * destructive one through when the approver is unreachable.
33
+ */
34
+
35
+ const fs = require('fs');
36
+
37
+ (async () => {
38
+ const botName = process.env.BRIDGE_BOT;
39
+ const chatId = process.env.BRIDGE_CHAT_ID;
40
+ const turnId = process.env.BRIDGE_TURN_ID || null;
41
+
42
+ if (!botName || !chatId) {
43
+ deny('bridge-approval-hook: BRIDGE_BOT and BRIDGE_CHAT_ID env vars required');
44
+ return;
45
+ }
46
+
47
+ let req;
48
+ try {
49
+ req = JSON.parse(fs.readFileSync(0, 'utf8'));
50
+ } catch (err) {
51
+ deny(`bad hook input: ${err.message}`);
52
+ return;
53
+ }
54
+ if (req.hook_event_name !== 'PreToolUse') {
55
+ // Not our event; pass through silently.
56
+ process.exit(0);
57
+ }
58
+
59
+ // Resolve relative to this hook's own location rather than a hardcoded
60
+ // absolute path — an absolute-path require is a symlink-swap RCE vector
61
+ // (anyone who can write to that path gets code execution in-bridge).
62
+ const path = require('path');
63
+ const { call, socketPathFor, readSecret } = require(path.join(__dirname, '..', 'lib', 'ipc-client'));
64
+ let res;
65
+ try {
66
+ res = await call({
67
+ path: socketPathFor(botName),
68
+ op: 'approval_request',
69
+ secret: readSecret(botName),
70
+ payload: {
71
+ bot_name: botName,
72
+ chat_id: chatId,
73
+ turn_id: turnId,
74
+ tool_name: req.tool_name,
75
+ tool_input: req.tool_input,
76
+ },
77
+ });
78
+ } catch (err) {
79
+ deny(`bridge unreachable: ${err.message}`);
80
+ return;
81
+ }
82
+
83
+ if (!res || !res.ok) {
84
+ deny(`bridge error: ${res?.error || 'unknown'}`);
85
+ return;
86
+ }
87
+
88
+ // Bridge signals one of: 'not-gated' | 'approved' | 'denied' | 'timeout' | 'auto-approved'
89
+ if (res.decision === 'not-gated' || res.decision === 'approved' || res.decision === 'auto-approved') {
90
+ // Pass through — let the default permission flow decide. An empty
91
+ // stdout + exit 0 means "no opinion" from this hook.
92
+ process.exit(0);
93
+ }
94
+
95
+ const reason = res.reason || `approval ${res.decision}`;
96
+ deny(reason, res.decision);
97
+ })().catch((err) => {
98
+ deny(`hook crashed: ${err.message}`);
99
+ });
100
+
101
+ function deny(reason, decision = 'denied') {
102
+ const out = {
103
+ hookSpecificOutput: {
104
+ hookEventName: 'PreToolUse',
105
+ permissionDecision: 'deny',
106
+ permissionDecisionReason: `[${decision}] ${reason}`,
107
+ },
108
+ };
109
+ try {
110
+ process.stdout.write(JSON.stringify(out));
111
+ } catch {}
112
+ process.exit(2);
113
+ }