create-ironclaws 1.0.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/README.md +101 -0
- package/bin/create.js +394 -0
- package/package.json +33 -0
- package/template/.env.example +38 -0
- package/template/CLAUDE.md +104 -0
- package/template/agent-credentials.yaml +33 -0
- package/template/agents.yaml +22 -0
- package/template/container/Dockerfile +70 -0
- package/template/container/Dockerfile.argus +34 -0
- package/template/container/agent-runner/package-lock.json +1524 -0
- package/template/container/agent-runner/package.json +23 -0
- package/template/container/agent-runner/src/index.ts +630 -0
- package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
- package/template/container/agent-runner/tsconfig.json +15 -0
- package/template/container/build-argus.sh +25 -0
- package/template/container/build.sh +23 -0
- package/template/container/skills/agent-browser/SKILL.md +159 -0
- package/template/container/skills/agent-status/SKILL.md +69 -0
- package/template/container/skills/capabilities/SKILL.md +100 -0
- package/template/container/skills/edit-agent/SKILL.md +93 -0
- package/template/container/skills/slack-formatting/SKILL.md +92 -0
- package/template/container/skills/status/SKILL.md +104 -0
- package/template/container/tools/elastic_query.py +161 -0
- package/template/container/tools/gdrive_tool.py +185 -0
- package/template/container/tools/jira_tool.py +433 -0
- package/template/container/tools/slack_history_tool.py +144 -0
- package/template/container/tools/youtube_tool.py +174 -0
- package/template/docker-compose.yml +54 -0
- package/template/docs/how-it-works.md +496 -0
- package/template/eslint.config.js +32 -0
- package/template/groups/forge/CLAUDE.md +107 -0
- package/template/package-lock.json +5278 -0
- package/template/package.json +52 -0
- package/template/scripts/github-app-token.py +58 -0
- package/template/scripts/register-expense-agent.sh +121 -0
- package/template/scripts/run-migrations.ts +105 -0
- package/template/scripts/setup-onecli-secrets.sh +252 -0
- package/template/setup-agents.sh +142 -0
- package/template/src/channels/index.ts +13 -0
- package/template/src/channels/registry.test.ts +42 -0
- package/template/src/channels/registry.ts +28 -0
- package/template/src/channels/slack.test.ts +859 -0
- package/template/src/channels/slack.ts +373 -0
- package/template/src/claw-skill.test.ts +45 -0
- package/template/src/config.ts +94 -0
- package/template/src/container-runner.test.ts +221 -0
- package/template/src/container-runner.ts +1029 -0
- package/template/src/container-runtime.test.ts +149 -0
- package/template/src/container-runtime.ts +124 -0
- package/template/src/db-migration.test.ts +67 -0
- package/template/src/db.test.ts +484 -0
- package/template/src/db.ts +837 -0
- package/template/src/env.ts +42 -0
- package/template/src/formatting.test.ts +294 -0
- package/template/src/github-token.ts +48 -0
- package/template/src/google-token.ts +75 -0
- package/template/src/group-folder.test.ts +43 -0
- package/template/src/group-folder.ts +44 -0
- package/template/src/group-queue.test.ts +484 -0
- package/template/src/group-queue.ts +363 -0
- package/template/src/http-server.ts +343 -0
- package/template/src/index.ts +960 -0
- package/template/src/ipc-auth.test.ts +679 -0
- package/template/src/ipc.ts +548 -0
- package/template/src/logger.ts +16 -0
- package/template/src/mount-security.ts +421 -0
- package/template/src/network-policy.ts +119 -0
- package/template/src/remote-control.test.ts +397 -0
- package/template/src/remote-control.ts +224 -0
- package/template/src/router.ts +52 -0
- package/template/src/routing.test.ts +170 -0
- package/template/src/sender-allowlist.test.ts +216 -0
- package/template/src/sender-allowlist.ts +128 -0
- package/template/src/task-scheduler.test.ts +129 -0
- package/template/src/task-scheduler.ts +290 -0
- package/template/src/timezone.test.ts +73 -0
- package/template/src/timezone.ts +37 -0
- package/template/src/types.ts +114 -0
- package/template/src/worktree.ts +206 -0
- package/template/tsconfig.json +20 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js';
|
|
6
|
+
import { isValidGroupFolder } from './group-folder.js';
|
|
7
|
+
import { logger } from './logger.js';
|
|
8
|
+
import {
|
|
9
|
+
NewMessage,
|
|
10
|
+
RegisteredGroup,
|
|
11
|
+
ScheduledTask,
|
|
12
|
+
TaskRunLog,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
|
|
15
|
+
let db: Database.Database;
|
|
16
|
+
|
|
17
|
+
function createSchema(database: Database.Database): void {
|
|
18
|
+
database.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS chats (
|
|
20
|
+
jid TEXT PRIMARY KEY,
|
|
21
|
+
name TEXT,
|
|
22
|
+
last_message_time TEXT,
|
|
23
|
+
channel TEXT,
|
|
24
|
+
is_group INTEGER DEFAULT 0
|
|
25
|
+
);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
27
|
+
id TEXT,
|
|
28
|
+
chat_jid TEXT,
|
|
29
|
+
sender TEXT,
|
|
30
|
+
sender_name TEXT,
|
|
31
|
+
content TEXT,
|
|
32
|
+
timestamp TEXT,
|
|
33
|
+
is_from_me INTEGER,
|
|
34
|
+
is_bot_message INTEGER DEFAULT 0,
|
|
35
|
+
PRIMARY KEY (id, chat_jid),
|
|
36
|
+
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
|
|
37
|
+
);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
group_folder TEXT NOT NULL,
|
|
43
|
+
chat_jid TEXT NOT NULL,
|
|
44
|
+
prompt TEXT NOT NULL,
|
|
45
|
+
schedule_type TEXT NOT NULL,
|
|
46
|
+
schedule_value TEXT NOT NULL,
|
|
47
|
+
next_run TEXT,
|
|
48
|
+
last_run TEXT,
|
|
49
|
+
last_result TEXT,
|
|
50
|
+
status TEXT DEFAULT 'active',
|
|
51
|
+
created_at TEXT NOT NULL
|
|
52
|
+
);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_next_run ON scheduled_tasks(next_run);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_status ON scheduled_tasks(status);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS task_run_logs (
|
|
57
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
58
|
+
task_id TEXT NOT NULL,
|
|
59
|
+
run_at TEXT NOT NULL,
|
|
60
|
+
duration_ms INTEGER NOT NULL,
|
|
61
|
+
status TEXT NOT NULL,
|
|
62
|
+
result TEXT,
|
|
63
|
+
error TEXT,
|
|
64
|
+
FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id)
|
|
65
|
+
);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at);
|
|
67
|
+
|
|
68
|
+
CREATE TABLE IF NOT EXISTS router_state (
|
|
69
|
+
key TEXT PRIMARY KEY,
|
|
70
|
+
value TEXT NOT NULL
|
|
71
|
+
);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
73
|
+
group_folder TEXT PRIMARY KEY,
|
|
74
|
+
session_id TEXT NOT NULL
|
|
75
|
+
);
|
|
76
|
+
CREATE TABLE IF NOT EXISTS registered_groups (
|
|
77
|
+
jid TEXT PRIMARY KEY,
|
|
78
|
+
name TEXT NOT NULL,
|
|
79
|
+
folder TEXT NOT NULL UNIQUE,
|
|
80
|
+
trigger_pattern TEXT NOT NULL,
|
|
81
|
+
added_at TEXT NOT NULL,
|
|
82
|
+
container_config TEXT,
|
|
83
|
+
requires_trigger INTEGER DEFAULT 1
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE TABLE IF NOT EXISTS questionnaire_metrics (
|
|
87
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
89
|
+
user_email TEXT,
|
|
90
|
+
document_url TEXT,
|
|
91
|
+
document_id TEXT,
|
|
92
|
+
question TEXT NOT NULL UNIQUE,
|
|
93
|
+
answer TEXT,
|
|
94
|
+
response_time_ms INTEGER,
|
|
95
|
+
was_answered INTEGER DEFAULT 0,
|
|
96
|
+
answer_reference TEXT,
|
|
97
|
+
category TEXT,
|
|
98
|
+
expert_answered INTEGER DEFAULT 0,
|
|
99
|
+
sc_answered INTEGER DEFAULT 0,
|
|
100
|
+
is_expired INTEGER DEFAULT 0,
|
|
101
|
+
confidence TEXT,
|
|
102
|
+
updated_at DATETIME
|
|
103
|
+
);
|
|
104
|
+
`);
|
|
105
|
+
|
|
106
|
+
// ── Schema migrations ────────────────────────────────────────────────────
|
|
107
|
+
// Each migration runs exactly once, tracked in schema_migrations table.
|
|
108
|
+
// To add a new migration: append to MIGRATIONS array with next version number.
|
|
109
|
+
database.exec(`
|
|
110
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
111
|
+
version INTEGER PRIMARY KEY,
|
|
112
|
+
applied_at TEXT NOT NULL
|
|
113
|
+
)
|
|
114
|
+
`);
|
|
115
|
+
|
|
116
|
+
const appliedVersions = new Set<number>(
|
|
117
|
+
(database.prepare('SELECT version FROM schema_migrations').all() as Array<{ version: number }>)
|
|
118
|
+
.map((r) => r.version),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const MIGRATIONS: Array<{ version: number; description: string; run: () => void }> = [
|
|
122
|
+
{
|
|
123
|
+
version: 1,
|
|
124
|
+
description: 'Add context_mode to scheduled_tasks',
|
|
125
|
+
run: () => database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`),
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
version: 2,
|
|
129
|
+
description: 'Add script to scheduled_tasks',
|
|
130
|
+
run: () => database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
version: 3,
|
|
134
|
+
description: 'Add silent to scheduled_tasks; mark byte-daily-transcript silent',
|
|
135
|
+
run: () => {
|
|
136
|
+
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN silent INTEGER DEFAULT 0`);
|
|
137
|
+
database.exec(`UPDATE scheduled_tasks SET silent=1 WHERE id='byte-daily-transcript'`);
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
version: 4,
|
|
142
|
+
description: 'Add is_bot_message to messages; backfill from content prefix',
|
|
143
|
+
run: () => {
|
|
144
|
+
database.exec(`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`);
|
|
145
|
+
database
|
|
146
|
+
.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`)
|
|
147
|
+
.run(`${ASSISTANT_NAME}:%`);
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
version: 5,
|
|
152
|
+
description: 'Add is_main to registered_groups; backfill folder=main',
|
|
153
|
+
run: () => {
|
|
154
|
+
database.exec(`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`);
|
|
155
|
+
database.exec(`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`);
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
version: 6,
|
|
160
|
+
description: 'Add channel and is_group to chats; backfill from JID patterns',
|
|
161
|
+
run: () => {
|
|
162
|
+
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
|
|
163
|
+
database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`);
|
|
164
|
+
database.exec(`UPDATE chats SET channel='whatsapp', is_group=1 WHERE jid LIKE '%@g.us'`);
|
|
165
|
+
database.exec(`UPDATE chats SET channel='whatsapp', is_group=0 WHERE jid LIKE '%@s.whatsapp.net'`);
|
|
166
|
+
database.exec(`UPDATE chats SET channel='discord', is_group=1 WHERE jid LIKE 'dc:%'`);
|
|
167
|
+
database.exec(`UPDATE chats SET channel='telegram', is_group=0 WHERE jid LIKE 'tg:%'`);
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const recordApplied = database.prepare(
|
|
173
|
+
'INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?, ?)',
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
for (const migration of MIGRATIONS) {
|
|
177
|
+
if (appliedVersions.has(migration.version)) continue;
|
|
178
|
+
try {
|
|
179
|
+
migration.run();
|
|
180
|
+
logger.debug({ version: migration.version, description: migration.description }, 'DB migration applied');
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
183
|
+
// ALTER TABLE fails with "duplicate column name" when the column already
|
|
184
|
+
// exists (pre-migration DB). That means the desired state is already in
|
|
185
|
+
// place — record as applied and move on.
|
|
186
|
+
if (msg.includes('duplicate column name') || msg.includes('already exists')) {
|
|
187
|
+
logger.debug({ version: migration.version }, 'DB migration already in desired state (column exists)');
|
|
188
|
+
} else {
|
|
189
|
+
// Unexpected error — log but still record to avoid retry loops
|
|
190
|
+
logger.error({ version: migration.version, description: migration.description, err }, 'DB migration failed unexpectedly');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Always record as applied — either it ran successfully or the schema is
|
|
194
|
+
// already in the desired state (duplicate column error).
|
|
195
|
+
recordApplied.run(migration.version, new Date().toISOString());
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function initDatabase(): void {
|
|
200
|
+
const dbPath = path.join(STORE_DIR, 'messages.db');
|
|
201
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
202
|
+
|
|
203
|
+
db = new Database(dbPath);
|
|
204
|
+
createSchema(db);
|
|
205
|
+
|
|
206
|
+
// Migrate from JSON files if they exist
|
|
207
|
+
migrateJsonState();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @internal - for tests only. Creates a fresh in-memory database. */
|
|
211
|
+
export function _initTestDatabase(): void {
|
|
212
|
+
db = new Database(':memory:');
|
|
213
|
+
createSchema(db);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @internal - for tests only. */
|
|
217
|
+
export function _closeDatabase(): void {
|
|
218
|
+
db.close();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Store chat metadata only (no message content).
|
|
223
|
+
* Used for all chats to enable group discovery without storing sensitive content.
|
|
224
|
+
*/
|
|
225
|
+
export function storeChatMetadata(
|
|
226
|
+
chatJid: string,
|
|
227
|
+
timestamp: string,
|
|
228
|
+
name?: string,
|
|
229
|
+
channel?: string,
|
|
230
|
+
isGroup?: boolean,
|
|
231
|
+
): void {
|
|
232
|
+
const ch = channel ?? null;
|
|
233
|
+
const group = isGroup === undefined ? null : isGroup ? 1 : 0;
|
|
234
|
+
|
|
235
|
+
if (name) {
|
|
236
|
+
// Update with name, preserving existing timestamp if newer
|
|
237
|
+
db.prepare(
|
|
238
|
+
`
|
|
239
|
+
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
|
|
240
|
+
ON CONFLICT(jid) DO UPDATE SET
|
|
241
|
+
name = excluded.name,
|
|
242
|
+
last_message_time = MAX(last_message_time, excluded.last_message_time),
|
|
243
|
+
channel = COALESCE(excluded.channel, channel),
|
|
244
|
+
is_group = COALESCE(excluded.is_group, is_group)
|
|
245
|
+
`,
|
|
246
|
+
).run(chatJid, name, timestamp, ch, group);
|
|
247
|
+
} else {
|
|
248
|
+
// Update timestamp only, preserve existing name if any
|
|
249
|
+
db.prepare(
|
|
250
|
+
`
|
|
251
|
+
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
|
|
252
|
+
ON CONFLICT(jid) DO UPDATE SET
|
|
253
|
+
last_message_time = MAX(last_message_time, excluded.last_message_time),
|
|
254
|
+
channel = COALESCE(excluded.channel, channel),
|
|
255
|
+
is_group = COALESCE(excluded.is_group, is_group)
|
|
256
|
+
`,
|
|
257
|
+
).run(chatJid, chatJid, timestamp, ch, group);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Update chat name without changing timestamp for existing chats.
|
|
263
|
+
* New chats get the current time as their initial timestamp.
|
|
264
|
+
* Used during group metadata sync.
|
|
265
|
+
*/
|
|
266
|
+
export function updateChatName(chatJid: string, name: string): void {
|
|
267
|
+
db.prepare(
|
|
268
|
+
`
|
|
269
|
+
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
|
|
270
|
+
ON CONFLICT(jid) DO UPDATE SET name = excluded.name
|
|
271
|
+
`,
|
|
272
|
+
).run(chatJid, name, new Date().toISOString());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface ChatInfo {
|
|
276
|
+
jid: string;
|
|
277
|
+
name: string;
|
|
278
|
+
last_message_time: string;
|
|
279
|
+
channel: string;
|
|
280
|
+
is_group: number;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get all known chats, ordered by most recent activity.
|
|
285
|
+
*/
|
|
286
|
+
export function getAllChats(): ChatInfo[] {
|
|
287
|
+
return db
|
|
288
|
+
.prepare(
|
|
289
|
+
`
|
|
290
|
+
SELECT jid, name, last_message_time, channel, is_group
|
|
291
|
+
FROM chats
|
|
292
|
+
ORDER BY last_message_time DESC
|
|
293
|
+
`,
|
|
294
|
+
)
|
|
295
|
+
.all() as ChatInfo[];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Get timestamp of last group metadata sync.
|
|
300
|
+
*/
|
|
301
|
+
export function getLastGroupSync(): string | null {
|
|
302
|
+
// Store sync time in a special chat entry
|
|
303
|
+
const row = db
|
|
304
|
+
.prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`)
|
|
305
|
+
.get() as { last_message_time: string } | undefined;
|
|
306
|
+
return row?.last_message_time || null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Record that group metadata was synced.
|
|
311
|
+
*/
|
|
312
|
+
export function setLastGroupSync(): void {
|
|
313
|
+
const now = new Date().toISOString();
|
|
314
|
+
db.prepare(
|
|
315
|
+
`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`,
|
|
316
|
+
).run(now);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Store a message with full content.
|
|
321
|
+
* Only call this for registered groups where message history is needed.
|
|
322
|
+
*/
|
|
323
|
+
export function storeMessage(msg: NewMessage): void {
|
|
324
|
+
db.prepare(
|
|
325
|
+
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
326
|
+
).run(
|
|
327
|
+
msg.id,
|
|
328
|
+
msg.chat_jid,
|
|
329
|
+
msg.sender,
|
|
330
|
+
msg.sender_name,
|
|
331
|
+
msg.content,
|
|
332
|
+
msg.timestamp,
|
|
333
|
+
msg.is_from_me ? 1 : 0,
|
|
334
|
+
msg.is_bot_message ? 1 : 0,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Store a message directly.
|
|
340
|
+
*/
|
|
341
|
+
export function storeMessageDirect(msg: {
|
|
342
|
+
id: string;
|
|
343
|
+
chat_jid: string;
|
|
344
|
+
sender: string;
|
|
345
|
+
sender_name: string;
|
|
346
|
+
content: string;
|
|
347
|
+
timestamp: string;
|
|
348
|
+
is_from_me: boolean;
|
|
349
|
+
is_bot_message?: boolean;
|
|
350
|
+
}): void {
|
|
351
|
+
db.prepare(
|
|
352
|
+
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
353
|
+
).run(
|
|
354
|
+
msg.id,
|
|
355
|
+
msg.chat_jid,
|
|
356
|
+
msg.sender,
|
|
357
|
+
msg.sender_name,
|
|
358
|
+
msg.content,
|
|
359
|
+
msg.timestamp,
|
|
360
|
+
msg.is_from_me ? 1 : 0,
|
|
361
|
+
msg.is_bot_message ? 1 : 0,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function getNewMessages(
|
|
366
|
+
jids: string[],
|
|
367
|
+
lastTimestamp: string,
|
|
368
|
+
botPrefix: string,
|
|
369
|
+
limit: number = 200,
|
|
370
|
+
): { messages: NewMessage[]; newTimestamp: string } {
|
|
371
|
+
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
|
372
|
+
|
|
373
|
+
const placeholders = jids.map(() => '?').join(',');
|
|
374
|
+
// Filter bot messages using both the is_bot_message flag AND the content
|
|
375
|
+
// prefix as a backstop for messages written before the migration ran.
|
|
376
|
+
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
|
377
|
+
const sql = `
|
|
378
|
+
SELECT * FROM (
|
|
379
|
+
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
|
380
|
+
FROM messages
|
|
381
|
+
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
|
382
|
+
AND is_from_me = 0 AND content NOT LIKE ?
|
|
383
|
+
AND content != '' AND content IS NOT NULL
|
|
384
|
+
ORDER BY timestamp DESC
|
|
385
|
+
LIMIT ?
|
|
386
|
+
) ORDER BY timestamp
|
|
387
|
+
`;
|
|
388
|
+
|
|
389
|
+
const rows = db
|
|
390
|
+
.prepare(sql)
|
|
391
|
+
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[];
|
|
392
|
+
|
|
393
|
+
let newTimestamp = lastTimestamp;
|
|
394
|
+
for (const row of rows) {
|
|
395
|
+
if (row.timestamp > newTimestamp) newTimestamp = row.timestamp;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { messages: rows, newTimestamp };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function getMessagesSince(
|
|
402
|
+
chatJid: string,
|
|
403
|
+
sinceTimestamp: string,
|
|
404
|
+
botPrefix: string,
|
|
405
|
+
limit: number = 200,
|
|
406
|
+
): NewMessage[] {
|
|
407
|
+
// Filter bot messages using both the is_bot_message flag AND the content
|
|
408
|
+
// prefix as a backstop for messages written before the migration ran.
|
|
409
|
+
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
|
410
|
+
const sql = `
|
|
411
|
+
SELECT * FROM (
|
|
412
|
+
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
|
413
|
+
FROM messages
|
|
414
|
+
WHERE chat_jid = ? AND timestamp > ?
|
|
415
|
+
AND is_from_me = 0 AND content NOT LIKE ?
|
|
416
|
+
AND content != '' AND content IS NOT NULL
|
|
417
|
+
ORDER BY timestamp DESC
|
|
418
|
+
LIMIT ?
|
|
419
|
+
) ORDER BY timestamp
|
|
420
|
+
`;
|
|
421
|
+
return db
|
|
422
|
+
.prepare(sql)
|
|
423
|
+
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function createTask(
|
|
427
|
+
task: Omit<ScheduledTask, 'last_run' | 'last_result'>,
|
|
428
|
+
): void {
|
|
429
|
+
db.prepare(
|
|
430
|
+
`
|
|
431
|
+
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, silent, created_at)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
433
|
+
`,
|
|
434
|
+
).run(
|
|
435
|
+
task.id,
|
|
436
|
+
task.group_folder,
|
|
437
|
+
task.chat_jid,
|
|
438
|
+
task.prompt,
|
|
439
|
+
task.script || null,
|
|
440
|
+
task.schedule_type,
|
|
441
|
+
task.schedule_value,
|
|
442
|
+
task.context_mode || 'isolated',
|
|
443
|
+
task.next_run,
|
|
444
|
+
task.status,
|
|
445
|
+
task.silent ? 1 : 0,
|
|
446
|
+
task.created_at,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function getTaskById(id: string): ScheduledTask | undefined {
|
|
451
|
+
return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as
|
|
452
|
+
| ScheduledTask
|
|
453
|
+
| undefined;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function getTasksForGroup(groupFolder: string): ScheduledTask[] {
|
|
457
|
+
return db
|
|
458
|
+
.prepare(
|
|
459
|
+
'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC',
|
|
460
|
+
)
|
|
461
|
+
.all(groupFolder) as ScheduledTask[];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function getAllTasks(): ScheduledTask[] {
|
|
465
|
+
return db
|
|
466
|
+
.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC')
|
|
467
|
+
.all() as ScheduledTask[];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function updateTask(
|
|
471
|
+
id: string,
|
|
472
|
+
updates: Partial<
|
|
473
|
+
Pick<
|
|
474
|
+
ScheduledTask,
|
|
475
|
+
| 'prompt'
|
|
476
|
+
| 'script'
|
|
477
|
+
| 'schedule_type'
|
|
478
|
+
| 'schedule_value'
|
|
479
|
+
| 'next_run'
|
|
480
|
+
| 'status'
|
|
481
|
+
>
|
|
482
|
+
>,
|
|
483
|
+
): void {
|
|
484
|
+
const fields: string[] = [];
|
|
485
|
+
const values: unknown[] = [];
|
|
486
|
+
|
|
487
|
+
if (updates.prompt !== undefined) {
|
|
488
|
+
fields.push('prompt = ?');
|
|
489
|
+
values.push(updates.prompt);
|
|
490
|
+
}
|
|
491
|
+
if (updates.script !== undefined) {
|
|
492
|
+
fields.push('script = ?');
|
|
493
|
+
values.push(updates.script || null);
|
|
494
|
+
}
|
|
495
|
+
if (updates.schedule_type !== undefined) {
|
|
496
|
+
fields.push('schedule_type = ?');
|
|
497
|
+
values.push(updates.schedule_type);
|
|
498
|
+
}
|
|
499
|
+
if (updates.schedule_value !== undefined) {
|
|
500
|
+
fields.push('schedule_value = ?');
|
|
501
|
+
values.push(updates.schedule_value);
|
|
502
|
+
}
|
|
503
|
+
if (updates.next_run !== undefined) {
|
|
504
|
+
fields.push('next_run = ?');
|
|
505
|
+
values.push(updates.next_run);
|
|
506
|
+
}
|
|
507
|
+
if (updates.status !== undefined) {
|
|
508
|
+
fields.push('status = ?');
|
|
509
|
+
values.push(updates.status);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (fields.length === 0) return;
|
|
513
|
+
|
|
514
|
+
values.push(id);
|
|
515
|
+
db.prepare(
|
|
516
|
+
`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`,
|
|
517
|
+
).run(...values);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function deleteTask(id: string): void {
|
|
521
|
+
// Delete child records first (FK constraint)
|
|
522
|
+
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
|
523
|
+
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export function getDueTasks(): ScheduledTask[] {
|
|
527
|
+
const now = new Date().toISOString();
|
|
528
|
+
return db
|
|
529
|
+
.prepare(
|
|
530
|
+
`
|
|
531
|
+
SELECT * FROM scheduled_tasks
|
|
532
|
+
WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ?
|
|
533
|
+
ORDER BY next_run
|
|
534
|
+
`,
|
|
535
|
+
)
|
|
536
|
+
.all(now) as ScheduledTask[];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function updateTaskAfterRun(
|
|
540
|
+
id: string,
|
|
541
|
+
nextRun: string | null,
|
|
542
|
+
lastResult: string,
|
|
543
|
+
): void {
|
|
544
|
+
const now = new Date().toISOString();
|
|
545
|
+
db.prepare(
|
|
546
|
+
`
|
|
547
|
+
UPDATE scheduled_tasks
|
|
548
|
+
SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END
|
|
549
|
+
WHERE id = ?
|
|
550
|
+
`,
|
|
551
|
+
).run(nextRun, now, lastResult, nextRun, id);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function logTaskRun(log: TaskRunLog): void {
|
|
555
|
+
db.prepare(
|
|
556
|
+
`
|
|
557
|
+
INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error)
|
|
558
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
559
|
+
`,
|
|
560
|
+
).run(
|
|
561
|
+
log.task_id,
|
|
562
|
+
log.run_at,
|
|
563
|
+
log.duration_ms,
|
|
564
|
+
log.status,
|
|
565
|
+
log.result,
|
|
566
|
+
log.error,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// --- Router state accessors ---
|
|
571
|
+
|
|
572
|
+
export function getRouterState(key: string): string | undefined {
|
|
573
|
+
const row = db
|
|
574
|
+
.prepare('SELECT value FROM router_state WHERE key = ?')
|
|
575
|
+
.get(key) as { value: string } | undefined;
|
|
576
|
+
return row?.value;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function setRouterState(key: string, value: string): void {
|
|
580
|
+
db.prepare(
|
|
581
|
+
'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)',
|
|
582
|
+
).run(key, value);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// --- Session accessors ---
|
|
586
|
+
|
|
587
|
+
export function getSession(groupFolder: string): string | undefined {
|
|
588
|
+
const row = db
|
|
589
|
+
.prepare('SELECT session_id FROM sessions WHERE group_folder = ?')
|
|
590
|
+
.get(groupFolder) as { session_id: string } | undefined;
|
|
591
|
+
return row?.session_id;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function setSession(groupFolder: string, sessionId: string): void {
|
|
595
|
+
db.prepare(
|
|
596
|
+
'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)',
|
|
597
|
+
).run(groupFolder, sessionId);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function clearSession(groupFolder: string): void {
|
|
601
|
+
db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function getAllSessions(): Record<string, string> {
|
|
605
|
+
const rows = db
|
|
606
|
+
.prepare('SELECT group_folder, session_id FROM sessions')
|
|
607
|
+
.all() as Array<{ group_folder: string; session_id: string }>;
|
|
608
|
+
const result: Record<string, string> = {};
|
|
609
|
+
for (const row of rows) {
|
|
610
|
+
result[row.group_folder] = row.session_id;
|
|
611
|
+
}
|
|
612
|
+
return result;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// --- Registered group accessors ---
|
|
616
|
+
|
|
617
|
+
export function getRegisteredGroup(
|
|
618
|
+
jid: string,
|
|
619
|
+
): (RegisteredGroup & { jid: string }) | undefined {
|
|
620
|
+
const row = db
|
|
621
|
+
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
|
622
|
+
.get(jid) as
|
|
623
|
+
| {
|
|
624
|
+
jid: string;
|
|
625
|
+
name: string;
|
|
626
|
+
folder: string;
|
|
627
|
+
trigger_pattern: string;
|
|
628
|
+
added_at: string;
|
|
629
|
+
container_config: string | null;
|
|
630
|
+
requires_trigger: number | null;
|
|
631
|
+
is_main: number | null;
|
|
632
|
+
}
|
|
633
|
+
| undefined;
|
|
634
|
+
if (!row) return undefined;
|
|
635
|
+
if (!isValidGroupFolder(row.folder)) {
|
|
636
|
+
logger.warn(
|
|
637
|
+
{ jid: row.jid, folder: row.folder },
|
|
638
|
+
'Skipping registered group with invalid folder',
|
|
639
|
+
);
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
jid: row.jid,
|
|
644
|
+
name: row.name,
|
|
645
|
+
folder: row.folder,
|
|
646
|
+
trigger: row.trigger_pattern,
|
|
647
|
+
added_at: row.added_at,
|
|
648
|
+
containerConfig: row.container_config
|
|
649
|
+
? JSON.parse(row.container_config)
|
|
650
|
+
: undefined,
|
|
651
|
+
requiresTrigger:
|
|
652
|
+
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
|
653
|
+
isMain: row.is_main === 1 ? true : undefined,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
|
658
|
+
if (!isValidGroupFolder(group.folder)) {
|
|
659
|
+
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
|
660
|
+
}
|
|
661
|
+
db.prepare(
|
|
662
|
+
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
|
|
663
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
664
|
+
).run(
|
|
665
|
+
jid,
|
|
666
|
+
group.name,
|
|
667
|
+
group.folder,
|
|
668
|
+
group.trigger,
|
|
669
|
+
group.added_at,
|
|
670
|
+
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
|
|
671
|
+
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
|
|
672
|
+
group.isMain ? 1 : 0,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
|
677
|
+
const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{
|
|
678
|
+
jid: string;
|
|
679
|
+
name: string;
|
|
680
|
+
folder: string;
|
|
681
|
+
trigger_pattern: string;
|
|
682
|
+
added_at: string;
|
|
683
|
+
container_config: string | null;
|
|
684
|
+
requires_trigger: number | null;
|
|
685
|
+
is_main: number | null;
|
|
686
|
+
}>;
|
|
687
|
+
const result: Record<string, RegisteredGroup> = {};
|
|
688
|
+
for (const row of rows) {
|
|
689
|
+
if (!isValidGroupFolder(row.folder)) {
|
|
690
|
+
logger.warn(
|
|
691
|
+
{ jid: row.jid, folder: row.folder },
|
|
692
|
+
'Skipping registered group with invalid folder',
|
|
693
|
+
);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
result[row.jid] = {
|
|
697
|
+
name: row.name,
|
|
698
|
+
folder: row.folder,
|
|
699
|
+
trigger: row.trigger_pattern,
|
|
700
|
+
added_at: row.added_at,
|
|
701
|
+
containerConfig: row.container_config
|
|
702
|
+
? JSON.parse(row.container_config)
|
|
703
|
+
: undefined,
|
|
704
|
+
requiresTrigger:
|
|
705
|
+
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
|
706
|
+
isMain: row.is_main === 1 ? true : undefined,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
return result;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// --- JSON migration ---
|
|
713
|
+
|
|
714
|
+
function migrateJsonState(): void {
|
|
715
|
+
const migrateFile = (filename: string) => {
|
|
716
|
+
const filePath = path.join(DATA_DIR, filename);
|
|
717
|
+
if (!fs.existsSync(filePath)) return null;
|
|
718
|
+
try {
|
|
719
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
720
|
+
fs.renameSync(filePath, `${filePath}.migrated`);
|
|
721
|
+
return data;
|
|
722
|
+
} catch {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// Migrate router_state.json
|
|
728
|
+
const routerState = migrateFile('router_state.json') as {
|
|
729
|
+
last_timestamp?: string;
|
|
730
|
+
last_agent_timestamp?: Record<string, string>;
|
|
731
|
+
} | null;
|
|
732
|
+
if (routerState) {
|
|
733
|
+
if (routerState.last_timestamp) {
|
|
734
|
+
setRouterState('last_timestamp', routerState.last_timestamp);
|
|
735
|
+
}
|
|
736
|
+
if (routerState.last_agent_timestamp) {
|
|
737
|
+
setRouterState(
|
|
738
|
+
'last_agent_timestamp',
|
|
739
|
+
JSON.stringify(routerState.last_agent_timestamp),
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Migrate sessions.json
|
|
745
|
+
const sessions = migrateFile('sessions.json') as Record<
|
|
746
|
+
string,
|
|
747
|
+
string
|
|
748
|
+
> | null;
|
|
749
|
+
if (sessions) {
|
|
750
|
+
for (const [folder, sessionId] of Object.entries(sessions)) {
|
|
751
|
+
setSession(folder, sessionId);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Migrate registered_groups.json
|
|
756
|
+
const groups = migrateFile('registered_groups.json') as Record<
|
|
757
|
+
string,
|
|
758
|
+
RegisteredGroup
|
|
759
|
+
> | null;
|
|
760
|
+
if (groups) {
|
|
761
|
+
for (const [jid, group] of Object.entries(groups)) {
|
|
762
|
+
try {
|
|
763
|
+
setRegisteredGroup(jid, group);
|
|
764
|
+
} catch (err) {
|
|
765
|
+
logger.warn(
|
|
766
|
+
{ jid, folder: group.folder, err },
|
|
767
|
+
'Skipping migrated registered group with invalid folder',
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// --- Questionnaire metric accessors ---
|
|
775
|
+
|
|
776
|
+
export interface QuestionnaireMetricInput {
|
|
777
|
+
user_email: string | null;
|
|
778
|
+
document_url: string | null;
|
|
779
|
+
document_id: string | null;
|
|
780
|
+
question: string;
|
|
781
|
+
answer: string | null;
|
|
782
|
+
response_time_ms: number;
|
|
783
|
+
was_answered: number;
|
|
784
|
+
answer_reference: string | null;
|
|
785
|
+
category: string | null;
|
|
786
|
+
confidence: string | null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export function findExactMatch(
|
|
790
|
+
question: string,
|
|
791
|
+
): { answer: string; citations: Array<{ source: string; title: string; url: string; answer_source: string }>; answer_source: string; confidence: string } | null {
|
|
792
|
+
const row = db
|
|
793
|
+
.prepare(
|
|
794
|
+
`SELECT answer, answer_reference, confidence
|
|
795
|
+
FROM questionnaire_metrics
|
|
796
|
+
WHERE question = ? AND was_answered = 1 AND is_expired = 0`,
|
|
797
|
+
)
|
|
798
|
+
.get(question) as
|
|
799
|
+
| { answer: string; answer_reference: string | null; confidence: string | null }
|
|
800
|
+
| undefined;
|
|
801
|
+
|
|
802
|
+
if (!row || !row.answer) return null;
|
|
803
|
+
|
|
804
|
+
return {
|
|
805
|
+
answer: row.answer,
|
|
806
|
+
citations: [],
|
|
807
|
+
answer_source: row.answer_reference || 'ai',
|
|
808
|
+
confidence: row.confidence || 'low',
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function saveQuestionnaireMetric(data: QuestionnaireMetricInput): void {
|
|
813
|
+
db.prepare(
|
|
814
|
+
`INSERT INTO questionnaire_metrics
|
|
815
|
+
(user_email, document_url, document_id, question, answer, response_time_ms, was_answered, answer_reference, category, confidence, updated_at)
|
|
816
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
817
|
+
ON CONFLICT(question) DO UPDATE SET
|
|
818
|
+
answer = excluded.answer,
|
|
819
|
+
response_time_ms = excluded.response_time_ms,
|
|
820
|
+
was_answered = excluded.was_answered,
|
|
821
|
+
answer_reference = excluded.answer_reference,
|
|
822
|
+
category = excluded.category,
|
|
823
|
+
confidence = excluded.confidence,
|
|
824
|
+
updated_at = CURRENT_TIMESTAMP`,
|
|
825
|
+
).run(
|
|
826
|
+
data.user_email,
|
|
827
|
+
data.document_url,
|
|
828
|
+
data.document_id,
|
|
829
|
+
data.question,
|
|
830
|
+
data.answer,
|
|
831
|
+
data.response_time_ms,
|
|
832
|
+
data.was_answered,
|
|
833
|
+
data.answer_reference,
|
|
834
|
+
data.category,
|
|
835
|
+
data.confidence,
|
|
836
|
+
);
|
|
837
|
+
}
|