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 +21 -0
- package/README.md +287 -0
- package/bin/bridge-approval-hook.js +113 -0
- package/bridge.js +1604 -0
- package/config.example.json +118 -0
- package/lib/approvals.js +219 -0
- package/lib/attachments.js +56 -0
- package/lib/config-scope.js +49 -0
- package/lib/db.js +291 -0
- package/lib/history.js +149 -0
- package/lib/inbox.js +34 -0
- package/lib/ipc-client.js +114 -0
- package/lib/ipc-server.js +149 -0
- package/lib/pairings.js +215 -0
- package/lib/process-manager.js +287 -0
- package/lib/prompt.js +200 -0
- package/lib/queue-utils.js +27 -0
- package/lib/session-key.js +31 -0
- package/lib/sessions.js +98 -0
- package/lib/stream-reply.js +140 -0
- package/lib/telegram.js +105 -0
- package/lib/voice.js +146 -0
- package/migrations/001-initial.sql +93 -0
- package/migrations/002-fix-fts-triggers.sql +24 -0
- package/migrations/003-pairings.sql +33 -0
- package/migrations/004-approvals.sql +28 -0
- package/ops/README.md +110 -0
- package/ops/polygram.plist.example +58 -0
- package/package.json +55 -0
- package/scripts/ipc-smoke.js +28 -0
- package/scripts/split-db.js +251 -0
- package/skills/telegram-history/SKILL.md +57 -0
- package/skills/telegram-history/scripts/query.js +289 -0
|
@@ -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
|
+
}
|
package/lib/approvals.js
ADDED
|
@@ -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 };
|