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.
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * polygram-doctor — operational health check for a polygram bot.
4
+ *
5
+ * polygram-doctor --bot <name>
6
+ * run default static checks (config / DB / IPC / Telegram)
7
+ *
8
+ * polygram-doctor --bot <name> --roundtrip --to <chat_id>
9
+ * also do an outbound round-trip: IPC send → Telegram → DB read-back
10
+ *
11
+ * polygram-doctor --bot <name> --json
12
+ * machine-readable output for monitoring pipelines
13
+ *
14
+ * Exit codes:
15
+ * 0 all checks passed (warnings allowed unless --strict)
16
+ * 1 at least one check failed
17
+ * 2 bad invocation / missing args
18
+ *
19
+ * Extended from OpenClaw's doctor.ts pattern: config, token, reachability,
20
+ * membership checks, recent-error trail. Safe to run against a live bot
21
+ * (no state changes) unless --roundtrip is passed, which posts a single
22
+ * disable_notification=true message.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const Database = require('better-sqlite3');
28
+
29
+ const {
30
+ call, tell, socketPathFor, readSecret,
31
+ } = require('../lib/ipc-client');
32
+
33
+ // ─── Arg parsing ─────────────────────────────────────────────────────
34
+
35
+ function parseArg(argv, flag, { required = false } = {}) {
36
+ const i = argv.indexOf(flag);
37
+ if (i === -1) {
38
+ if (required) die(`missing required flag: ${flag}`);
39
+ return null;
40
+ }
41
+ const v = argv[i + 1];
42
+ if (!v || v.startsWith('--')) die(`${flag} requires a value`);
43
+ return v;
44
+ }
45
+
46
+ function hasFlag(argv, flag) {
47
+ return argv.includes(flag);
48
+ }
49
+
50
+ function die(msg) {
51
+ process.stderr.write(`polygram-doctor: ${msg}\n`);
52
+ process.exit(2);
53
+ }
54
+
55
+ const argv = process.argv;
56
+ const botName = parseArg(argv, '--bot', { required: true });
57
+ const configPath = parseArg(argv, '--config')
58
+ || process.env.POLYGRAM_CONFIG
59
+ || path.join(process.cwd(), 'config.json');
60
+ const dbPath = parseArg(argv, '--db')
61
+ || process.env.POLYGRAM_DB
62
+ || path.join(process.cwd(), `${botName}.db`);
63
+ const roundtripTo = parseArg(argv, '--to');
64
+ const doRoundtrip = hasFlag(argv, '--roundtrip');
65
+ const asJson = hasFlag(argv, '--json');
66
+ const strict = hasFlag(argv, '--strict');
67
+ const timeoutMs = parseInt(parseArg(argv, '--timeout-ms') || '8000', 10);
68
+
69
+ if (doRoundtrip && !roundtripTo) die('--roundtrip requires --to <chat_id>');
70
+
71
+ // ─── Check accumulator ───────────────────────────────────────────────
72
+
73
+ const checks = [];
74
+ function push(name, status, detail, extra) {
75
+ checks.push({ name, status, detail, extra });
76
+ if (!asJson) {
77
+ const icon = status === 'ok' ? '✅' : status === 'warn' ? '⚠️ ' : '❌';
78
+ let line = `${icon} ${name}`;
79
+ if (detail) line += ` — ${detail}`;
80
+ console.log(line);
81
+ }
82
+ }
83
+
84
+ // ─── Checks ──────────────────────────────────────────────────────────
85
+
86
+ function checkConfig() {
87
+ let raw;
88
+ try { raw = fs.readFileSync(configPath, 'utf8'); }
89
+ catch (e) { push('config', 'fail', `cannot read ${configPath}: ${e.message}`); return null; }
90
+ let cfg;
91
+ try { cfg = JSON.parse(raw); }
92
+ catch (e) { push('config', 'fail', `invalid JSON: ${e.message}`); return null; }
93
+ if (!cfg.bots || !cfg.bots[botName]) {
94
+ push('config', 'fail', `bot "${botName}" not in config.bots`);
95
+ return null;
96
+ }
97
+ const bot = cfg.bots[botName];
98
+ if (!bot.token) {
99
+ push('config', 'fail', `bot.${botName}.token is empty`);
100
+ return cfg;
101
+ }
102
+ const chatCount = Object.values(cfg.chats || {}).filter(c => c.bot === botName).length;
103
+ if (chatCount === 0) {
104
+ push('config', 'warn', `bot owns 0 chats in config.chats`);
105
+ } else {
106
+ push('config', 'ok', `bot found, ${chatCount} chat(s), admin=${bot.adminChatId || 'none'}`);
107
+ }
108
+ return cfg;
109
+ }
110
+
111
+ function checkDb() {
112
+ if (!fs.existsSync(dbPath)) {
113
+ push('db', 'fail', `no file at ${dbPath}`);
114
+ return null;
115
+ }
116
+ let db;
117
+ try { db = new Database(dbPath, { readonly: true, fileMustExist: true }); }
118
+ catch (e) { push('db', 'fail', `cannot open: ${e.message}`); return null; }
119
+ try {
120
+ const version = db.pragma('user_version', { simple: true });
121
+ const migrationsDir = path.join(__dirname, '..', 'migrations');
122
+ const files = fs.readdirSync(migrationsDir).filter(f => /^\d+.*\.sql$/.test(f));
123
+ const latest = files.length
124
+ ? Math.max(...files.map(f => parseInt(f.match(/^(\d+)/)[1], 10)))
125
+ : 0;
126
+ if (version === latest) {
127
+ push('db', 'ok', `schema v${version}`, { version, latest });
128
+ } else if (version < latest) {
129
+ push('db', 'warn', `schema v${version}, migrations dir has v${latest}; restart bot to apply`, { version, latest });
130
+ } else {
131
+ push('db', 'warn', `schema v${version} ahead of migrations dir v${latest}`, { version, latest });
132
+ }
133
+ return db;
134
+ } catch (e) {
135
+ push('db', 'fail', e.message);
136
+ db.close();
137
+ return null;
138
+ }
139
+ }
140
+
141
+ async function checkIpc() {
142
+ try {
143
+ const res = await call({
144
+ path: socketPathFor(botName),
145
+ op: 'ping',
146
+ callTimeoutMs: timeoutMs,
147
+ });
148
+ if (res?.ok && res.pong) {
149
+ push('ipc', 'ok', `socket responsive, bot=${res.bot}`);
150
+ return true;
151
+ }
152
+ push('ipc', 'fail', JSON.stringify(res));
153
+ return false;
154
+ } catch (err) {
155
+ // Distinguish "no socket" (bot not running) from "socket dead"
156
+ if (/ENOENT|ECONNREFUSED/.test(err.message)) {
157
+ push('ipc', 'warn', `bot not running — IPC socket absent at ${socketPathFor(botName)}`);
158
+ } else {
159
+ push('ipc', 'fail', err.message);
160
+ }
161
+ return false;
162
+ }
163
+ }
164
+
165
+ async function checkTelegram(cfg) {
166
+ if (!cfg || !cfg.bots?.[botName]?.token) {
167
+ push('telegram', 'warn', 'skipped — no token in config');
168
+ return;
169
+ }
170
+ const token = cfg.bots[botName].token;
171
+ try {
172
+ const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, {
173
+ signal: AbortSignal.timeout(timeoutMs),
174
+ });
175
+ const data = await res.json();
176
+ if (!data.ok) {
177
+ push('telegram', 'fail', `getMe returned: ${data.description || data.error_code}`);
178
+ return;
179
+ }
180
+ push('telegram', 'ok', `@${data.result.username} (${data.result.first_name})`,
181
+ { username: data.result.username, id: data.result.id });
182
+ } catch (err) {
183
+ push('telegram', 'fail', err.message);
184
+ }
185
+ }
186
+
187
+ function checkRecentErrors(db) {
188
+ if (!db) { push('recent-errors', 'warn', 'skipped — no db'); return; }
189
+ try {
190
+ const since = Date.now() - 24 * 3600 * 1000;
191
+ const rows = db.prepare(`
192
+ SELECT kind, COUNT(*) AS n
193
+ FROM events
194
+ WHERE ts >= ?
195
+ AND kind IN (
196
+ 'handler-error', 'telegram-api-error', 'telegram-edit-failed',
197
+ 'poll-stalled', 'resume-fail', 'turn-timeout',
198
+ 'typing-suspended', 'approval-sweep-failed', 'malformed-update',
199
+ 'telegram-retry', 'telegram-thread-fallback'
200
+ )
201
+ GROUP BY kind
202
+ ORDER BY n DESC
203
+ `).all(since);
204
+ if (rows.length === 0) {
205
+ push('recent-errors', 'ok', 'no failure events in last 24h');
206
+ return;
207
+ }
208
+ const summary = rows.map(r => `${r.kind}=${r.n}`).join(', ');
209
+ // Hard-fail on real errors, warn on retry/fallback (those are recoveries).
210
+ const hasHardError = rows.some(r =>
211
+ r.kind === 'handler-error' || r.kind === 'resume-fail' ||
212
+ r.kind === 'turn-timeout' || r.kind === 'approval-sweep-failed'
213
+ );
214
+ push('recent-errors', hasHardError ? 'warn' : 'ok', summary, { rows });
215
+ } catch (e) {
216
+ push('recent-errors', 'fail', e.message);
217
+ }
218
+ }
219
+
220
+ function checkPendingOutbound(db) {
221
+ if (!db) return;
222
+ try {
223
+ const cutoff = Date.now() - 5 * 60 * 1000;
224
+ const stale = db.prepare(`
225
+ SELECT COUNT(*) AS n FROM messages
226
+ WHERE status = 'pending' AND bot_name = ? AND ts < ?
227
+ `).get(botName, cutoff).n;
228
+ if (stale > 0) {
229
+ push('pending-outbound', 'warn', `${stale} pending rows older than 5min — bot may have crashed mid-send`);
230
+ } else {
231
+ push('pending-outbound', 'ok', 'no stale pending outbound rows');
232
+ }
233
+ } catch (e) {
234
+ push('pending-outbound', 'fail', e.message);
235
+ }
236
+ }
237
+
238
+ function checkApprovals(db) {
239
+ if (!db) return;
240
+ try {
241
+ const row = db.prepare(`
242
+ SELECT COUNT(*) AS n FROM approvals
243
+ WHERE status = 'pending' AND bot_name = ?
244
+ `).get(botName);
245
+ if (row.n > 0) {
246
+ push('approvals', 'warn', `${row.n} pending approval(s) — operator may need to act`);
247
+ } else {
248
+ push('approvals', 'ok', 'no pending approvals');
249
+ }
250
+ } catch (e) {
251
+ // Table may not exist pre-migration — not fatal.
252
+ push('approvals', 'warn', `skipped: ${e.message}`);
253
+ }
254
+ }
255
+
256
+ async function checkRoundtrip() {
257
+ if (!doRoundtrip) return;
258
+ const marker = `polygram-doctor:${Date.now()}`;
259
+ try {
260
+ const res = await tell(botName, 'sendMessage', {
261
+ chat_id: roundtripTo,
262
+ text: marker,
263
+ disable_notification: true,
264
+ }, { source: 'polygram-doctor', callTimeoutMs: timeoutMs });
265
+ const msgId = res?.message_id;
266
+ if (!msgId) {
267
+ push('roundtrip', 'fail', `no message_id: ${JSON.stringify(res)}`);
268
+ return;
269
+ }
270
+ // DB read-back
271
+ await new Promise((r) => setTimeout(r, 250));
272
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
273
+ const row = db.prepare(`
274
+ SELECT direction, status, text FROM messages
275
+ WHERE chat_id = ? AND msg_id = ?
276
+ `).get(String(roundtripTo), msgId);
277
+ db.close();
278
+ if (!row) { push('roundtrip', 'fail', `no DB row for msg_id=${msgId}`); return; }
279
+ if (row.status !== 'sent' || row.direction !== 'out') {
280
+ push('roundtrip', 'fail', `row status=${row.status} direction=${row.direction}`);
281
+ return;
282
+ }
283
+ if (!String(row.text).includes(marker)) {
284
+ push('roundtrip', 'fail', 'marker not in DB row');
285
+ return;
286
+ }
287
+ push('roundtrip', 'ok', `msg_id=${msgId} delivered + recorded`);
288
+ } catch (err) {
289
+ push('roundtrip', 'fail', err.message);
290
+ }
291
+ }
292
+
293
+ // ─── Main ────────────────────────────────────────────────────────────
294
+
295
+ async function main() {
296
+ const cfg = checkConfig();
297
+ const db = checkDb();
298
+ await checkIpc();
299
+ await checkTelegram(cfg);
300
+ checkRecentErrors(db);
301
+ checkPendingOutbound(db);
302
+ checkApprovals(db);
303
+ await checkRoundtrip();
304
+ if (db) try { db.close(); } catch {}
305
+
306
+ const fails = checks.filter(c => c.status === 'fail').length;
307
+ const warns = checks.filter(c => c.status === 'warn').length;
308
+
309
+ if (asJson) {
310
+ console.log(JSON.stringify({ bot: botName, checks, fails, warns }, null, 2));
311
+ } else {
312
+ const passed = checks.length - fails - warns;
313
+ console.log(`\n${passed} ok / ${warns} warn / ${fails} fail (bot=${botName})`);
314
+ }
315
+
316
+ if (fails > 0) process.exit(1);
317
+ if (strict && warns > 0) process.exit(1);
318
+ process.exit(0);
319
+ }
320
+
321
+ main().catch((err) => {
322
+ process.stderr.write(`polygram-doctor: unexpected error: ${err.message}\n${err.stack}\n`);
323
+ process.exit(1);
324
+ });
package/scripts/smoke.js DELETED
@@ -1,122 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * polygram-smoke — round-trip health check for a polygram bot.
4
- *
5
- * polygram-smoke --bot <name> --to <chat_id>
6
- *
7
- * Exits 0 if all three round-trips pass, 1 otherwise.
8
- *
9
- * Intended for cron (heartbeat) or ad-hoc operator verification.
10
- *
11
- * Round-trips:
12
- * 1. IPC ping — socket is alive and handshakes
13
- * 2. Outbound — IPC 'send' op → Telegram API → returns msg_id
14
- * 3. DB read-back — that msg_id appears in messages table with
15
- * direction='out', status='sent', matching text
16
- */
17
-
18
- const path = require('path');
19
- const Database = require('better-sqlite3');
20
- const { call, tell, socketPathFor, readSecret } = require('../lib/ipc-client');
21
-
22
- function parseArg(argv, flag, required = false) {
23
- const i = argv.indexOf(flag);
24
- if (i === -1) {
25
- if (required) die(`missing required flag: ${flag}`);
26
- return null;
27
- }
28
- const v = argv[i + 1];
29
- if (!v || v.startsWith('--')) die(`${flag} requires a value`);
30
- return v;
31
- }
32
-
33
- function die(msg) {
34
- process.stderr.write(`polygram-smoke: ${msg}\n`);
35
- process.exit(2);
36
- }
37
-
38
- const bot = parseArg(process.argv, '--bot', true);
39
- const to = parseArg(process.argv, '--to', true);
40
- const dbPath = parseArg(process.argv, '--db')
41
- || process.env.POLYGRAM_DB
42
- || path.join(process.cwd(), `${bot}.db`);
43
- const timeoutMs = parseInt(parseArg(process.argv, '--timeout-ms') || '8000', 10);
44
-
45
- const stamp = Date.now();
46
- const marker = `polygram-smoke:${stamp}`;
47
- const results = [];
48
-
49
- function report(step, ok, detail) {
50
- const icon = ok ? '✅' : '❌';
51
- console.log(`${icon} ${step}${detail ? ` — ${detail}` : ''}`);
52
- results.push({ step, ok, detail });
53
- }
54
-
55
- async function main() {
56
- // Step 1 — IPC ping
57
- try {
58
- const res = await call({
59
- path: socketPathFor(bot),
60
- op: 'ping',
61
- callTimeoutMs: timeoutMs,
62
- });
63
- if (res?.ok && res.pong) report('ipc-ping', true, `bot=${res.bot}`);
64
- else { report('ipc-ping', false, JSON.stringify(res)); exitNow(); }
65
- } catch (err) {
66
- report('ipc-ping', false, err.message);
67
- exitNow();
68
- }
69
-
70
- // Step 2 — outbound round-trip
71
- let msgId = null;
72
- try {
73
- const res = await tell(bot, 'sendMessage', {
74
- chat_id: to,
75
- text: marker,
76
- disable_notification: true,
77
- }, { source: 'polygram-smoke', callTimeoutMs: timeoutMs });
78
- msgId = res?.message_id;
79
- if (msgId) report('outbound-send', true, `msg_id=${msgId}`);
80
- else { report('outbound-send', false, JSON.stringify(res)); exitNow(); }
81
- } catch (err) {
82
- report('outbound-send', false, err.message);
83
- exitNow();
84
- }
85
-
86
- // Step 3 — DB read-back. The sender writes pending→sent in two steps;
87
- // give it a tick, then query by msg_id.
88
- await new Promise((r) => setTimeout(r, 250));
89
- try {
90
- const db = new Database(dbPath, { readonly: true, fileMustExist: true });
91
- const row = db.prepare(`
92
- SELECT direction, status, text, source, msg_id
93
- FROM messages
94
- WHERE chat_id = ? AND msg_id = ?
95
- `).get(String(to), msgId);
96
- db.close();
97
-
98
- if (!row) { report('db-readback', false, `no row for msg_id=${msgId}`); exitNow(); }
99
- if (row.direction !== 'out') { report('db-readback', false, `direction=${row.direction}`); exitNow(); }
100
- if (row.status !== 'sent') { report('db-readback', false, `status=${row.status}`); exitNow(); }
101
- if (!String(row.text).includes(marker)) { report('db-readback', false, `text mismatch: ${row.text?.slice(0, 60)}`); exitNow(); }
102
- report('db-readback', true, `sent row confirmed (source=${row.source})`);
103
- } catch (err) {
104
- report('db-readback', false, err.message);
105
- exitNow();
106
- }
107
-
108
- console.log(`\npolygram-smoke: PASS ${bot} ${new Date().toISOString()}`);
109
- process.exit(0);
110
- }
111
-
112
- function exitNow() {
113
- const passed = results.filter(r => r.ok).length;
114
- const total = results.length;
115
- console.log(`\npolygram-smoke: FAIL ${passed}/${total} steps bot=${bot}`);
116
- process.exit(1);
117
- }
118
-
119
- main().catch((err) => {
120
- console.error('polygram-smoke: unexpected error:', err.message);
121
- process.exit(1);
122
- });