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
package/lib/db.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge DB client. Wraps better-sqlite3 with the ops the bridge + skill need.
|
|
3
|
+
* Synchronous (better-sqlite3). DB errors are caught by callers so the bridge
|
|
4
|
+
* never drops messages because of transcript failures.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const Database = require('better-sqlite3');
|
|
10
|
+
|
|
11
|
+
const SCHEMA_VERSION = 4;
|
|
12
|
+
|
|
13
|
+
function open(dbPath) {
|
|
14
|
+
const db = new Database(dbPath);
|
|
15
|
+
db.pragma('journal_mode = WAL');
|
|
16
|
+
db.pragma('busy_timeout = 5000');
|
|
17
|
+
db.pragma('foreign_keys = ON');
|
|
18
|
+
runMigrations(db, path.join(__dirname, '..', 'migrations'));
|
|
19
|
+
return wrap(db);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runMigrations(db, migrationsDir) {
|
|
23
|
+
const files = fs.readdirSync(migrationsDir)
|
|
24
|
+
.filter((f) => f.endsWith('.sql'))
|
|
25
|
+
.sort();
|
|
26
|
+
|
|
27
|
+
const currentPre = db.pragma('user_version', { simple: true });
|
|
28
|
+
if (currentPre >= SCHEMA_VERSION) return;
|
|
29
|
+
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
const n = parseInt(file.slice(0, 3), 10);
|
|
32
|
+
if (Number.isNaN(n)) continue;
|
|
33
|
+
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
|
34
|
+
// BEGIN IMMEDIATE acquires the write lock up-front, preventing two
|
|
35
|
+
// processes from both reading user_version=N and both attempting to
|
|
36
|
+
// apply migration N+1. The second migrator hits SQLITE_BUSY beyond
|
|
37
|
+
// busy_timeout and errors cleanly instead of corrupting state.
|
|
38
|
+
db.exec('BEGIN IMMEDIATE');
|
|
39
|
+
try {
|
|
40
|
+
// Re-read inside the transaction so we skip anything another process
|
|
41
|
+
// just committed (check-and-set semantics).
|
|
42
|
+
const current = db.pragma('user_version', { simple: true });
|
|
43
|
+
if (n <= current) {
|
|
44
|
+
db.exec('COMMIT');
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
console.log(`[db] applying migration ${file}`);
|
|
48
|
+
db.exec(sql);
|
|
49
|
+
db.pragma(`user_version = ${n}`);
|
|
50
|
+
db.exec('COMMIT');
|
|
51
|
+
} catch (err) {
|
|
52
|
+
try { db.exec('ROLLBACK'); } catch {}
|
|
53
|
+
throw new Error(`migration ${file} failed: ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function wrap(db) {
|
|
59
|
+
const insertMessageStmt = db.prepare(`
|
|
60
|
+
INSERT INTO messages (
|
|
61
|
+
chat_id, thread_id, msg_id, user, user_id, text, reply_to_id,
|
|
62
|
+
direction, source, bot_name, attachments_json, session_id,
|
|
63
|
+
model, effort, turn_id, status, error, cost_usd, ts
|
|
64
|
+
) VALUES (
|
|
65
|
+
@chat_id, @thread_id, @msg_id, @user, @user_id, @text, @reply_to_id,
|
|
66
|
+
@direction, @source, @bot_name, @attachments_json, @session_id,
|
|
67
|
+
@model, @effort, @turn_id, @status, @error, @cost_usd, @ts
|
|
68
|
+
)
|
|
69
|
+
ON CONFLICT(chat_id, msg_id) DO UPDATE SET
|
|
70
|
+
text = excluded.text,
|
|
71
|
+
edited_ts = excluded.ts
|
|
72
|
+
`);
|
|
73
|
+
|
|
74
|
+
const insertOutboundPendingStmt = db.prepare(`
|
|
75
|
+
INSERT INTO messages (
|
|
76
|
+
chat_id, thread_id, user, text, direction, source, bot_name,
|
|
77
|
+
turn_id, session_id, status, ts, msg_id
|
|
78
|
+
) VALUES (
|
|
79
|
+
@chat_id, @thread_id, @user, @text, 'out', @source, @bot_name,
|
|
80
|
+
@turn_id, @session_id, 'pending', @ts, @pending_id
|
|
81
|
+
)
|
|
82
|
+
`);
|
|
83
|
+
|
|
84
|
+
const markOutboundSentStmt = db.prepare(`
|
|
85
|
+
UPDATE messages SET msg_id = @msg_id, status = 'sent', ts = @ts
|
|
86
|
+
WHERE id = @id
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
const markOutboundFailedStmt = db.prepare(`
|
|
90
|
+
UPDATE messages SET status = 'failed', error = @error
|
|
91
|
+
WHERE id = @id
|
|
92
|
+
`);
|
|
93
|
+
|
|
94
|
+
const upsertSessionStmt = db.prepare(`
|
|
95
|
+
INSERT INTO sessions (
|
|
96
|
+
session_key, chat_id, thread_id, claude_session_id,
|
|
97
|
+
agent, cwd, model, effort, created_ts, last_active_ts
|
|
98
|
+
) VALUES (
|
|
99
|
+
@session_key, @chat_id, @thread_id, @claude_session_id,
|
|
100
|
+
@agent, @cwd, @model, @effort, @ts, @ts
|
|
101
|
+
)
|
|
102
|
+
ON CONFLICT(session_key) DO UPDATE SET
|
|
103
|
+
chat_id = excluded.chat_id,
|
|
104
|
+
thread_id = excluded.thread_id,
|
|
105
|
+
claude_session_id = excluded.claude_session_id,
|
|
106
|
+
agent = excluded.agent,
|
|
107
|
+
cwd = excluded.cwd,
|
|
108
|
+
model = excluded.model,
|
|
109
|
+
effort = excluded.effort,
|
|
110
|
+
last_active_ts = excluded.last_active_ts
|
|
111
|
+
`);
|
|
112
|
+
|
|
113
|
+
const getSessionStmt = db.prepare(`SELECT * FROM sessions WHERE session_key = ?`);
|
|
114
|
+
const touchSessionStmt = db.prepare(`UPDATE sessions SET last_active_ts = ? WHERE session_key = ?`);
|
|
115
|
+
const clearSessionIdStmt = db.prepare(`DELETE FROM sessions WHERE session_key = ?`);
|
|
116
|
+
|
|
117
|
+
const getMessageStmt = db.prepare(`
|
|
118
|
+
SELECT * FROM messages WHERE chat_id = ? AND msg_id = ?
|
|
119
|
+
ORDER BY id DESC LIMIT 1
|
|
120
|
+
`);
|
|
121
|
+
|
|
122
|
+
const setMessageTextStmt = db.prepare(`
|
|
123
|
+
UPDATE messages
|
|
124
|
+
SET text = @text,
|
|
125
|
+
attachments_json = COALESCE(@attachments_json, attachments_json)
|
|
126
|
+
WHERE chat_id = @chat_id AND msg_id = @msg_id
|
|
127
|
+
`);
|
|
128
|
+
|
|
129
|
+
const logChatMigrationStmt = db.prepare(`
|
|
130
|
+
INSERT OR REPLACE INTO chat_migrations (old_chat_id, new_chat_id, migrated_ts)
|
|
131
|
+
VALUES (?, ?, ?)
|
|
132
|
+
`);
|
|
133
|
+
|
|
134
|
+
const resolveChatIdStmt = db.prepare(`
|
|
135
|
+
SELECT new_chat_id FROM chat_migrations WHERE old_chat_id = ?
|
|
136
|
+
`);
|
|
137
|
+
|
|
138
|
+
const logEventStmt = db.prepare(`
|
|
139
|
+
INSERT INTO events (ts, chat_id, kind, detail_json)
|
|
140
|
+
VALUES (?, ?, ?, ?)
|
|
141
|
+
`);
|
|
142
|
+
|
|
143
|
+
const logConfigChangeStmt = db.prepare(`
|
|
144
|
+
INSERT INTO config_changes (
|
|
145
|
+
chat_id, thread_id, field, old_value, new_value,
|
|
146
|
+
user_id, user, source, ts
|
|
147
|
+
) VALUES (
|
|
148
|
+
@chat_id, @thread_id, @field, @old_value, @new_value,
|
|
149
|
+
@user_id, @user, @source, @ts
|
|
150
|
+
)
|
|
151
|
+
`);
|
|
152
|
+
|
|
153
|
+
const markStalePendingStmt = db.prepare(`
|
|
154
|
+
UPDATE messages SET status = 'failed', error = 'crashed-mid-send'
|
|
155
|
+
WHERE status = 'pending' AND ts < ?
|
|
156
|
+
`);
|
|
157
|
+
const markStalePendingForBotStmt = db.prepare(`
|
|
158
|
+
UPDATE messages SET status = 'failed', error = 'crashed-mid-send'
|
|
159
|
+
WHERE status = 'pending' AND ts < ? AND bot_name = ?
|
|
160
|
+
`);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
raw: db,
|
|
164
|
+
|
|
165
|
+
insertMessage(row) {
|
|
166
|
+
return insertMessageStmt.run({
|
|
167
|
+
chat_id: String(row.chat_id),
|
|
168
|
+
thread_id: row.thread_id ? String(row.thread_id) : null,
|
|
169
|
+
msg_id: row.msg_id,
|
|
170
|
+
user: row.user || null,
|
|
171
|
+
user_id: row.user_id || null,
|
|
172
|
+
text: row.text || '',
|
|
173
|
+
reply_to_id: row.reply_to_id || null,
|
|
174
|
+
direction: row.direction || 'in',
|
|
175
|
+
source: row.source || 'bridge',
|
|
176
|
+
bot_name: row.bot_name || null,
|
|
177
|
+
attachments_json: row.attachments_json || null,
|
|
178
|
+
session_id: row.session_id || null,
|
|
179
|
+
model: row.model || null,
|
|
180
|
+
effort: row.effort || null,
|
|
181
|
+
turn_id: row.turn_id || null,
|
|
182
|
+
status: row.status || 'received',
|
|
183
|
+
error: row.error || null,
|
|
184
|
+
cost_usd: row.cost_usd ?? null,
|
|
185
|
+
ts: row.ts || Date.now(),
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
insertOutboundPending(row) {
|
|
190
|
+
return insertOutboundPendingStmt.run({
|
|
191
|
+
chat_id: String(row.chat_id),
|
|
192
|
+
thread_id: row.thread_id ? String(row.thread_id) : null,
|
|
193
|
+
user: row.user || null,
|
|
194
|
+
text: row.text || '',
|
|
195
|
+
source: row.source || 'bridge',
|
|
196
|
+
bot_name: row.bot_name || null,
|
|
197
|
+
turn_id: row.turn_id || null,
|
|
198
|
+
session_id: row.session_id || null,
|
|
199
|
+
ts: row.ts || Date.now(),
|
|
200
|
+
pending_id: row.pending_id,
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
markOutboundSent(id, { msg_id, ts }) {
|
|
205
|
+
return markOutboundSentStmt.run({ id, msg_id, ts: ts || Date.now() });
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
markOutboundFailed(id, err) {
|
|
209
|
+
return markOutboundFailedStmt.run({ id, error: String(err).slice(0, 500) });
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
upsertSession(row) {
|
|
213
|
+
return upsertSessionStmt.run({
|
|
214
|
+
session_key: row.session_key,
|
|
215
|
+
chat_id: String(row.chat_id),
|
|
216
|
+
thread_id: row.thread_id ? String(row.thread_id) : null,
|
|
217
|
+
claude_session_id: row.claude_session_id,
|
|
218
|
+
agent: row.agent || null,
|
|
219
|
+
cwd: row.cwd || null,
|
|
220
|
+
model: row.model || null,
|
|
221
|
+
effort: row.effort || null,
|
|
222
|
+
ts: row.ts || Date.now(),
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
getSession(sessionKey) {
|
|
227
|
+
return getSessionStmt.get(sessionKey);
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
touchSession(sessionKey, ts = Date.now()) {
|
|
231
|
+
return touchSessionStmt.run(ts, sessionKey);
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
clearSessionId(sessionKey) {
|
|
235
|
+
return clearSessionIdStmt.run(sessionKey);
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
getMessage(chatId, msgId) {
|
|
239
|
+
return getMessageStmt.get(String(chatId), msgId);
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
setMessageText({ chat_id, msg_id, text, attachments_json = null }) {
|
|
243
|
+
return setMessageTextStmt.run({
|
|
244
|
+
chat_id: String(chat_id),
|
|
245
|
+
msg_id,
|
|
246
|
+
text: text ?? '',
|
|
247
|
+
attachments_json,
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
logChatMigration(oldChatId, newChatId, ts = Date.now()) {
|
|
252
|
+
return logChatMigrationStmt.run(String(oldChatId), String(newChatId), ts);
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
resolveChatId(chatId) {
|
|
256
|
+
const row = resolveChatIdStmt.get(String(chatId));
|
|
257
|
+
return row?.new_chat_id || String(chatId);
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
logEvent(kind, { chat_id = null, ...detail } = {}) {
|
|
261
|
+
return logEventStmt.run(
|
|
262
|
+
Date.now(),
|
|
263
|
+
chat_id ? String(chat_id) : null,
|
|
264
|
+
kind,
|
|
265
|
+
Object.keys(detail).length ? JSON.stringify(detail) : null,
|
|
266
|
+
);
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
logConfigChange(row) {
|
|
270
|
+
return logConfigChangeStmt.run({
|
|
271
|
+
chat_id: String(row.chat_id),
|
|
272
|
+
thread_id: row.thread_id ? String(row.thread_id) : null,
|
|
273
|
+
field: row.field,
|
|
274
|
+
old_value: row.old_value ?? null,
|
|
275
|
+
new_value: row.new_value,
|
|
276
|
+
user_id: row.user_id || null,
|
|
277
|
+
user: row.user || null,
|
|
278
|
+
source: row.source || 'command',
|
|
279
|
+
ts: row.ts || Date.now(),
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
markStalePending(olderThanMs = 60_000, botName = null) {
|
|
284
|
+
const cutoff = Date.now() - olderThanMs;
|
|
285
|
+
if (botName) return markStalePendingForBotStmt.run(cutoff, botName);
|
|
286
|
+
return markStalePendingStmt.run(cutoff);
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = { open };
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only query helpers against the bridge transcript DB.
|
|
3
|
+
*
|
|
4
|
+
* All functions take an opened DB wrapper (from `lib/db.js` or a read-only
|
|
5
|
+
* handle). Bot-scope isolation is enforced here: pass `allowedChatIds` and
|
|
6
|
+
* no result will leak outside that list.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const HARD_LIMIT = 500;
|
|
10
|
+
|
|
11
|
+
function clampLimit(limit, defaultLimit = 20) {
|
|
12
|
+
const n = Number(limit) || defaultLimit;
|
|
13
|
+
if (n < 1) return 1;
|
|
14
|
+
if (n > HARD_LIMIT) return HARD_LIMIT;
|
|
15
|
+
return Math.floor(n);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseSinceMs(since) {
|
|
19
|
+
if (!since) return null;
|
|
20
|
+
const m = String(since).match(/^(\d+)\s*(h|d|m)?$/i);
|
|
21
|
+
if (!m) return null;
|
|
22
|
+
const n = parseInt(m[1], 10);
|
|
23
|
+
const unit = (m[2] || 'd').toLowerCase();
|
|
24
|
+
const ms = unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : 86_400_000;
|
|
25
|
+
return n * ms;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Scope semantics:
|
|
29
|
+
// allowedChatIds === null → scope disabled (admin/global)
|
|
30
|
+
// allowedChatIds === [] → deny all (bot owns no chats yet)
|
|
31
|
+
// allowedChatIds === [id, ...] → restrict to those chats
|
|
32
|
+
// Empty array must NOT be conflated with null, or a newly-configured bot
|
|
33
|
+
// with no chats yet would see the entire transcript.
|
|
34
|
+
function withChatScope(sql, params, allowedChatIds, column = 'chat_id') {
|
|
35
|
+
if (allowedChatIds === null || allowedChatIds === undefined) return { sql, params };
|
|
36
|
+
const joiner = /where/i.test(sql) ? ' AND' : ' WHERE';
|
|
37
|
+
if (allowedChatIds.length === 0) {
|
|
38
|
+
return { sql: `${sql}${joiner} 1=0`, params };
|
|
39
|
+
}
|
|
40
|
+
const placeholders = allowedChatIds.map(() => '?').join(',');
|
|
41
|
+
return {
|
|
42
|
+
sql: `${sql}${joiner} ${column} IN (${placeholders})`,
|
|
43
|
+
params: [...params, ...allowedChatIds.map(String)],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* FTS5 input sanitizer. Wraps each whitespace-separated token in double
|
|
49
|
+
* quotes so special operators (AND/OR/NEAR/*) become literals.
|
|
50
|
+
*/
|
|
51
|
+
function fts5Escape(query) {
|
|
52
|
+
return String(query || '')
|
|
53
|
+
.split(/\s+/)
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((t) => '"' + t.replace(/"/g, '""') + '"')
|
|
56
|
+
.join(' ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function recent(db, { chatId, threadId = null, limit = 20, since = null, includeOutbound = true, allowedChatIds = null } = {}) {
|
|
60
|
+
const clamped = clampLimit(limit);
|
|
61
|
+
let sql = 'SELECT * FROM messages WHERE chat_id = ?';
|
|
62
|
+
const params = [String(chatId)];
|
|
63
|
+
if (threadId) { sql += ' AND thread_id = ?'; params.push(String(threadId)); }
|
|
64
|
+
if (!includeOutbound) sql += ` AND direction = 'in'`;
|
|
65
|
+
const sinceMs = parseSinceMs(since);
|
|
66
|
+
if (sinceMs) { sql += ' AND ts >= ?'; params.push(Date.now() - sinceMs); }
|
|
67
|
+
const scoped = withChatScope(sql, params, allowedChatIds);
|
|
68
|
+
scoped.sql += ' ORDER BY ts DESC LIMIT ?';
|
|
69
|
+
return db.raw.prepare(scoped.sql).all(...scoped.params, clamped).reverse();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function around(db, { chatId, msgId, before = 5, after = 5, allowedChatIds = null } = {}) {
|
|
73
|
+
const chat = String(chatId);
|
|
74
|
+
if (allowedChatIds && !allowedChatIds.map(String).includes(chat)) return [];
|
|
75
|
+
const anchor = db.raw.prepare('SELECT * FROM messages WHERE chat_id = ? AND msg_id = ? ORDER BY id DESC LIMIT 1').get(chat, msgId);
|
|
76
|
+
if (!anchor) return [];
|
|
77
|
+
const b = Math.min(Math.max(0, Number(before) || 0), HARD_LIMIT);
|
|
78
|
+
const a = Math.min(Math.max(0, Number(after) || 0), HARD_LIMIT);
|
|
79
|
+
const beforeRows = db.raw.prepare('SELECT * FROM messages WHERE chat_id = ? AND ts < ? ORDER BY ts DESC LIMIT ?').all(chat, anchor.ts, b).reverse();
|
|
80
|
+
const afterRows = db.raw.prepare('SELECT * FROM messages WHERE chat_id = ? AND ts > ? ORDER BY ts ASC LIMIT ?').all(chat, anchor.ts, a);
|
|
81
|
+
return [...beforeRows, anchor, ...afterRows];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function search(db, { query, chatId = null, threadId = null, user = null, days = null, limit = 20, allowedChatIds = null } = {}) {
|
|
85
|
+
const q = fts5Escape(query);
|
|
86
|
+
if (!q) return [];
|
|
87
|
+
const clamped = clampLimit(limit);
|
|
88
|
+
let sql = `
|
|
89
|
+
SELECT m.* FROM messages_fts
|
|
90
|
+
JOIN messages m ON m.id = messages_fts.rowid
|
|
91
|
+
WHERE messages_fts MATCH ?
|
|
92
|
+
`;
|
|
93
|
+
const params = [q];
|
|
94
|
+
if (chatId) { sql += ' AND m.chat_id = ?'; params.push(String(chatId)); }
|
|
95
|
+
if (threadId) { sql += ' AND m.thread_id = ?'; params.push(String(threadId)); }
|
|
96
|
+
if (user) { sql += ' AND m.user LIKE ?'; params.push(`%${user}%`); }
|
|
97
|
+
if (days) { sql += ' AND m.ts >= ?'; params.push(Date.now() - days * 86_400_000); }
|
|
98
|
+
const scoped = withChatScope(sql, params, allowedChatIds, 'm.chat_id');
|
|
99
|
+
scoped.sql += ' ORDER BY m.ts DESC LIMIT ?';
|
|
100
|
+
return db.raw.prepare(scoped.sql).all(...scoped.params, clamped);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function byUser(db, { user, chatId = null, threadId = null, days = 7, limit = 50, allowedChatIds = null } = {}) {
|
|
104
|
+
const clamped = clampLimit(limit, 50);
|
|
105
|
+
let sql = 'SELECT * FROM messages WHERE user LIKE ?';
|
|
106
|
+
const params = [`%${user}%`];
|
|
107
|
+
if (chatId) { sql += ' AND chat_id = ?'; params.push(String(chatId)); }
|
|
108
|
+
if (threadId) { sql += ' AND thread_id = ?'; params.push(String(threadId)); }
|
|
109
|
+
if (days) { sql += ' AND ts >= ?'; params.push(Date.now() - days * 86_400_000); }
|
|
110
|
+
const scoped = withChatScope(sql, params, allowedChatIds);
|
|
111
|
+
scoped.sql += ' ORDER BY ts DESC LIMIT ?';
|
|
112
|
+
return db.raw.prepare(scoped.sql).all(...scoped.params, clamped);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getMsg(db, { msgId, chatId = null, allowedChatIds = null } = {}) {
|
|
116
|
+
let sql = 'SELECT * FROM messages WHERE msg_id = ?';
|
|
117
|
+
const params = [msgId];
|
|
118
|
+
if (chatId) { sql += ' AND chat_id = ?'; params.push(String(chatId)); }
|
|
119
|
+
const scoped = withChatScope(sql, params, allowedChatIds);
|
|
120
|
+
scoped.sql += ' ORDER BY id DESC LIMIT 1';
|
|
121
|
+
return db.raw.prepare(scoped.sql).get(...scoped.params) || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function stats(db, { chatId = null, threadId = null, days = 7, allowedChatIds = null } = {}) {
|
|
125
|
+
const sinceTs = Date.now() - days * 86_400_000;
|
|
126
|
+
let sql = `SELECT user, direction, COUNT(*) AS count FROM messages WHERE ts >= ?`;
|
|
127
|
+
const params = [sinceTs];
|
|
128
|
+
if (chatId) { sql += ' AND chat_id = ?'; params.push(String(chatId)); }
|
|
129
|
+
if (threadId) { sql += ' AND thread_id = ?'; params.push(String(threadId)); }
|
|
130
|
+
const scoped = withChatScope(sql, params, allowedChatIds);
|
|
131
|
+
scoped.sql += ' GROUP BY user, direction ORDER BY count DESC';
|
|
132
|
+
return db.raw.prepare(scoped.sql).all(...scoped.params);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatPretty(rows) {
|
|
136
|
+
return rows.map((r) => {
|
|
137
|
+
const d = new Date(r.ts);
|
|
138
|
+
const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
139
|
+
const who = r.direction === 'out' ? `[bot:${r.bot_name || '?'}]` : (r.user || '?');
|
|
140
|
+
const text = (r.text || '').replace(/\s+/g, ' ').slice(0, 200);
|
|
141
|
+
return `[${hhmm}] ${who}: ${text} (msg ${r.msg_id})`;
|
|
142
|
+
}).join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
recent, around, search, byUser, getMsg, stats,
|
|
147
|
+
formatPretty, fts5Escape, parseSinceMs, clampLimit,
|
|
148
|
+
HARD_LIMIT,
|
|
149
|
+
};
|
package/lib/inbox.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbox on-disk helpers.
|
|
3
|
+
*
|
|
4
|
+
* `sweepInbox(dir, maxAgeMs)` deletes files under each chat subdir whose
|
|
5
|
+
* mtime is older than `maxAgeMs`. Called on bridge boot so a long-running
|
|
6
|
+
* bridge doesn't accumulate every file a user has ever sent.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
function sweepInbox(dir, maxAgeMs) {
|
|
13
|
+
if (!fs.existsSync(dir)) return { swept: 0, bytes: 0 };
|
|
14
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
15
|
+
let swept = 0;
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
for (const chatDir of fs.readdirSync(dir)) {
|
|
18
|
+
const full = path.join(dir, chatDir);
|
|
19
|
+
let stat;
|
|
20
|
+
try { stat = fs.statSync(full); } catch { continue; }
|
|
21
|
+
if (!stat.isDirectory()) continue;
|
|
22
|
+
for (const f of fs.readdirSync(full)) {
|
|
23
|
+
const p = path.join(full, f);
|
|
24
|
+
let s;
|
|
25
|
+
try { s = fs.statSync(p); } catch { continue; }
|
|
26
|
+
if (s.isFile() && s.mtimeMs < cutoff) {
|
|
27
|
+
try { fs.unlinkSync(p); swept++; bytes += s.size; } catch {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { swept, bytes };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { sweepInbox };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the bridge's unix socket IPC.
|
|
3
|
+
*
|
|
4
|
+
* One-shot request-reply: open connection, write one JSON line, read one
|
|
5
|
+
* JSON line, close. Used by the approval hook script (and, eventually,
|
|
6
|
+
* cron bridge callers).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const net = require('net');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
|
|
13
|
+
const DEFAULT_CALL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
function socketPathFor(botName) {
|
|
16
|
+
return `/tmp/polygram-${botName}.sock`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function secretPathFor(botName) {
|
|
20
|
+
return `/tmp/polygram-${botName}.secret`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the IPC secret for a bot. Prefers BRIDGE_IPC_SECRET env var (set by
|
|
25
|
+
* the bridge when spawning Claude subprocesses) over the file (used by
|
|
26
|
+
* cron and external callers that aren't bridge children). Missing secret
|
|
27
|
+
* is not an error — caller decides whether to send it.
|
|
28
|
+
*/
|
|
29
|
+
function readSecret(botName) {
|
|
30
|
+
if (process.env.BRIDGE_IPC_SECRET) return process.env.BRIDGE_IPC_SECRET;
|
|
31
|
+
try { return fs.readFileSync(secretPathFor(botName), 'utf8').trim(); }
|
|
32
|
+
catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function call({
|
|
36
|
+
path, op, payload = {}, id = null, secret = null,
|
|
37
|
+
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
38
|
+
callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS,
|
|
39
|
+
}) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const sock = net.createConnection({ path });
|
|
42
|
+
let resolved = false;
|
|
43
|
+
const finish = (err, res) => {
|
|
44
|
+
if (resolved) return;
|
|
45
|
+
resolved = true;
|
|
46
|
+
try { sock.destroy(); } catch {}
|
|
47
|
+
clearTimeout(connectTimer);
|
|
48
|
+
clearTimeout(callTimer);
|
|
49
|
+
if (err) reject(err); else resolve(res);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const connectTimer = setTimeout(
|
|
53
|
+
() => finish(new Error(`connect timeout after ${connectTimeoutMs}ms: ${path}`)),
|
|
54
|
+
connectTimeoutMs,
|
|
55
|
+
);
|
|
56
|
+
const callTimer = setTimeout(
|
|
57
|
+
() => finish(new Error(`call timeout after ${callTimeoutMs}ms`)),
|
|
58
|
+
callTimeoutMs,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
let buf = '';
|
|
62
|
+
sock.on('connect', () => {
|
|
63
|
+
clearTimeout(connectTimer);
|
|
64
|
+
const envelope = { op, id, ...payload };
|
|
65
|
+
if (secret) envelope.secret = secret;
|
|
66
|
+
sock.write(JSON.stringify(envelope) + '\n');
|
|
67
|
+
});
|
|
68
|
+
sock.on('data', (chunk) => {
|
|
69
|
+
buf += chunk.toString('utf8');
|
|
70
|
+
const nl = buf.indexOf('\n');
|
|
71
|
+
if (nl === -1) return;
|
|
72
|
+
const line = buf.slice(0, nl);
|
|
73
|
+
try {
|
|
74
|
+
finish(null, JSON.parse(line));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
finish(new Error(`bad json reply: ${err.message}`));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
sock.on('error', (err) => finish(err));
|
|
80
|
+
sock.on('close', () => {
|
|
81
|
+
if (!resolved) finish(new Error('socket closed without reply'));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Convenience: send a Telegram message (or other allowed method) via the
|
|
88
|
+
* owning bot's IPC socket. Cron's preferred replacement for talking to
|
|
89
|
+
* `lib/telegram.js` directly.
|
|
90
|
+
*
|
|
91
|
+
* On failure returns { ok: false, error }. Callers should surface the error
|
|
92
|
+
* to their own monitoring — silently eating it is how cron outages go unnoticed.
|
|
93
|
+
*/
|
|
94
|
+
async function tell(bot, method, params = {}, opts = {}) {
|
|
95
|
+
const path = opts.path || socketPathFor(bot);
|
|
96
|
+
const secret = opts.secret !== undefined ? opts.secret : readSecret(bot);
|
|
97
|
+
const res = await call({
|
|
98
|
+
path,
|
|
99
|
+
op: 'send',
|
|
100
|
+
id: opts.id || null,
|
|
101
|
+
secret,
|
|
102
|
+
payload: { method, params, source: opts.source || `cron:${process.argv[1]?.split('/').pop() || 'unknown'}` },
|
|
103
|
+
connectTimeoutMs: opts.connectTimeoutMs,
|
|
104
|
+
callTimeoutMs: opts.callTimeoutMs,
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
const err = new Error(`bridge IPC: ${res.error || 'unknown error'}`);
|
|
108
|
+
err.cause = res;
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
return res.result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { call, tell, socketPathFor, secretPathFor, readSecret };
|