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.
@@ -0,0 +1,118 @@
1
+ {
2
+ "_comment": "Copy to config.json and fill in real values. Two bots shown: one admin, one partner-facing with approvals + voice.",
3
+
4
+ "bots": {
5
+ "admin-bot": {
6
+ "token": "REPLACE_WITH_BOT_TOKEN_FROM_BOTFATHER",
7
+ "allowConfigCommands": true,
8
+ "_comment_adminChatId": "Required when allowConfigCommands is true for pairing commands (/pair-code, /pairings, /unpair) to work. These grant cross-chat trust and are gated to the admin chat only.",
9
+ "adminChatId": "123456789",
10
+ "needsToken": false,
11
+ "streamReplies": true,
12
+ "streamMinChars": 30,
13
+ "streamThrottleMs": 500,
14
+ "voice": {
15
+ "enabled": true,
16
+ "provider": "openai",
17
+ "openai": {
18
+ "apiKeyEnv": "OPENAI_API_KEY",
19
+ "model": "whisper-1"
20
+ },
21
+ "language": "auto",
22
+ "maxDurationSec": 600,
23
+ "ackReaction": "👂"
24
+ }
25
+ },
26
+
27
+ "partner-bot": {
28
+ "token": "REPLACE_WITH_PARTNER_BOT_TOKEN",
29
+ "needsToken": false,
30
+ "approvals": {
31
+ "adminChatId": "123456789",
32
+ "timeoutMs": 300000,
33
+ "gatedTools": [
34
+ "Bash(rm *)",
35
+ "Bash(git push *)",
36
+ "mcp__*__invoice_create",
37
+ "mcp__*__order_write"
38
+ ]
39
+ },
40
+ "voice": {
41
+ "enabled": true,
42
+ "provider": "local",
43
+ "local": {
44
+ "binary": "/opt/homebrew/bin/whisper-cpp",
45
+ "model": "/Users/you/models/ggml-base.en.bin"
46
+ }
47
+ }
48
+ }
49
+ },
50
+
51
+ "chats": {
52
+ "123456789": {
53
+ "name": "Admin DM",
54
+ "bot": "admin-bot",
55
+ "agent": "admin",
56
+ "model": "opus",
57
+ "effort": "medium",
58
+ "cwd": "/Users/you/admin-agent",
59
+ "timeout": 600
60
+ },
61
+
62
+ "-1000000000001": {
63
+ "name": "Ops Group",
64
+ "bot": "admin-bot",
65
+ "agent": "admin",
66
+ "model": "sonnet",
67
+ "effort": "low",
68
+ "cwd": "/Users/you/admin-agent",
69
+ "requireMention": true,
70
+ "_comment_isolateTopics": "Default false: all topics share one Claude context (intuitive for organisational topics). Set true for OpenClaw-style per-topic isolation when each topic is a genuinely separate project.",
71
+ "topics": {
72
+ "100": "Orders",
73
+ "200": "Billing",
74
+ "300": "Planning"
75
+ }
76
+ },
77
+
78
+ "-1000000000003": {
79
+ "name": "Projects Group (per-topic isolation)",
80
+ "bot": "admin-bot",
81
+ "agent": "admin",
82
+ "model": "sonnet",
83
+ "effort": "low",
84
+ "cwd": "/Users/you/admin-agent",
85
+ "requireMention": true,
86
+ "isolateTopics": true,
87
+ "topics": {
88
+ "100": "Customer A",
89
+ "200": "Customer B"
90
+ }
91
+ },
92
+
93
+ "-1000000000002": {
94
+ "name": "Partner Group",
95
+ "bot": "partner-bot",
96
+ "agent": "partner",
97
+ "model": "sonnet",
98
+ "effort": "low",
99
+ "cwd": "/Users/you/partner-agent",
100
+ "requireMention": true
101
+ }
102
+ },
103
+
104
+ "defaults": {
105
+ "model": "sonnet",
106
+ "effort": "low",
107
+ "timeout": 300,
108
+ "inboxRetentionDays": 30
109
+ },
110
+
111
+ "maxWarmProcesses": 10,
112
+
113
+ "attachmentLimits": {
114
+ "maxCount": 5,
115
+ "maxTotalMb": 20,
116
+ "maxFileMb": 10
117
+ }
118
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Approvals - inline keyboard gating for destructive tool calls.
3
+ *
4
+ * Claude Code fires a PreToolUse hook. The hook RPCs to the bridge daemon.
5
+ * The daemon inserts a pending row, posts [Approve]/[Deny] to the admin
6
+ * chat, and blocks on the operator's click (or a timeout).
7
+ *
8
+ * Persistence: `pending_approvals` row captures the whole lifecycle so we
9
+ * keep an audit trail even if the bridge restarts. Rows never get deleted;
10
+ * 'pending' rows at boot are swept into 'timeout'.
11
+ */
12
+
13
+ const crypto = require('crypto');
14
+
15
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
16
+ // 16 random bytes → 22 base64url chars ≈ 128 bits of entropy. Prevents
17
+ // brute-force guessing of approval callback tokens by anyone in the admin
18
+ // chat (old 6-char value was ~36 bits, within chat-storm reach).
19
+ const TOKEN_BYTES = 16;
20
+
21
+ function digestInput(input) {
22
+ const json = typeof input === 'string' ? input : JSON.stringify(input);
23
+ return crypto.createHash('sha256').update(json).digest('hex').slice(0, 16);
24
+ }
25
+
26
+ function newToken() {
27
+ return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
28
+ }
29
+
30
+ /**
31
+ * Constant-time compare. Different lengths → false without timing leak
32
+ * (timingSafeEqual itself throws on length mismatch).
33
+ */
34
+ function tokensEqual(a, b) {
35
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
36
+ const ab = Buffer.from(a);
37
+ const bb = Buffer.from(b);
38
+ if (ab.length !== bb.length) return false;
39
+ return crypto.timingSafeEqual(ab, bb);
40
+ }
41
+
42
+ /**
43
+ * Translate a Claude-Code-style permission pattern into a RegExp.
44
+ * Supported forms:
45
+ * "Bash" - any Bash tool call
46
+ * "Bash(rm *)" - Bash whose first-argument string matches glob
47
+ * "mcp__shopify__order_cancel" - exact MCP tool name
48
+ * "mcp__*__invoice_create" - glob on segment
49
+ * "WebFetch" - any WebFetch
50
+ * `*` matches anything non-greedily within a segment, including spaces.
51
+ */
52
+ function patternToRegex(pattern) {
53
+ const trimmed = String(pattern).trim();
54
+ const parenIdx = trimmed.indexOf('(');
55
+ const toolPart = parenIdx === -1 ? trimmed : trimmed.slice(0, parenIdx);
56
+ const argPart = parenIdx === -1
57
+ ? null
58
+ : trimmed.slice(parenIdx + 1, trimmed.lastIndexOf(')'));
59
+
60
+ const escape = (s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
61
+ const globToRe = (s) => escape(s).replace(/\*/g, '.*');
62
+
63
+ const toolRe = new RegExp(`^${globToRe(toolPart)}$`);
64
+ const argRe = argPart == null ? null : new RegExp(`^${globToRe(argPart)}$`);
65
+ return { toolRe, argRe, raw: trimmed };
66
+ }
67
+
68
+ /**
69
+ * Check whether a tool call matches any of the configured patterns.
70
+ * `toolInput` is the original params object. We match on:
71
+ * - Bash: first positional command (`command` param, space-split first token
72
+ * or whole string for arg globs that include spaces)
73
+ * - WebFetch: `url`
74
+ * - others: the JSON stringification (coarse but safe default)
75
+ */
76
+ function matchesAnyPattern(toolName, toolInput, patterns = []) {
77
+ const input = toolInput || {};
78
+ for (const raw of patterns) {
79
+ const p = patternToRegex(raw);
80
+ if (!p.toolRe.test(toolName)) continue;
81
+ if (!p.argRe) return { matched: true, pattern: p.raw };
82
+ let candidate = '';
83
+ if (toolName === 'Bash') {
84
+ candidate = input.command || '';
85
+ } else if (toolName === 'WebFetch') {
86
+ candidate = input.url || '';
87
+ } else {
88
+ candidate = typeof input === 'string' ? input : JSON.stringify(input);
89
+ }
90
+ if (p.argRe.test(candidate)) return { matched: true, pattern: p.raw };
91
+ }
92
+ return { matched: false };
93
+ }
94
+
95
+ function createStore(rawDb, now = () => Date.now()) {
96
+ const insertStmt = rawDb.prepare(`
97
+ INSERT INTO pending_approvals (
98
+ bot_name, turn_id, requester_chat_id, approver_chat_id,
99
+ tool_name, tool_input_json, tool_input_digest,
100
+ callback_token, requested_ts, timeout_ts
101
+ ) VALUES (
102
+ @bot_name, @turn_id, @requester_chat_id, @approver_chat_id,
103
+ @tool_name, @tool_input_json, @tool_input_digest,
104
+ @callback_token, @requested_ts, @timeout_ts
105
+ )
106
+ `);
107
+ const findDedupStmt = rawDb.prepare(`
108
+ SELECT * FROM pending_approvals
109
+ WHERE bot_name = ? AND turn_id IS ? AND tool_input_digest = ?
110
+ AND status = 'pending'
111
+ LIMIT 1
112
+ `);
113
+ const setApproverMsgStmt = rawDb.prepare(`
114
+ UPDATE pending_approvals SET approver_msg_id = ? WHERE id = ?
115
+ `);
116
+ const resolveStmt = rawDb.prepare(`
117
+ UPDATE pending_approvals
118
+ SET status = @status,
119
+ decided_ts = @decided_ts,
120
+ decided_by_user_id = @decided_by_user_id,
121
+ decided_by_user = @decided_by_user,
122
+ reason = @reason
123
+ WHERE id = @id AND status = 'pending'
124
+ `);
125
+ const getByIdStmt = rawDb.prepare(`SELECT * FROM pending_approvals WHERE id = ?`);
126
+ const listTimedOutStmt = rawDb.prepare(`
127
+ SELECT * FROM pending_approvals
128
+ WHERE status = 'pending' AND timeout_ts < ?
129
+ `);
130
+ const listPendingStmt = rawDb.prepare(`
131
+ SELECT * FROM pending_approvals
132
+ WHERE bot_name = ? AND status = 'pending'
133
+ ORDER BY requested_ts DESC
134
+ `);
135
+
136
+ return {
137
+ issue({
138
+ bot_name, turn_id = null, requester_chat_id, approver_chat_id,
139
+ tool_name, tool_input, timeoutMs = DEFAULT_TIMEOUT_MS,
140
+ }) {
141
+ if (!bot_name) throw new Error('bot_name required');
142
+ if (!requester_chat_id) throw new Error('requester_chat_id required');
143
+ if (!approver_chat_id) throw new Error('approver_chat_id required');
144
+ if (!tool_name) throw new Error('tool_name required');
145
+
146
+ const tool_input_json = typeof tool_input === 'string'
147
+ ? tool_input
148
+ : JSON.stringify(tool_input || {});
149
+ const tool_input_digest = digestInput(tool_input_json);
150
+ const requested_ts = now();
151
+ const timeout_ts = requested_ts + timeoutMs;
152
+ const callback_token = newToken();
153
+
154
+ // Dedup: wrap find + insert in a single BEGIN IMMEDIATE transaction so
155
+ // two concurrent hook calls (same turn, same input) can't both miss
156
+ // the existing row and insert two. SQLite's UPSERT would also work if
157
+ // we added a UNIQUE partial index in a migration; keeping this in
158
+ // application code avoids a schema bump.
159
+ let row, reused = false;
160
+ const tx = rawDb.transaction(() => {
161
+ const existing = findDedupStmt.get(bot_name, turn_id, tool_input_digest);
162
+ if (existing) { row = existing; reused = true; return; }
163
+ const res = insertStmt.run({
164
+ bot_name,
165
+ turn_id,
166
+ requester_chat_id: String(requester_chat_id),
167
+ approver_chat_id: String(approver_chat_id),
168
+ tool_name,
169
+ tool_input_json,
170
+ tool_input_digest,
171
+ callback_token,
172
+ requested_ts,
173
+ timeout_ts,
174
+ });
175
+ row = getByIdStmt.get(res.lastInsertRowid);
176
+ });
177
+ tx.immediate(); // BEGIN IMMEDIATE → write lock before the SELECT
178
+ if (reused) return { ...row, reused: true };
179
+ return row;
180
+ },
181
+
182
+ setApproverMsgId(id, msg_id) {
183
+ return setApproverMsgStmt.run(msg_id, id).changes;
184
+ },
185
+
186
+ resolve({ id, status, decided_by_user_id = null, decided_by_user = null, reason = null }) {
187
+ if (!['approved', 'denied', 'timeout', 'cancelled'].includes(status)) {
188
+ throw new Error(`bad status: ${status}`);
189
+ }
190
+ const res = resolveStmt.run({
191
+ id,
192
+ status,
193
+ decided_ts: now(),
194
+ decided_by_user_id,
195
+ decided_by_user,
196
+ reason,
197
+ });
198
+ return res.changes;
199
+ },
200
+
201
+ getById(id) { return getByIdStmt.get(id); },
202
+
203
+ listPending(bot_name) { return listPendingStmt.all(bot_name); },
204
+
205
+ sweepTimedOut() {
206
+ return listTimedOutStmt.all(now());
207
+ },
208
+ };
209
+ }
210
+
211
+ module.exports = {
212
+ createStore,
213
+ digestInput,
214
+ newToken,
215
+ tokensEqual,
216
+ patternToRegex,
217
+ matchesAnyPattern,
218
+ DEFAULT_TIMEOUT_MS,
219
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Attachment filter — caps count + total size + MIME allowlist.
3
+ * Rejected items return a human-readable reason that we surface to the
4
+ * user and log to the events table.
5
+ */
6
+
7
+ const MAX_COUNT = 5;
8
+ const MAX_FILE_BYTES = 10 * 1024 * 1024;
9
+ const MAX_TOTAL_BYTES = 20 * 1024 * 1024;
10
+ const MIME_ALLOW = [
11
+ /^image\//, /^audio\//, /^video\//,
12
+ /^application\/pdf$/, /^text\/plain$/,
13
+ /^application\/msword$/, /^application\/vnd\.openxmlformats-/,
14
+ /^application\/vnd\.ms-excel$/, /^application\/json$/,
15
+ /^text\/csv$/,
16
+ ];
17
+
18
+ function filterAttachments(attachments, opts = {}) {
19
+ const maxCount = opts.maxCount ?? MAX_COUNT;
20
+ const maxFileBytes = opts.maxFileBytes ?? MAX_FILE_BYTES;
21
+ const maxTotalBytes = opts.maxTotalBytes ?? MAX_TOTAL_BYTES;
22
+ const mimeAllow = opts.mimeAllow ?? MIME_ALLOW;
23
+
24
+ const accepted = [];
25
+ const rejected = [];
26
+ let totalBytes = 0;
27
+
28
+ for (const a of attachments || []) {
29
+ if (accepted.length >= maxCount) {
30
+ rejected.push({ att: a, reason: `exceeds max count (${maxCount})` });
31
+ continue;
32
+ }
33
+ const mime = a.mime_type || '';
34
+ if (!mimeAllow.some((re) => re.test(mime))) {
35
+ rejected.push({ att: a, reason: `mime not allowed (${mime || 'unknown'})` });
36
+ continue;
37
+ }
38
+ const size = a.size || 0;
39
+ // Telegram sometimes reports file_size=0 or omits it. Those bypass the
40
+ // cap here but the download step MUST re-check Content-Length and actual
41
+ // bytes — see downloadAttachments in bridge.js.
42
+ if (size > maxFileBytes) {
43
+ rejected.push({ att: a, reason: `exceeds per-file cap (${maxFileBytes} bytes, got ${size})` });
44
+ continue;
45
+ }
46
+ if (totalBytes + size > maxTotalBytes) {
47
+ rejected.push({ att: a, reason: `exceeds total size cap (${maxTotalBytes} bytes)` });
48
+ continue;
49
+ }
50
+ totalBytes += size;
51
+ accepted.push(a);
52
+ }
53
+ return { accepted, rejected, totalBytes };
54
+ }
55
+
56
+ module.exports = { filterAttachments, MAX_COUNT, MAX_FILE_BYTES, MAX_TOTAL_BYTES, MIME_ALLOW };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Per-bot config scoping.
3
+ *
4
+ * `filterConfigToBot(config, botName)` narrows a full-bridge config down to
5
+ * the subset owned by one bot. Enables Phase 7 (per-bot process isolation):
6
+ * each bot runs in its own Node process, sees only its own chats, and can't
7
+ * accidentally touch another bot's queue or Claude pool.
8
+ */
9
+
10
+ function parseBotArg(argv) {
11
+ return parseFlag(argv, '--bot', 'a bot name (e.g. --bot shumabit)');
12
+ }
13
+
14
+ function parseDbArg(argv) {
15
+ return parseFlag(argv, '--db', 'a path (e.g. --db /path/to/shumabit.db)');
16
+ }
17
+
18
+ function parseFlag(argv, flag, hint) {
19
+ const i = argv.indexOf(flag);
20
+ if (i === -1) return null;
21
+ const v = argv[i + 1];
22
+ if (!v || v.startsWith('--')) {
23
+ throw new Error(`${flag} requires ${hint}`);
24
+ }
25
+ return v;
26
+ }
27
+
28
+ function filterConfigToBot(config, botName) {
29
+ if (!config || !config.bots || !config.chats) {
30
+ throw new Error('config must have bots and chats');
31
+ }
32
+ if (!config.bots[botName]) {
33
+ throw new Error(`bot "${botName}" not in config.bots`);
34
+ }
35
+ const chats = {};
36
+ for (const [chatId, chat] of Object.entries(config.chats)) {
37
+ if (chat.bot === botName) chats[chatId] = chat;
38
+ }
39
+ if (Object.keys(chats).length === 0) {
40
+ throw new Error(`bot "${botName}" owns no chats in config.chats`);
41
+ }
42
+ return {
43
+ ...config,
44
+ bots: { [botName]: config.bots[botName] },
45
+ chats,
46
+ };
47
+ }
48
+
49
+ module.exports = { parseBotArg, parseDbArg, filterConfigToBot };