@kin-tio/cli 0.6.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.
Files changed (66) hide show
  1. package/.env.example +46 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +202 -0
  4. package/README.md +150 -0
  5. package/README.zh-CN.md +79 -0
  6. package/THIRD_PARTY_NOTICES +31 -0
  7. package/assets/ilink-login-card.png +0 -0
  8. package/bin/kintio.js +3 -0
  9. package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
  10. package/dist/cli.js +3 -0
  11. package/dist/daemon.js +28 -0
  12. package/dist/index.js +70 -0
  13. package/dist/mcp-relay.js +11 -0
  14. package/dist/src/agent/runtime.js +1 -0
  15. package/dist/src/app.js +34 -0
  16. package/dist/src/cli.js +578 -0
  17. package/dist/src/config.js +237 -0
  18. package/dist/src/domain/message.js +23 -0
  19. package/dist/src/domain/send-contract.js +205 -0
  20. package/dist/src/domain/wecom-message.js +281 -0
  21. package/dist/src/ilink/executor.js +306 -0
  22. package/dist/src/ilink/inbound-image.js +310 -0
  23. package/dist/src/ilink/listener.js +306 -0
  24. package/dist/src/ilink/login-manager.js +198 -0
  25. package/dist/src/ilink/login-store.js +197 -0
  26. package/dist/src/ilink/media-gateway.js +83 -0
  27. package/dist/src/ilink/media.js +267 -0
  28. package/dist/src/ilink/message.js +247 -0
  29. package/dist/src/ilink/protocol/client.js +464 -0
  30. package/dist/src/ilink/protocol/types.js +35 -0
  31. package/dist/src/ilink/qr.js +109 -0
  32. package/dist/src/ilink/secret-box.js +143 -0
  33. package/dist/src/ilink/sqlite-store.js +1194 -0
  34. package/dist/src/ilink/store-types.js +63 -0
  35. package/dist/src/lib/image-format.js +23 -0
  36. package/dist/src/lib/path-identity.js +38 -0
  37. package/dist/src/lib/private-directory.js +51 -0
  38. package/dist/src/lib/text.js +19 -0
  39. package/dist/src/lib/wecom-crypto.js +74 -0
  40. package/dist/src/lib/xml.js +8 -0
  41. package/dist/src/mcp/conversation-memory-server.js +179 -0
  42. package/dist/src/mcp/ilink-server.js +158 -0
  43. package/dist/src/mcp/ipc-host.js +275 -0
  44. package/dist/src/mcp/ipc-protocol.js +226 -0
  45. package/dist/src/mcp/stdio-relay.js +122 -0
  46. package/dist/src/mcp/wechat-kf-executor.js +295 -0
  47. package/dist/src/mcp/wechat-kf-server.js +208 -0
  48. package/dist/src/routes/wecom.js +89 -0
  49. package/dist/src/runtime/daemon-protocol.js +202 -0
  50. package/dist/src/runtime/managed-skill.js +49 -0
  51. package/dist/src/runtime/native-daemon.js +325 -0
  52. package/dist/src/runtime/single-instance-lock.js +167 -0
  53. package/dist/src/runtime.js +503 -0
  54. package/dist/src/services/codex-agent.js +542 -0
  55. package/dist/src/services/codex-app-server.js +436 -0
  56. package/dist/src/services/conversation-processor.js +762 -0
  57. package/dist/src/services/image-stager.js +49 -0
  58. package/dist/src/services/media-gateway.js +83 -0
  59. package/dist/src/services/wecom-api.js +311 -0
  60. package/dist/src/services/wecom-sync.js +316 -0
  61. package/dist/src/state/persistence.js +124 -0
  62. package/dist/src/state/sqlite-store.js +3102 -0
  63. package/dist/src/supervisor.js +212 -0
  64. package/dist/src/types.js +1 -0
  65. package/dist/src/version.js +1 -0
  66. package/package.json +72 -0
@@ -0,0 +1,3102 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { MAX_WECHAT_IMAGE_BYTES, detectImageFormat, } from '../lib/image-format.js';
5
+ import { ensurePrivateDirectory } from '../lib/private-directory.js';
6
+ const SCHEMA_VERSION = 22;
7
+ const INBOUND_STATUSES = [
8
+ 'received',
9
+ 'processing',
10
+ 'preparing',
11
+ 'ready',
12
+ 'completed',
13
+ 'steering',
14
+ 'steered',
15
+ 'absorbed',
16
+ 'failed',
17
+ 'ignored',
18
+ 'suppressed',
19
+ ];
20
+ const SEND_STATUSES = [
21
+ 'pending',
22
+ 'sending',
23
+ 'accepted',
24
+ 'failed',
25
+ 'uncertain',
26
+ ];
27
+ function rowAs(row) {
28
+ return row === undefined ? undefined : row;
29
+ }
30
+ function rowsAs(rows) {
31
+ return rows;
32
+ }
33
+ function errorMessage(error) {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
36
+ /** @internal Persistence lifecycle helper shared by initialization and close. */
37
+ export function secureSqliteFiles(filePath) {
38
+ for (const candidate of [filePath, `${filePath}-wal`, `${filePath}-shm`]) {
39
+ try {
40
+ fs.chmodSync(candidate, 0o600);
41
+ }
42
+ catch (error) {
43
+ if (errorCode(error) !== 'ENOENT')
44
+ throw error;
45
+ }
46
+ }
47
+ }
48
+ function errorCode(error) {
49
+ if (!error || typeof error !== 'object' || !('code' in error))
50
+ return '';
51
+ return String(error.code ?? '');
52
+ }
53
+ function sqlList(values) {
54
+ return values.map((value) => `'${value}'`).join(',');
55
+ }
56
+ function sha256(value) {
57
+ return createHash('sha256').update(value).digest('hex');
58
+ }
59
+ function requiredText(value, name) {
60
+ const text = String(value || '');
61
+ if (!text)
62
+ throw new Error(`${name} is required`);
63
+ return text;
64
+ }
65
+ function canonicalValue(value) {
66
+ if (value === undefined)
67
+ return null;
68
+ if (value === null ||
69
+ typeof value === 'string' ||
70
+ typeof value === 'boolean') {
71
+ return value;
72
+ }
73
+ if (typeof value === 'number') {
74
+ if (!Number.isFinite(value))
75
+ throw new Error('JSON numbers must be finite');
76
+ return value;
77
+ }
78
+ if (Array.isArray(value))
79
+ return value.map(canonicalValue);
80
+ if (typeof value !== 'object' || Buffer.isBuffer(value)) {
81
+ throw new Error(`Unsupported JSON value: ${typeof value}`);
82
+ }
83
+ const source = value;
84
+ const output = {};
85
+ for (const key of Object.keys(source).sort()) {
86
+ if (source[key] !== undefined)
87
+ output[key] = canonicalValue(source[key]);
88
+ }
89
+ return output;
90
+ }
91
+ function encodeJson(value) {
92
+ if (value === undefined || value === null)
93
+ return null;
94
+ return JSON.stringify(canonicalValue(value));
95
+ }
96
+ function decodeJson(value) {
97
+ if (value === undefined || value === null || value === '')
98
+ return undefined;
99
+ return JSON.parse(value);
100
+ }
101
+ function objectJson(value) {
102
+ const decoded = decodeJson(value);
103
+ return decoded && !Array.isArray(decoded) && typeof decoded === 'object'
104
+ ? decoded
105
+ : undefined;
106
+ }
107
+ function inboundPayload(row) {
108
+ const payload = objectJson(row.payload_json);
109
+ if (!payload)
110
+ return undefined;
111
+ const legacyProviderMessageId = payload.id;
112
+ const { id: _legacyId, messageKey: _legacyMessageKey, ...normalized } = payload;
113
+ return {
114
+ ...normalized,
115
+ providerMessageId: String(normalized.providerMessageId || legacyProviderMessageId || row.msgid),
116
+ };
117
+ }
118
+ function mapInbound(row) {
119
+ if (!row)
120
+ return undefined;
121
+ const payload = inboundPayload(row);
122
+ return {
123
+ inboxSeq: row.inbox_seq,
124
+ messageKey: row.message_key,
125
+ channel: row.channel,
126
+ accountKey: row.open_kfid,
127
+ providerMessageId: row.msgid,
128
+ peerId: row.external_userid,
129
+ origin: row.origin,
130
+ type: row.msg_type,
131
+ sentAt: row.sent_at,
132
+ status: row.status,
133
+ deferred: row.deferred === 1,
134
+ primaryMessageKey: row.primary_message_key || '',
135
+ ...(payload ? { payload } : {}),
136
+ codexTurnId: row.codex_turn_id,
137
+ clientInputId: row.client_input_id,
138
+ errorMessage: row.error_message,
139
+ createdAt: row.created_at,
140
+ updatedAt: row.updated_at,
141
+ };
142
+ }
143
+ function mapConversation(row) {
144
+ if (!row)
145
+ return undefined;
146
+ return {
147
+ channel: row.channel,
148
+ accountKey: row.open_kfid,
149
+ peerId: row.external_userid,
150
+ threadId: row.thread_id,
151
+ memoryThreadId: row.memory_thread_id,
152
+ updatedAt: row.updated_at,
153
+ };
154
+ }
155
+ function mapAttempt(row) {
156
+ if (!row)
157
+ return undefined;
158
+ const payload = objectJson(row.payload_json);
159
+ const metadata = objectJson(row.metadata_json);
160
+ return {
161
+ attemptId: row.attempt_key,
162
+ messageKey: row.source_message_key,
163
+ channel: row.channel,
164
+ accountKey: row.open_kfid,
165
+ peerId: row.external_userid,
166
+ replyWindowId: Number(row.reply_window_id || 0),
167
+ sendIndex: row.send_index,
168
+ source: row.source,
169
+ type: row.sent_type,
170
+ ...(payload ? { payload } : {}),
171
+ ...(metadata ? { metadata } : {}),
172
+ fingerprint: row.fingerprint,
173
+ clientMessageId: row.client_message_id,
174
+ status: row.status,
175
+ providerMessageId: row.wecom_msgid,
176
+ errorCode: row.error_code,
177
+ errorMessage: row.error_message,
178
+ failType: row.fail_type,
179
+ createdAt: row.created_at,
180
+ updatedAt: row.updated_at,
181
+ };
182
+ }
183
+ function mapSessionMedia(value, row) {
184
+ if (!Array.isArray(value)) {
185
+ throw new AgentSessionError('Agent session media catalog is invalid');
186
+ }
187
+ return value.map((item) => {
188
+ if (!item || Array.isArray(item) || typeof item !== 'object') {
189
+ throw new AgentSessionError('Agent session media catalog is invalid');
190
+ }
191
+ const legacy = item;
192
+ const channel = String(legacy.channel || row.channel);
193
+ const accountKey = String(legacy.accountKey || legacy.openKfId || row.open_kfid);
194
+ const peerId = String(legacy.peerId || legacy.externalUserId || row.external_userid);
195
+ if (channel !== row.channel ||
196
+ accountKey !== row.open_kfid ||
197
+ peerId !== row.external_userid) {
198
+ throw new AgentSessionError('Agent session media identity is invalid');
199
+ }
200
+ const { openKfId: _legacyAccountKey, externalUserId: _legacyPeerId, ...normalized } = legacy;
201
+ return {
202
+ ...normalized,
203
+ channel: row.channel,
204
+ accountKey,
205
+ peerId,
206
+ };
207
+ });
208
+ }
209
+ function mapAgentSession(row, token) {
210
+ if (!row)
211
+ return undefined;
212
+ const media = decodeJson(row.media_json);
213
+ return {
214
+ token,
215
+ messageKey: row.source_message_key,
216
+ channel: row.channel,
217
+ accountKey: row.open_kfid,
218
+ peerId: row.external_userid,
219
+ replyWindowId: Number(row.reply_window_id || 0),
220
+ boundaryInboxSeq: row.boundary_inbox_seq,
221
+ memoryThreadId: row.memory_thread_id,
222
+ mediaCatalog: mapSessionMedia(media, row),
223
+ expiresAt: row.expires_at,
224
+ closedAt: row.closed_at,
225
+ createdAt: row.created_at,
226
+ updatedAt: row.updated_at,
227
+ };
228
+ }
229
+ function inboundInsert(accountKey, message) {
230
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
231
+ throw new Error('Inbound message must be an object');
232
+ }
233
+ if (message.conversation.accountKey !== accountKey) {
234
+ throw new Error('Inbound message account does not match its sync source');
235
+ }
236
+ const channel = message.conversation.channel;
237
+ const msgid = requiredText(message.providerMessageId, 'providerMessageId');
238
+ return {
239
+ messageKey: stableMessageKey(channel, accountKey, msgid),
240
+ accountKey,
241
+ providerMessageId: msgid,
242
+ peerId: requiredText(message.conversation.peerId, 'peerId'),
243
+ channel,
244
+ origin: String(message.origin || 'unknown'),
245
+ type: String(message.type || 'unknown'),
246
+ sentAt: Number(message.sentAt || 0),
247
+ status: 'received',
248
+ payload: message,
249
+ };
250
+ }
251
+ export function stableMessageKey(channel, accountKey, providerMessageId) {
252
+ const selectedChannel = requiredText(channel, 'channel');
253
+ if (!['wechat_kf', 'weixin_ilink'].includes(selectedChannel)) {
254
+ throw new Error(`Unsupported chat channel: ${selectedChannel}`);
255
+ }
256
+ const service = requiredText(accountKey, 'accountKey');
257
+ const message = requiredText(providerMessageId, 'providerMessageId');
258
+ return `im_${sha256(`${selectedChannel}\0${service}\0${message}`).slice(0, 40)}`;
259
+ }
260
+ export function stableClientMessageId(messageKey, sendIndex) {
261
+ return `wb_${sha256(`${requiredText(messageKey, 'messageKey')}\0${sendIndex}`).slice(0, 29)}`;
262
+ }
263
+ function stableAttemptKey(messageKey, sendIndex) {
264
+ return `sa_${sha256(`${messageKey}\0${sendIndex}`).slice(0, 29)}`;
265
+ }
266
+ export class CursorConflictError extends Error {
267
+ code = 'cursor_conflict';
268
+ expected;
269
+ actual;
270
+ constructor(accountKey, expected, actual) {
271
+ super(`Cursor conflict for ${accountKey}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
272
+ this.name = 'CursorConflictError';
273
+ this.expected = expected;
274
+ this.actual = actual;
275
+ }
276
+ }
277
+ class SendInvariantError extends Error {
278
+ code;
279
+ constructor(message, code = 'send_fingerprint_conflict') {
280
+ super(message);
281
+ this.name = 'SendInvariantError';
282
+ this.code = code;
283
+ }
284
+ }
285
+ export class AgentSessionError extends Error {
286
+ code;
287
+ constructor(message, code = 'invalid_agent_session') {
288
+ super(message);
289
+ this.name = 'AgentSessionError';
290
+ this.code = code;
291
+ }
292
+ }
293
+ export class SqliteStore {
294
+ filePath;
295
+ #database;
296
+ clock;
297
+ constructor({ filePath, clock = Date.now, journalMode = 'WAL', }, internal) {
298
+ if (!filePath)
299
+ throw new Error('SQLite filePath is required');
300
+ if (!['WAL', 'DELETE'].includes(journalMode)) {
301
+ throw new Error(`Unsupported SQLite journal mode: ${journalMode}`);
302
+ }
303
+ this.filePath = path.resolve(filePath);
304
+ this.clock = clock;
305
+ const databaseDirectory = path.dirname(this.filePath);
306
+ ensurePrivateDirectory(databaseDirectory);
307
+ this.#database = internal.database;
308
+ fs.chmodSync(this.filePath, 0o600);
309
+ this.#database.exec('PRAGMA busy_timeout = 5000');
310
+ this.#database.exec(`PRAGMA journal_mode = ${journalMode}`);
311
+ this.#database.exec('PRAGMA synchronous = FULL');
312
+ this.#database.exec('PRAGMA foreign_keys = ON');
313
+ this.#initializeSchema();
314
+ secureSqliteFiles(this.filePath);
315
+ }
316
+ #now() {
317
+ return Number(this.clock());
318
+ }
319
+ #initializeSchema() {
320
+ const versionRow = rowAs(this.#database.prepare('PRAGMA user_version').get());
321
+ let version = Number(versionRow?.user_version ?? 0);
322
+ if (version > SCHEMA_VERSION) {
323
+ throw new Error(`SQLite schema version ${version} is newer than supported version ${SCHEMA_VERSION}`);
324
+ }
325
+ if (version === SCHEMA_VERSION)
326
+ return;
327
+ if (version !== 0 && version !== 11 && version !== 12 &&
328
+ version !== 13 && version !== 14 && version !== 15 &&
329
+ version !== 16 && version !== 17 && version !== 18 && version !== 19 &&
330
+ version !== 20 && version !== 21) {
331
+ throw new Error(`SQLite schema version ${version} is no longer supported; migrate to version 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, or 21 first`);
332
+ }
333
+ if (version === 11) {
334
+ this.#database.exec(`
335
+ PRAGMA foreign_keys = OFF;
336
+ PRAGMA legacy_alter_table = ON;
337
+ BEGIN IMMEDIATE;
338
+ `);
339
+ try {
340
+ this.#database.exec(`
341
+ CREATE TEMP TABLE stale_v11_sources (
342
+ message_key TEXT PRIMARY KEY
343
+ ) STRICT;
344
+ INSERT INTO stale_v11_sources (message_key)
345
+ SELECT inbound.message_key
346
+ FROM inbound_messages AS inbound
347
+ JOIN conversations AS conversation
348
+ ON conversation.open_kfid = inbound.open_kfid
349
+ AND conversation.external_userid = inbound.external_userid
350
+ WHERE inbound.primary_message_key IS NULL
351
+ AND (
352
+ (
353
+ conversation.mode = 'human'
354
+ AND inbound.status IN (
355
+ 'received', 'failed', 'processing', 'preparing', 'ready'
356
+ )
357
+ )
358
+ OR (
359
+ inbound.status IN ('failed', 'processing', 'preparing', 'ready')
360
+ AND inbound.claimed_conversation_epoch <>
361
+ conversation.automation_epoch
362
+ )
363
+ );
364
+ UPDATE inbound_messages
365
+ SET status = 'suppressed', payload_json = NULL,
366
+ error_message = 'retired_conversation_state'
367
+ WHERE (
368
+ message_key IN (SELECT message_key FROM stale_v11_sources)
369
+ OR primary_message_key IN (
370
+ SELECT message_key FROM stale_v11_sources
371
+ )
372
+ )
373
+ AND status NOT IN ('completed', 'ignored', 'absorbed');
374
+ UPDATE send_attempts
375
+ SET status = 'failed', error_code = 'suppressed',
376
+ error_message = 'retired_conversation_state'
377
+ WHERE source_message_key IN (
378
+ SELECT message_key FROM stale_v11_sources
379
+ )
380
+ AND status = 'pending';
381
+
382
+ UPDATE inbound_messages
383
+ SET status = 'ignored', payload_json = NULL
384
+ WHERE status = 'held';
385
+ UPDATE inbound_messages
386
+ SET payload_json = NULL
387
+ WHERE status = 'absorbed';
388
+
389
+ DROP INDEX inbound_pending_idx;
390
+ DROP INDEX inbound_primary_idx;
391
+ ALTER TABLE inbound_messages RENAME TO inbound_messages_v11;
392
+ CREATE TABLE inbound_messages (
393
+ inbox_seq INTEGER PRIMARY KEY AUTOINCREMENT,
394
+ message_key TEXT NOT NULL UNIQUE,
395
+ open_kfid TEXT NOT NULL,
396
+ msgid TEXT NOT NULL,
397
+ external_userid TEXT NOT NULL DEFAULT '',
398
+ origin TEXT NOT NULL,
399
+ msg_type TEXT NOT NULL,
400
+ sent_at INTEGER NOT NULL DEFAULT 0,
401
+ status TEXT NOT NULL CHECK (status IN (${sqlList(INBOUND_STATUSES)})),
402
+ primary_message_key TEXT,
403
+ payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
404
+ codex_turn_id TEXT NOT NULL DEFAULT '',
405
+ client_input_id TEXT NOT NULL DEFAULT '',
406
+ steering_boundary INTEGER NOT NULL DEFAULT 0,
407
+ error_message TEXT NOT NULL DEFAULT '',
408
+ created_at INTEGER NOT NULL,
409
+ updated_at INTEGER NOT NULL,
410
+ UNIQUE (open_kfid, msgid),
411
+ UNIQUE (message_key, open_kfid, external_userid),
412
+ FOREIGN KEY (primary_message_key)
413
+ REFERENCES inbound_messages(message_key)
414
+ ) STRICT;
415
+ INSERT INTO inbound_messages (
416
+ inbox_seq, message_key, open_kfid, msgid, external_userid,
417
+ origin, msg_type, sent_at, status, primary_message_key,
418
+ payload_json, codex_turn_id, client_input_id, steering_boundary,
419
+ error_message, created_at, updated_at
420
+ )
421
+ SELECT
422
+ inbox_seq, message_key, open_kfid, msgid, external_userid,
423
+ origin, msg_type, sent_at, status, primary_message_key,
424
+ payload_json, codex_turn_id, client_input_id, steering_boundary,
425
+ error_message, created_at, updated_at
426
+ FROM inbound_messages_v11;
427
+ DROP TABLE inbound_messages_v11;
428
+ CREATE INDEX inbound_pending_idx
429
+ ON inbound_messages(status, open_kfid, external_userid, inbox_seq);
430
+ CREATE INDEX inbound_primary_idx
431
+ ON inbound_messages(primary_message_key, inbox_seq);
432
+
433
+ DROP INDEX conversation_thread_idx;
434
+ ALTER TABLE conversations RENAME TO conversations_v11;
435
+ CREATE TABLE conversations (
436
+ open_kfid TEXT NOT NULL,
437
+ external_userid TEXT NOT NULL,
438
+ thread_id TEXT NOT NULL DEFAULT '',
439
+ updated_at INTEGER NOT NULL,
440
+ PRIMARY KEY (open_kfid, external_userid)
441
+ ) STRICT, WITHOUT ROWID;
442
+ INSERT INTO conversations (
443
+ open_kfid, external_userid, thread_id, updated_at
444
+ )
445
+ SELECT open_kfid, external_userid, thread_id, updated_at
446
+ FROM conversations_v11;
447
+ DROP TABLE conversations_v11;
448
+ CREATE UNIQUE INDEX conversation_thread_idx
449
+ ON conversations(thread_id) WHERE thread_id <> '';
450
+
451
+ DROP INDEX agent_session_source_idx;
452
+ ALTER TABLE agent_sessions RENAME TO agent_sessions_v11;
453
+ CREATE TABLE agent_sessions (
454
+ token_hash TEXT PRIMARY KEY,
455
+ source_message_key TEXT NOT NULL,
456
+ open_kfid TEXT NOT NULL,
457
+ external_userid TEXT NOT NULL,
458
+ boundary_inbox_seq INTEGER NOT NULL CHECK (boundary_inbox_seq >= 0),
459
+ media_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(media_json)),
460
+ expires_at INTEGER NOT NULL,
461
+ closed_at INTEGER NOT NULL DEFAULT 0,
462
+ created_at INTEGER NOT NULL,
463
+ updated_at INTEGER NOT NULL,
464
+ FOREIGN KEY (source_message_key, open_kfid, external_userid)
465
+ REFERENCES inbound_messages(
466
+ message_key, open_kfid, external_userid
467
+ ) ON DELETE CASCADE
468
+ ) STRICT;
469
+ INSERT INTO agent_sessions (
470
+ token_hash, source_message_key, open_kfid, external_userid,
471
+ boundary_inbox_seq, media_json, expires_at, closed_at,
472
+ created_at, updated_at
473
+ )
474
+ SELECT
475
+ token_hash, source_message_key, open_kfid, external_userid,
476
+ boundary_inbox_seq, media_json, expires_at,
477
+ CASE WHEN closed_at = 0 THEN MAX(updated_at, 1) ELSE closed_at END,
478
+ created_at, updated_at
479
+ FROM agent_sessions_v11;
480
+ DROP TABLE agent_sessions_v11;
481
+ CREATE INDEX agent_session_source_idx
482
+ ON agent_sessions(source_message_key, closed_at, expires_at);
483
+ DROP TABLE stale_v11_sources;
484
+
485
+ PRAGMA user_version = 12;
486
+ `);
487
+ const violations = this.foreignKeyCheck();
488
+ if (violations.length) {
489
+ throw new Error('SQLite migration v12 created foreign-key violations');
490
+ }
491
+ this.#database.exec('COMMIT');
492
+ version = 12;
493
+ }
494
+ catch (error) {
495
+ this.#database.exec('ROLLBACK');
496
+ throw error;
497
+ }
498
+ finally {
499
+ this.#database.exec(`
500
+ PRAGMA legacy_alter_table = OFF;
501
+ PRAGMA foreign_keys = ON;
502
+ `);
503
+ }
504
+ }
505
+ if (version === 12) {
506
+ this.#database.exec('BEGIN IMMEDIATE');
507
+ try {
508
+ this.#database.exec(`
509
+ ALTER TABLE inbound_messages
510
+ ADD COLUMN deferred INTEGER NOT NULL DEFAULT 0
511
+ CHECK (deferred IN (0, 1));
512
+ CREATE INDEX inbound_deferred_idx
513
+ ON inbound_messages(deferred, status, inbox_seq);
514
+ PRAGMA user_version = 13;
515
+ COMMIT;
516
+ `);
517
+ version = 13;
518
+ }
519
+ catch (error) {
520
+ this.#database.exec('ROLLBACK');
521
+ throw error;
522
+ }
523
+ }
524
+ if (version === 13) {
525
+ this.#database.exec('BEGIN IMMEDIATE');
526
+ try {
527
+ this.#database.exec(`
528
+ ALTER TABLE conversations
529
+ ADD COLUMN memory_thread_id TEXT NOT NULL DEFAULT '';
530
+ ALTER TABLE agent_sessions
531
+ ADD COLUMN memory_thread_id TEXT NOT NULL DEFAULT '';
532
+ PRAGMA user_version = 14;
533
+ COMMIT;
534
+ `);
535
+ version = 14;
536
+ }
537
+ catch (error) {
538
+ this.#database.exec('ROLLBACK');
539
+ throw error;
540
+ }
541
+ }
542
+ if (version === 14) {
543
+ this.#database.exec('BEGIN IMMEDIATE');
544
+ try {
545
+ this.#database.exec(`
546
+ CREATE TABLE IF NOT EXISTS maintainer_binding (
547
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
548
+ open_kfid TEXT NOT NULL,
549
+ external_userid TEXT NOT NULL,
550
+ bound_message_key TEXT NOT NULL,
551
+ bound_at INTEGER NOT NULL,
552
+ updated_at INTEGER NOT NULL
553
+ ) STRICT;
554
+ PRAGMA user_version = 15;
555
+ COMMIT;
556
+ `);
557
+ version = 15;
558
+ }
559
+ catch (error) {
560
+ this.#database.exec('ROLLBACK');
561
+ throw error;
562
+ }
563
+ }
564
+ if (version === 15) {
565
+ this.#database.exec('BEGIN IMMEDIATE');
566
+ try {
567
+ this.#database.exec(`
568
+ ALTER TABLE inbound_messages
569
+ ADD COLUMN channel TEXT NOT NULL DEFAULT 'wechat_kf'
570
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink'));
571
+ ALTER TABLE agent_sessions
572
+ ADD COLUMN channel TEXT NOT NULL DEFAULT 'wechat_kf'
573
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink'));
574
+ ALTER TABLE agent_sessions
575
+ ADD COLUMN reply_window_id INTEGER;
576
+
577
+ CREATE TABLE ilink_accounts (
578
+ account_key TEXT PRIMARY KEY,
579
+ provider_account_id TEXT NOT NULL UNIQUE,
580
+ owner_peer_id TEXT NOT NULL,
581
+ base_url TEXT NOT NULL,
582
+ generation INTEGER NOT NULL DEFAULT 1 CHECK (generation > 0),
583
+ status TEXT NOT NULL DEFAULT 'active'
584
+ CHECK (status IN ('active', 'paused', 'disabled', 'revoked')),
585
+ pause_until INTEGER NOT NULL DEFAULT 0,
586
+ cursor TEXT NOT NULL DEFAULT '',
587
+ cursor_updated_at INTEGER NOT NULL DEFAULT 0,
588
+ created_at INTEGER NOT NULL,
589
+ updated_at INTEGER NOT NULL
590
+ ) STRICT, WITHOUT ROWID;
591
+ CREATE UNIQUE INDEX ilink_active_owner_idx
592
+ ON ilink_accounts(owner_peer_id)
593
+ WHERE status IN ('active', 'paused');
594
+
595
+ CREATE TABLE ilink_account_secrets (
596
+ account_key TEXT PRIMARY KEY,
597
+ account_generation INTEGER NOT NULL CHECK (account_generation > 0),
598
+ nonce TEXT NOT NULL,
599
+ ciphertext TEXT NOT NULL,
600
+ auth_tag TEXT NOT NULL,
601
+ updated_at INTEGER NOT NULL,
602
+ FOREIGN KEY (account_key) REFERENCES ilink_accounts(account_key)
603
+ ON DELETE CASCADE
604
+ ) STRICT, WITHOUT ROWID;
605
+
606
+ CREATE TABLE ilink_reply_windows (
607
+ reply_window_id INTEGER PRIMARY KEY AUTOINCREMENT,
608
+ account_key TEXT NOT NULL,
609
+ peer_id TEXT NOT NULL,
610
+ account_generation INTEGER NOT NULL CHECK (account_generation > 0),
611
+ source_message_key TEXT NOT NULL UNIQUE,
612
+ source_inbox_seq INTEGER NOT NULL CHECK (source_inbox_seq > 0),
613
+ provider_seq INTEGER,
614
+ issued_at INTEGER NOT NULL,
615
+ expires_at INTEGER NOT NULL CHECK (expires_at > issued_at),
616
+ max_sends INTEGER NOT NULL DEFAULT 10
617
+ CHECK (max_sends BETWEEN 1 AND 10),
618
+ next_send_index INTEGER NOT NULL DEFAULT 0
619
+ CHECK (next_send_index >= 0),
620
+ reserved_send_count INTEGER NOT NULL DEFAULT 0
621
+ CHECK (reserved_send_count >= 0),
622
+ transmitted_send_count INTEGER NOT NULL DEFAULT 0
623
+ CHECK (transmitted_send_count >= 0),
624
+ state TEXT NOT NULL DEFAULT 'open'
625
+ CHECK (state IN ('open', 'superseded', 'closed', 'cancelled')),
626
+ secret_generation INTEGER NOT NULL CHECK (secret_generation >= 0),
627
+ created_at INTEGER NOT NULL,
628
+ updated_at INTEGER NOT NULL,
629
+ FOREIGN KEY (account_key) REFERENCES ilink_accounts(account_key),
630
+ FOREIGN KEY (source_message_key) REFERENCES inbound_messages(message_key),
631
+ CHECK (reserved_send_count + transmitted_send_count <= max_sends)
632
+ ) STRICT;
633
+ CREATE UNIQUE INDEX ilink_one_open_window_idx
634
+ ON ilink_reply_windows(account_key, peer_id) WHERE state = 'open';
635
+
636
+ CREATE TABLE ilink_reply_window_secrets (
637
+ reply_window_id INTEGER PRIMARY KEY,
638
+ nonce TEXT NOT NULL,
639
+ ciphertext TEXT NOT NULL,
640
+ auth_tag TEXT NOT NULL,
641
+ updated_at INTEGER NOT NULL,
642
+ FOREIGN KEY (reply_window_id)
643
+ REFERENCES ilink_reply_windows(reply_window_id) ON DELETE CASCADE
644
+ ) STRICT, WITHOUT ROWID;
645
+
646
+ DROP INDEX send_status_idx;
647
+ DROP INDEX send_wecom_msgid_idx;
648
+ DROP INDEX send_conversation_idx;
649
+ ALTER TABLE send_attempts RENAME TO send_attempts_v15;
650
+ CREATE TABLE send_attempts (
651
+ attempt_key TEXT PRIMARY KEY,
652
+ source_message_key TEXT NOT NULL,
653
+ open_kfid TEXT NOT NULL,
654
+ external_userid TEXT NOT NULL,
655
+ channel TEXT NOT NULL DEFAULT 'wechat_kf'
656
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
657
+ reply_window_id INTEGER,
658
+ send_index INTEGER NOT NULL CHECK (send_index >= 0 AND send_index < 1000),
659
+ source TEXT NOT NULL,
660
+ sent_type TEXT NOT NULL,
661
+ payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
662
+ metadata_json TEXT CHECK (metadata_json IS NULL OR json_valid(metadata_json)),
663
+ fingerprint TEXT NOT NULL,
664
+ client_message_id TEXT NOT NULL UNIQUE,
665
+ status TEXT NOT NULL CHECK (status IN (${sqlList(SEND_STATUSES)})),
666
+ wecom_msgid TEXT NOT NULL DEFAULT '',
667
+ error_code TEXT NOT NULL DEFAULT '',
668
+ error_message TEXT NOT NULL DEFAULT '',
669
+ fail_type INTEGER NOT NULL DEFAULT 0,
670
+ created_at INTEGER NOT NULL,
671
+ updated_at INTEGER NOT NULL,
672
+ UNIQUE (source_message_key, send_index),
673
+ FOREIGN KEY (source_message_key, open_kfid, external_userid)
674
+ REFERENCES inbound_messages(message_key, open_kfid, external_userid),
675
+ FOREIGN KEY (reply_window_id)
676
+ REFERENCES ilink_reply_windows(reply_window_id)
677
+ ) STRICT;
678
+ INSERT INTO send_attempts (
679
+ attempt_key, source_message_key, open_kfid, external_userid,
680
+ channel, reply_window_id, send_index, source, sent_type,
681
+ payload_json, metadata_json, fingerprint, client_message_id,
682
+ status, wecom_msgid, error_code, error_message, fail_type,
683
+ created_at, updated_at
684
+ )
685
+ SELECT
686
+ attempt_key, source_message_key, open_kfid, external_userid,
687
+ 'wechat_kf', NULL, send_index, source, sent_type,
688
+ payload_json, metadata_json, fingerprint, client_message_id,
689
+ status, wecom_msgid, error_code, error_message, fail_type,
690
+ created_at, updated_at
691
+ FROM send_attempts_v15;
692
+ DROP TABLE send_attempts_v15;
693
+ CREATE INDEX send_status_idx
694
+ ON send_attempts(channel, status, created_at, send_index);
695
+ CREATE UNIQUE INDEX send_wecom_msgid_idx
696
+ ON send_attempts(wecom_msgid) WHERE wecom_msgid <> '';
697
+ CREATE INDEX send_conversation_idx
698
+ ON send_attempts(open_kfid, external_userid, updated_at DESC);
699
+
700
+ PRAGMA user_version = 16;
701
+ COMMIT;
702
+ `);
703
+ version = 16;
704
+ }
705
+ catch (error) {
706
+ this.#database.exec('ROLLBACK');
707
+ throw error;
708
+ }
709
+ }
710
+ if (version === 16) {
711
+ this.#database.exec('BEGIN IMMEDIATE');
712
+ try {
713
+ this.#database.exec(`
714
+ CREATE TABLE ilink_login_offers (
715
+ offer_id TEXT PRIMARY KEY,
716
+ source_message_key TEXT NOT NULL,
717
+ source_open_kfid TEXT NOT NULL,
718
+ source_external_userid TEXT NOT NULL,
719
+ secret_generation INTEGER NOT NULL CHECK (secret_generation >= 0),
720
+ nonce TEXT NOT NULL,
721
+ ciphertext TEXT NOT NULL,
722
+ auth_tag TEXT NOT NULL,
723
+ api_base_url TEXT NOT NULL,
724
+ status TEXT NOT NULL DEFAULT 'waiting'
725
+ CHECK (status IN (
726
+ 'waiting', 'scanned', 'confirmed', 'expired', 'failed', 'cancelled'
727
+ )),
728
+ expires_at INTEGER NOT NULL,
729
+ last_polled_at INTEGER NOT NULL DEFAULT 0,
730
+ error_code TEXT NOT NULL DEFAULT '',
731
+ created_at INTEGER NOT NULL,
732
+ updated_at INTEGER NOT NULL
733
+ ) STRICT, WITHOUT ROWID;
734
+ CREATE UNIQUE INDEX ilink_one_pending_offer_idx
735
+ ON ilink_login_offers(source_open_kfid, source_external_userid)
736
+ WHERE status IN ('waiting', 'scanned');
737
+ PRAGMA user_version = 17;
738
+ COMMIT;
739
+ `);
740
+ version = 17;
741
+ }
742
+ catch (error) {
743
+ this.#database.exec('ROLLBACK');
744
+ throw error;
745
+ }
746
+ }
747
+ if (version === 17) {
748
+ this.#database.exec('BEGIN IMMEDIATE');
749
+ try {
750
+ this.#database.exec(`
751
+ CREATE TABLE IF NOT EXISTS ilink_enrollment_audit (
752
+ offer_id TEXT PRIMARY KEY,
753
+ source_message_key TEXT NOT NULL,
754
+ source_open_kfid TEXT NOT NULL,
755
+ source_external_userid TEXT NOT NULL,
756
+ account_key TEXT NOT NULL DEFAULT '',
757
+ result TEXT NOT NULL
758
+ CHECK (result IN ('confirmed', 'expired', 'failed', 'cancelled')),
759
+ offered_at INTEGER NOT NULL,
760
+ completed_at INTEGER NOT NULL
761
+ ) STRICT, WITHOUT ROWID;
762
+
763
+ DROP TRIGGER IF EXISTS ilink_session_window_insert_guard;
764
+ DROP TRIGGER IF EXISTS ilink_session_window_update_guard;
765
+ DROP TRIGGER IF EXISTS ilink_attempt_window_insert_guard;
766
+ DROP TRIGGER IF EXISTS ilink_attempt_window_update_guard;
767
+ DROP TRIGGER IF EXISTS ilink_window_source_insert_guard;
768
+ DROP TRIGGER IF EXISTS ilink_window_source_update_guard;
769
+ DROP TRIGGER IF EXISTS ilink_window_delete_guard;
770
+
771
+ CREATE TRIGGER ilink_session_window_insert_guard
772
+ BEFORE INSERT ON agent_sessions
773
+ WHEN (
774
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
775
+ (NEW.channel = 'weixin_ilink' AND (
776
+ NEW.reply_window_id IS NULL OR NOT EXISTS (
777
+ SELECT 1 FROM ilink_reply_windows AS window
778
+ WHERE window.reply_window_id = NEW.reply_window_id
779
+ AND window.account_key = NEW.open_kfid
780
+ AND window.peer_id = NEW.external_userid
781
+ AND window.source_inbox_seq = NEW.boundary_inbox_seq
782
+ AND window.state = 'open'
783
+ )
784
+ ))
785
+ ) BEGIN SELECT RAISE(ABORT, 'agent session channel/window mismatch'); END;
786
+ CREATE TRIGGER ilink_session_window_update_guard
787
+ BEFORE UPDATE OF channel, reply_window_id, open_kfid,
788
+ external_userid, boundary_inbox_seq ON agent_sessions
789
+ WHEN (
790
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
791
+ (NEW.channel = 'weixin_ilink' AND (
792
+ NEW.reply_window_id IS NULL OR NOT EXISTS (
793
+ SELECT 1 FROM ilink_reply_windows AS window
794
+ WHERE window.reply_window_id = NEW.reply_window_id
795
+ AND window.account_key = NEW.open_kfid
796
+ AND window.peer_id = NEW.external_userid
797
+ AND window.source_inbox_seq = NEW.boundary_inbox_seq
798
+ AND window.state = 'open'
799
+ )
800
+ ))
801
+ ) BEGIN SELECT RAISE(ABORT, 'agent session channel/window mismatch'); END;
802
+
803
+ CREATE TRIGGER ilink_attempt_window_insert_guard
804
+ BEFORE INSERT ON send_attempts
805
+ WHEN (
806
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
807
+ (NEW.channel = 'weixin_ilink' AND (
808
+ NEW.reply_window_id IS NULL OR NOT EXISTS (
809
+ SELECT 1 FROM ilink_reply_windows AS window
810
+ WHERE window.reply_window_id = NEW.reply_window_id
811
+ AND window.account_key = NEW.open_kfid
812
+ AND window.peer_id = NEW.external_userid
813
+ )
814
+ ))
815
+ ) BEGIN SELECT RAISE(ABORT, 'send attempt channel/window mismatch'); END;
816
+ CREATE TRIGGER ilink_attempt_window_update_guard
817
+ BEFORE UPDATE OF channel, reply_window_id, open_kfid,
818
+ external_userid ON send_attempts
819
+ WHEN (
820
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
821
+ (NEW.channel = 'weixin_ilink' AND (
822
+ NEW.reply_window_id IS NULL OR NOT EXISTS (
823
+ SELECT 1 FROM ilink_reply_windows AS window
824
+ WHERE window.reply_window_id = NEW.reply_window_id
825
+ AND window.account_key = NEW.open_kfid
826
+ AND window.peer_id = NEW.external_userid
827
+ )
828
+ ))
829
+ ) BEGIN SELECT RAISE(ABORT, 'send attempt channel/window mismatch'); END;
830
+
831
+ CREATE TRIGGER ilink_window_source_insert_guard
832
+ BEFORE INSERT ON ilink_reply_windows
833
+ WHEN NOT EXISTS (
834
+ SELECT 1 FROM inbound_messages AS inbound
835
+ WHERE inbound.message_key = NEW.source_message_key
836
+ AND inbound.open_kfid = NEW.account_key
837
+ AND inbound.external_userid = NEW.peer_id
838
+ AND inbound.channel = 'weixin_ilink'
839
+ ) BEGIN SELECT RAISE(ABORT, 'reply window source mismatch'); END;
840
+ CREATE TRIGGER ilink_window_source_update_guard
841
+ BEFORE UPDATE OF source_message_key, account_key, peer_id
842
+ ON ilink_reply_windows
843
+ WHEN NOT EXISTS (
844
+ SELECT 1 FROM inbound_messages AS inbound
845
+ WHERE inbound.message_key = NEW.source_message_key
846
+ AND inbound.open_kfid = NEW.account_key
847
+ AND inbound.external_userid = NEW.peer_id
848
+ AND inbound.channel = 'weixin_ilink'
849
+ ) BEGIN SELECT RAISE(ABORT, 'reply window source mismatch'); END;
850
+ CREATE TRIGGER ilink_window_delete_guard
851
+ BEFORE DELETE ON ilink_reply_windows
852
+ WHEN EXISTS (
853
+ SELECT 1 FROM agent_sessions
854
+ WHERE reply_window_id = OLD.reply_window_id
855
+ ) BEGIN SELECT RAISE(ABORT, 'reply window still has agent sessions'); END;
856
+
857
+ PRAGMA user_version = 18;
858
+ COMMIT;
859
+ `);
860
+ version = 18;
861
+ }
862
+ catch (error) {
863
+ this.#database.exec('ROLLBACK');
864
+ throw error;
865
+ }
866
+ }
867
+ if (version === 18) {
868
+ this.#database.exec('BEGIN IMMEDIATE');
869
+ try {
870
+ this.#database.exec(`
871
+ CREATE TABLE ilink_inbound_images (
872
+ message_key TEXT NOT NULL,
873
+ position INTEGER NOT NULL CHECK (position >= 0),
874
+ account_key TEXT NOT NULL,
875
+ peer_id TEXT NOT NULL,
876
+ secret_generation INTEGER NOT NULL CHECK (secret_generation >= 0),
877
+ nonce TEXT NOT NULL,
878
+ ciphertext TEXT NOT NULL,
879
+ auth_tag TEXT NOT NULL,
880
+ created_at INTEGER NOT NULL,
881
+ PRIMARY KEY (message_key, position),
882
+ FOREIGN KEY (message_key, account_key, peer_id)
883
+ REFERENCES inbound_messages(message_key, open_kfid, external_userid)
884
+ ON DELETE CASCADE
885
+ ) STRICT, WITHOUT ROWID;
886
+ CREATE INDEX IF NOT EXISTS ilink_inbound_images_created_idx
887
+ ON ilink_inbound_images(created_at);
888
+ PRAGMA user_version = 19;
889
+ COMMIT;
890
+ `);
891
+ version = 19;
892
+ }
893
+ catch (error) {
894
+ this.#database.exec('ROLLBACK');
895
+ throw error;
896
+ }
897
+ }
898
+ if (version === 19) {
899
+ this.#database.exec('BEGIN IMMEDIATE');
900
+ try {
901
+ this.#database.exec(`
902
+ CREATE INDEX IF NOT EXISTS ilink_reply_windows_expiry_idx
903
+ ON ilink_reply_windows(expires_at, state);
904
+ CREATE INDEX IF NOT EXISTS ilink_reply_windows_updated_idx
905
+ ON ilink_reply_windows(updated_at, state);
906
+ PRAGMA user_version = 20;
907
+ COMMIT;
908
+ `);
909
+ version = 20;
910
+ }
911
+ catch (error) {
912
+ this.#database.exec('ROLLBACK');
913
+ throw error;
914
+ }
915
+ }
916
+ if (version === 20) {
917
+ this.#database.exec('BEGIN IMMEDIATE');
918
+ try {
919
+ this.#database.exec(`
920
+ UPDATE send_attempts
921
+ SET status = 'failed', error_code = 'feature_removed',
922
+ error_message = 'Retired notification tool removed before transmission'
923
+ WHERE source IN ('maintainer_binding', 'maintainer_notify')
924
+ AND status = 'pending';
925
+ DROP TABLE IF EXISTS maintainer_binding;
926
+ PRAGMA user_version = 21;
927
+ COMMIT;
928
+ `);
929
+ version = 21;
930
+ }
931
+ catch (error) {
932
+ this.#database.exec('ROLLBACK');
933
+ throw error;
934
+ }
935
+ }
936
+ if (version === 21) {
937
+ if (this.foreignKeyCheck().length) {
938
+ throw new Error('SQLite schema v21 contains foreign-key violations; repair the database before upgrading');
939
+ }
940
+ this.#database.exec(`
941
+ PRAGMA foreign_keys = OFF;
942
+ PRAGMA legacy_alter_table = ON;
943
+ BEGIN IMMEDIATE;
944
+ `);
945
+ try {
946
+ this.#database.exec(`
947
+ DROP TRIGGER IF EXISTS ilink_session_window_insert_guard;
948
+ DROP TRIGGER IF EXISTS ilink_session_window_update_guard;
949
+ DROP TRIGGER IF EXISTS ilink_attempt_window_insert_guard;
950
+ DROP TRIGGER IF EXISTS ilink_attempt_window_update_guard;
951
+ DROP TRIGGER IF EXISTS ilink_window_source_insert_guard;
952
+ DROP TRIGGER IF EXISTS ilink_window_source_update_guard;
953
+ DROP TRIGGER IF EXISTS ilink_window_delete_guard;
954
+
955
+ DROP INDEX inbound_pending_idx;
956
+ DROP INDEX inbound_primary_idx;
957
+ DROP INDEX inbound_deferred_idx;
958
+ DROP INDEX conversation_thread_idx;
959
+ DROP INDEX send_status_idx;
960
+ DROP INDEX send_wecom_msgid_idx;
961
+ DROP INDEX send_conversation_idx;
962
+ DROP INDEX media_conversation_idx;
963
+ DROP INDEX agent_session_source_idx;
964
+
965
+ ALTER TABLE agent_sessions RENAME TO agent_sessions_v21;
966
+ ALTER TABLE send_attempts RENAME TO send_attempts_v21;
967
+ ALTER TABLE inbound_media RENAME TO inbound_media_v21;
968
+ ALTER TABLE conversations RENAME TO conversations_v21;
969
+ ALTER TABLE inbound_messages RENAME TO inbound_messages_v21;
970
+
971
+ CREATE TABLE inbound_messages (
972
+ inbox_seq INTEGER PRIMARY KEY AUTOINCREMENT,
973
+ message_key TEXT NOT NULL UNIQUE,
974
+ open_kfid TEXT NOT NULL,
975
+ msgid TEXT NOT NULL,
976
+ external_userid TEXT NOT NULL DEFAULT '',
977
+ channel TEXT NOT NULL
978
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
979
+ origin TEXT NOT NULL,
980
+ msg_type TEXT NOT NULL,
981
+ sent_at INTEGER NOT NULL DEFAULT 0,
982
+ status TEXT NOT NULL CHECK (status IN (${sqlList(INBOUND_STATUSES)})),
983
+ deferred INTEGER NOT NULL DEFAULT 0 CHECK (deferred IN (0, 1)),
984
+ primary_message_key TEXT,
985
+ payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
986
+ codex_turn_id TEXT NOT NULL DEFAULT '',
987
+ client_input_id TEXT NOT NULL DEFAULT '',
988
+ steering_boundary INTEGER NOT NULL DEFAULT 0,
989
+ error_message TEXT NOT NULL DEFAULT '',
990
+ created_at INTEGER NOT NULL,
991
+ updated_at INTEGER NOT NULL,
992
+ UNIQUE (channel, open_kfid, msgid),
993
+ UNIQUE (message_key, open_kfid, external_userid),
994
+ UNIQUE (message_key, channel, open_kfid, external_userid),
995
+ FOREIGN KEY (
996
+ primary_message_key, channel, open_kfid, external_userid
997
+ ) REFERENCES inbound_messages(
998
+ message_key, channel, open_kfid, external_userid
999
+ )
1000
+ ) STRICT;
1001
+ INSERT INTO inbound_messages (
1002
+ inbox_seq, message_key, open_kfid, msgid, external_userid,
1003
+ channel, origin, msg_type, sent_at, status, deferred,
1004
+ primary_message_key, payload_json, codex_turn_id, client_input_id,
1005
+ steering_boundary, error_message, created_at, updated_at
1006
+ )
1007
+ SELECT
1008
+ old.inbox_seq, old.message_key, old.open_kfid, old.msgid,
1009
+ old.external_userid, old.channel, old.origin, old.msg_type,
1010
+ old.sent_at,
1011
+ CASE
1012
+ WHEN old.primary_message_key IS NOT NULL
1013
+ AND NOT EXISTS (
1014
+ SELECT 1 FROM inbound_messages_v21 AS primary_message
1015
+ WHERE primary_message.message_key = old.primary_message_key
1016
+ AND primary_message.channel = old.channel
1017
+ AND primary_message.open_kfid = old.open_kfid
1018
+ AND primary_message.external_userid = old.external_userid
1019
+ )
1020
+ AND old.status IN ('steering', 'steered')
1021
+ THEN 'received'
1022
+ ELSE old.status
1023
+ END,
1024
+ old.deferred,
1025
+ CASE
1026
+ WHEN old.primary_message_key IS NULL OR EXISTS (
1027
+ SELECT 1 FROM inbound_messages_v21 AS primary_message
1028
+ WHERE primary_message.message_key = old.primary_message_key
1029
+ AND primary_message.channel = old.channel
1030
+ AND primary_message.open_kfid = old.open_kfid
1031
+ AND primary_message.external_userid = old.external_userid
1032
+ ) THEN old.primary_message_key
1033
+ ELSE NULL
1034
+ END,
1035
+ old.payload_json,
1036
+ CASE
1037
+ WHEN old.primary_message_key IS NOT NULL
1038
+ AND NOT EXISTS (
1039
+ SELECT 1 FROM inbound_messages_v21 AS primary_message
1040
+ WHERE primary_message.message_key = old.primary_message_key
1041
+ AND primary_message.channel = old.channel
1042
+ AND primary_message.open_kfid = old.open_kfid
1043
+ AND primary_message.external_userid = old.external_userid
1044
+ )
1045
+ THEN ''
1046
+ ELSE old.codex_turn_id
1047
+ END,
1048
+ CASE
1049
+ WHEN old.primary_message_key IS NOT NULL
1050
+ AND NOT EXISTS (
1051
+ SELECT 1 FROM inbound_messages_v21 AS primary_message
1052
+ WHERE primary_message.message_key = old.primary_message_key
1053
+ AND primary_message.channel = old.channel
1054
+ AND primary_message.open_kfid = old.open_kfid
1055
+ AND primary_message.external_userid = old.external_userid
1056
+ )
1057
+ THEN ''
1058
+ ELSE old.client_input_id
1059
+ END,
1060
+ CASE
1061
+ WHEN old.primary_message_key IS NOT NULL
1062
+ AND NOT EXISTS (
1063
+ SELECT 1 FROM inbound_messages_v21 AS primary_message
1064
+ WHERE primary_message.message_key = old.primary_message_key
1065
+ AND primary_message.channel = old.channel
1066
+ AND primary_message.open_kfid = old.open_kfid
1067
+ AND primary_message.external_userid = old.external_userid
1068
+ )
1069
+ THEN 0
1070
+ ELSE old.steering_boundary
1071
+ END,
1072
+ old.error_message, old.created_at, old.updated_at
1073
+ FROM inbound_messages_v21 AS old;
1074
+
1075
+ CREATE TABLE conversations (
1076
+ channel TEXT NOT NULL
1077
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
1078
+ open_kfid TEXT NOT NULL,
1079
+ external_userid TEXT NOT NULL,
1080
+ thread_id TEXT NOT NULL DEFAULT '',
1081
+ memory_thread_id TEXT NOT NULL DEFAULT '',
1082
+ updated_at INTEGER NOT NULL,
1083
+ PRIMARY KEY (channel, open_kfid, external_userid)
1084
+ ) STRICT, WITHOUT ROWID;
1085
+ CREATE TEMP TABLE conversation_identities_v22 (
1086
+ channel TEXT NOT NULL,
1087
+ open_kfid TEXT NOT NULL,
1088
+ external_userid TEXT NOT NULL,
1089
+ latest_inbox_seq INTEGER NOT NULL,
1090
+ latest_updated_at INTEGER NOT NULL,
1091
+ PRIMARY KEY (channel, open_kfid, external_userid)
1092
+ ) STRICT, WITHOUT ROWID;
1093
+ INSERT INTO conversation_identities_v22 (
1094
+ channel, open_kfid, external_userid,
1095
+ latest_inbox_seq, latest_updated_at
1096
+ )
1097
+ SELECT
1098
+ channel, open_kfid, external_userid,
1099
+ MAX(inbox_seq), MAX(updated_at)
1100
+ FROM inbound_messages_v21
1101
+ WHERE external_userid <> ''
1102
+ GROUP BY channel, open_kfid, external_userid;
1103
+ INSERT INTO conversation_identities_v22 (
1104
+ channel, open_kfid, external_userid,
1105
+ latest_inbox_seq, latest_updated_at
1106
+ )
1107
+ SELECT
1108
+ 'wechat_kf', old.open_kfid, old.external_userid, 0, old.updated_at
1109
+ FROM conversations_v21 AS old
1110
+ WHERE NOT EXISTS (
1111
+ SELECT 1 FROM conversation_identities_v22 AS identity
1112
+ WHERE identity.open_kfid = old.open_kfid
1113
+ AND identity.external_userid = old.external_userid
1114
+ );
1115
+ INSERT INTO conversations (
1116
+ channel, open_kfid, external_userid,
1117
+ thread_id, memory_thread_id, updated_at
1118
+ )
1119
+ SELECT
1120
+ identity.channel, identity.open_kfid, identity.external_userid,
1121
+ CASE
1122
+ WHEN old.open_kfid IS NOT NULL AND identity.channel = (
1123
+ SELECT owner.channel
1124
+ FROM conversation_identities_v22 AS owner
1125
+ WHERE owner.open_kfid = identity.open_kfid
1126
+ AND owner.external_userid = identity.external_userid
1127
+ ORDER BY owner.latest_inbox_seq DESC, owner.channel
1128
+ LIMIT 1
1129
+ ) THEN old.thread_id
1130
+ ELSE ''
1131
+ END,
1132
+ CASE
1133
+ WHEN old.open_kfid IS NOT NULL AND identity.channel = (
1134
+ SELECT owner.channel
1135
+ FROM conversation_identities_v22 AS owner
1136
+ WHERE owner.open_kfid = identity.open_kfid
1137
+ AND owner.external_userid = identity.external_userid
1138
+ ORDER BY owner.latest_inbox_seq DESC, owner.channel
1139
+ LIMIT 1
1140
+ ) THEN old.memory_thread_id
1141
+ ELSE ''
1142
+ END,
1143
+ COALESCE(old.updated_at, identity.latest_updated_at)
1144
+ FROM conversation_identities_v22 AS identity
1145
+ LEFT JOIN conversations_v21 AS old
1146
+ ON old.open_kfid = identity.open_kfid
1147
+ AND old.external_userid = identity.external_userid;
1148
+ DROP TABLE conversation_identities_v22;
1149
+
1150
+ CREATE TABLE inbound_media (
1151
+ media_seq INTEGER PRIMARY KEY AUTOINCREMENT,
1152
+ message_key TEXT NOT NULL,
1153
+ channel TEXT NOT NULL
1154
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
1155
+ open_kfid TEXT NOT NULL,
1156
+ external_userid TEXT NOT NULL,
1157
+ position INTEGER NOT NULL CHECK (position >= 0),
1158
+ kind TEXT NOT NULL,
1159
+ media_id TEXT NOT NULL,
1160
+ filename TEXT NOT NULL DEFAULT '',
1161
+ sent_at INTEGER NOT NULL DEFAULT 0,
1162
+ remembered_at INTEGER NOT NULL,
1163
+ UNIQUE (message_key, position),
1164
+ FOREIGN KEY (
1165
+ message_key, channel, open_kfid, external_userid
1166
+ ) REFERENCES inbound_messages(
1167
+ message_key, channel, open_kfid, external_userid
1168
+ ) ON DELETE CASCADE
1169
+ ) STRICT;
1170
+ INSERT INTO inbound_media (
1171
+ media_seq, message_key, channel, open_kfid, external_userid,
1172
+ position, kind, media_id, filename, sent_at, remembered_at
1173
+ )
1174
+ SELECT
1175
+ media.media_seq, media.message_key, inbound.channel,
1176
+ media.open_kfid, media.external_userid, media.position,
1177
+ media.kind, media.media_id, media.filename,
1178
+ media.sent_at, media.remembered_at
1179
+ FROM inbound_media_v21 AS media
1180
+ JOIN inbound_messages AS inbound
1181
+ ON inbound.message_key = media.message_key;
1182
+
1183
+ CREATE TABLE send_attempts (
1184
+ attempt_key TEXT PRIMARY KEY,
1185
+ source_message_key TEXT NOT NULL,
1186
+ open_kfid TEXT NOT NULL,
1187
+ external_userid TEXT NOT NULL,
1188
+ channel TEXT NOT NULL
1189
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
1190
+ reply_window_id INTEGER,
1191
+ send_index INTEGER NOT NULL CHECK (send_index >= 0 AND send_index < 1000),
1192
+ source TEXT NOT NULL,
1193
+ sent_type TEXT NOT NULL,
1194
+ payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
1195
+ metadata_json TEXT CHECK (metadata_json IS NULL OR json_valid(metadata_json)),
1196
+ fingerprint TEXT NOT NULL,
1197
+ client_message_id TEXT NOT NULL UNIQUE,
1198
+ status TEXT NOT NULL CHECK (status IN (${sqlList(SEND_STATUSES)})),
1199
+ wecom_msgid TEXT NOT NULL DEFAULT '',
1200
+ error_code TEXT NOT NULL DEFAULT '',
1201
+ error_message TEXT NOT NULL DEFAULT '',
1202
+ fail_type INTEGER NOT NULL DEFAULT 0,
1203
+ created_at INTEGER NOT NULL,
1204
+ updated_at INTEGER NOT NULL,
1205
+ UNIQUE (source_message_key, send_index),
1206
+ FOREIGN KEY (
1207
+ source_message_key, channel, open_kfid, external_userid
1208
+ ) REFERENCES inbound_messages(
1209
+ message_key, channel, open_kfid, external_userid
1210
+ ),
1211
+ FOREIGN KEY (reply_window_id)
1212
+ REFERENCES ilink_reply_windows(reply_window_id)
1213
+ ) STRICT;
1214
+ INSERT INTO send_attempts (
1215
+ attempt_key, source_message_key, open_kfid, external_userid,
1216
+ channel, reply_window_id, send_index, source, sent_type,
1217
+ payload_json, metadata_json, fingerprint, client_message_id,
1218
+ status, wecom_msgid, error_code, error_message, fail_type,
1219
+ created_at, updated_at
1220
+ )
1221
+ SELECT
1222
+ attempt.attempt_key, attempt.source_message_key,
1223
+ attempt.open_kfid, attempt.external_userid, inbound.channel,
1224
+ attempt.reply_window_id, attempt.send_index, attempt.source,
1225
+ attempt.sent_type, attempt.payload_json, attempt.metadata_json,
1226
+ attempt.fingerprint, attempt.client_message_id, attempt.status,
1227
+ attempt.wecom_msgid, attempt.error_code, attempt.error_message,
1228
+ attempt.fail_type, attempt.created_at, attempt.updated_at
1229
+ FROM send_attempts_v21 AS attempt
1230
+ JOIN inbound_messages AS inbound
1231
+ ON inbound.message_key = attempt.source_message_key;
1232
+
1233
+ CREATE TABLE agent_sessions (
1234
+ token_hash TEXT PRIMARY KEY,
1235
+ source_message_key TEXT NOT NULL,
1236
+ open_kfid TEXT NOT NULL,
1237
+ external_userid TEXT NOT NULL,
1238
+ channel TEXT NOT NULL
1239
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
1240
+ reply_window_id INTEGER,
1241
+ boundary_inbox_seq INTEGER NOT NULL CHECK (boundary_inbox_seq >= 0),
1242
+ memory_thread_id TEXT NOT NULL DEFAULT '',
1243
+ media_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(media_json)),
1244
+ expires_at INTEGER NOT NULL,
1245
+ closed_at INTEGER NOT NULL DEFAULT 0,
1246
+ created_at INTEGER NOT NULL,
1247
+ updated_at INTEGER NOT NULL,
1248
+ FOREIGN KEY (
1249
+ source_message_key, channel, open_kfid, external_userid
1250
+ ) REFERENCES inbound_messages(
1251
+ message_key, channel, open_kfid, external_userid
1252
+ ) ON DELETE CASCADE
1253
+ ) STRICT;
1254
+ INSERT INTO agent_sessions (
1255
+ token_hash, source_message_key, open_kfid, external_userid,
1256
+ channel, reply_window_id, boundary_inbox_seq, memory_thread_id,
1257
+ media_json, expires_at, closed_at, created_at, updated_at
1258
+ )
1259
+ SELECT
1260
+ session.token_hash, session.source_message_key,
1261
+ session.open_kfid, session.external_userid, inbound.channel,
1262
+ session.reply_window_id, session.boundary_inbox_seq,
1263
+ session.memory_thread_id, session.media_json, session.expires_at,
1264
+ session.closed_at, session.created_at, session.updated_at
1265
+ FROM agent_sessions_v21 AS session
1266
+ JOIN inbound_messages AS inbound
1267
+ ON inbound.message_key = session.source_message_key;
1268
+
1269
+ DROP TABLE agent_sessions_v21;
1270
+ DROP TABLE send_attempts_v21;
1271
+ DROP TABLE inbound_media_v21;
1272
+ DROP TABLE conversations_v21;
1273
+ DROP TABLE inbound_messages_v21;
1274
+
1275
+ CREATE INDEX inbound_pending_idx
1276
+ ON inbound_messages(
1277
+ status, channel, open_kfid, external_userid, inbox_seq
1278
+ );
1279
+ CREATE INDEX inbound_primary_idx
1280
+ ON inbound_messages(primary_message_key, inbox_seq);
1281
+ CREATE INDEX inbound_deferred_idx
1282
+ ON inbound_messages(deferred, status, inbox_seq);
1283
+ CREATE UNIQUE INDEX conversation_thread_idx
1284
+ ON conversations(thread_id) WHERE thread_id <> '';
1285
+ CREATE INDEX send_status_idx
1286
+ ON send_attempts(channel, status, created_at, send_index);
1287
+ CREATE UNIQUE INDEX send_wecom_msgid_idx
1288
+ ON send_attempts(channel, wecom_msgid) WHERE wecom_msgid <> '';
1289
+ CREATE INDEX send_conversation_idx
1290
+ ON send_attempts(
1291
+ channel, open_kfid, external_userid, updated_at DESC
1292
+ );
1293
+ CREATE INDEX media_conversation_idx
1294
+ ON inbound_media(
1295
+ channel, open_kfid, external_userid, remembered_at DESC
1296
+ );
1297
+ CREATE INDEX agent_session_source_idx
1298
+ ON agent_sessions(source_message_key, closed_at, expires_at);
1299
+
1300
+ CREATE TRIGGER ilink_session_window_insert_guard
1301
+ BEFORE INSERT ON agent_sessions WHEN (
1302
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
1303
+ (NEW.channel = 'weixin_ilink' AND (NEW.reply_window_id IS NULL OR NOT EXISTS (
1304
+ SELECT 1 FROM ilink_reply_windows AS window
1305
+ WHERE window.reply_window_id = NEW.reply_window_id
1306
+ AND window.account_key = NEW.open_kfid
1307
+ AND window.peer_id = NEW.external_userid
1308
+ AND window.source_inbox_seq = NEW.boundary_inbox_seq
1309
+ AND window.state = 'open'
1310
+ )))
1311
+ ) BEGIN SELECT RAISE(ABORT, 'agent session channel/window mismatch'); END;
1312
+ CREATE TRIGGER ilink_session_window_update_guard
1313
+ BEFORE UPDATE OF channel, reply_window_id, open_kfid,
1314
+ external_userid, boundary_inbox_seq ON agent_sessions WHEN (
1315
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
1316
+ (NEW.channel = 'weixin_ilink' AND (NEW.reply_window_id IS NULL OR NOT EXISTS (
1317
+ SELECT 1 FROM ilink_reply_windows AS window
1318
+ WHERE window.reply_window_id = NEW.reply_window_id
1319
+ AND window.account_key = NEW.open_kfid
1320
+ AND window.peer_id = NEW.external_userid
1321
+ AND window.source_inbox_seq = NEW.boundary_inbox_seq
1322
+ AND window.state = 'open'
1323
+ )))
1324
+ ) BEGIN SELECT RAISE(ABORT, 'agent session channel/window mismatch'); END;
1325
+ CREATE TRIGGER ilink_attempt_window_insert_guard
1326
+ BEFORE INSERT ON send_attempts WHEN (
1327
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
1328
+ (NEW.channel = 'weixin_ilink' AND (NEW.reply_window_id IS NULL OR NOT EXISTS (
1329
+ SELECT 1 FROM ilink_reply_windows AS window
1330
+ WHERE window.reply_window_id = NEW.reply_window_id
1331
+ AND window.account_key = NEW.open_kfid
1332
+ AND window.peer_id = NEW.external_userid
1333
+ )))
1334
+ ) BEGIN SELECT RAISE(ABORT, 'send attempt channel/window mismatch'); END;
1335
+ CREATE TRIGGER ilink_attempt_window_update_guard
1336
+ BEFORE UPDATE OF channel, reply_window_id, open_kfid,
1337
+ external_userid ON send_attempts WHEN (
1338
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
1339
+ (NEW.channel = 'weixin_ilink' AND (NEW.reply_window_id IS NULL OR NOT EXISTS (
1340
+ SELECT 1 FROM ilink_reply_windows AS window
1341
+ WHERE window.reply_window_id = NEW.reply_window_id
1342
+ AND window.account_key = NEW.open_kfid
1343
+ AND window.peer_id = NEW.external_userid
1344
+ )))
1345
+ ) BEGIN SELECT RAISE(ABORT, 'send attempt channel/window mismatch'); END;
1346
+ CREATE TRIGGER ilink_window_source_insert_guard
1347
+ BEFORE INSERT ON ilink_reply_windows WHEN NOT EXISTS (
1348
+ SELECT 1 FROM inbound_messages AS inbound
1349
+ WHERE inbound.message_key = NEW.source_message_key
1350
+ AND inbound.open_kfid = NEW.account_key
1351
+ AND inbound.external_userid = NEW.peer_id
1352
+ AND inbound.channel = 'weixin_ilink'
1353
+ ) BEGIN SELECT RAISE(ABORT, 'reply window source mismatch'); END;
1354
+ CREATE TRIGGER ilink_window_source_update_guard
1355
+ BEFORE UPDATE OF source_message_key, account_key, peer_id
1356
+ ON ilink_reply_windows WHEN NOT EXISTS (
1357
+ SELECT 1 FROM inbound_messages AS inbound
1358
+ WHERE inbound.message_key = NEW.source_message_key
1359
+ AND inbound.open_kfid = NEW.account_key
1360
+ AND inbound.external_userid = NEW.peer_id
1361
+ AND inbound.channel = 'weixin_ilink'
1362
+ ) BEGIN SELECT RAISE(ABORT, 'reply window source mismatch'); END;
1363
+ CREATE TRIGGER ilink_window_delete_guard
1364
+ BEFORE DELETE ON ilink_reply_windows WHEN EXISTS (
1365
+ SELECT 1 FROM agent_sessions WHERE reply_window_id = OLD.reply_window_id
1366
+ ) BEGIN SELECT RAISE(ABORT, 'reply window still has agent sessions'); END;
1367
+ `);
1368
+ const violations = this.foreignKeyCheck();
1369
+ if (violations.length) {
1370
+ throw new Error('SQLite migration v22 created foreign-key violations');
1371
+ }
1372
+ this.#database.exec(`
1373
+ PRAGMA user_version = 22;
1374
+ COMMIT;
1375
+ `);
1376
+ }
1377
+ catch (error) {
1378
+ this.#database.exec('ROLLBACK');
1379
+ throw error;
1380
+ }
1381
+ finally {
1382
+ this.#database.exec(`
1383
+ PRAGMA legacy_alter_table = OFF;
1384
+ PRAGMA foreign_keys = ON;
1385
+ `);
1386
+ }
1387
+ return;
1388
+ }
1389
+ this.#database.exec('BEGIN IMMEDIATE');
1390
+ try {
1391
+ this.#database.exec(`
1392
+ CREATE TABLE sync_cursors (
1393
+ open_kfid TEXT PRIMARY KEY,
1394
+ cursor TEXT NOT NULL,
1395
+ updated_at INTEGER NOT NULL
1396
+ ) STRICT;
1397
+
1398
+ CREATE TABLE conversations (
1399
+ channel TEXT NOT NULL
1400
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
1401
+ open_kfid TEXT NOT NULL,
1402
+ external_userid TEXT NOT NULL,
1403
+ thread_id TEXT NOT NULL DEFAULT '',
1404
+ memory_thread_id TEXT NOT NULL DEFAULT '',
1405
+ updated_at INTEGER NOT NULL,
1406
+ PRIMARY KEY (channel, open_kfid, external_userid)
1407
+ ) STRICT, WITHOUT ROWID;
1408
+
1409
+ CREATE TABLE authorizations (
1410
+ external_userid TEXT PRIMARY KEY,
1411
+ authorized INTEGER NOT NULL DEFAULT 0 CHECK (authorized IN (0, 1)),
1412
+ consecutive_matches INTEGER NOT NULL DEFAULT 0
1413
+ CHECK (consecutive_matches >= 0),
1414
+ last_open_kfid TEXT NOT NULL DEFAULT '',
1415
+ last_message_key TEXT NOT NULL DEFAULT '',
1416
+ authorized_at INTEGER NOT NULL DEFAULT 0,
1417
+ updated_at INTEGER NOT NULL
1418
+ ) STRICT;
1419
+
1420
+ CREATE TABLE ilink_accounts (
1421
+ account_key TEXT PRIMARY KEY,
1422
+ provider_account_id TEXT NOT NULL UNIQUE,
1423
+ owner_peer_id TEXT NOT NULL,
1424
+ base_url TEXT NOT NULL,
1425
+ generation INTEGER NOT NULL DEFAULT 1 CHECK (generation > 0),
1426
+ status TEXT NOT NULL DEFAULT 'active'
1427
+ CHECK (status IN ('active', 'paused', 'disabled', 'revoked')),
1428
+ pause_until INTEGER NOT NULL DEFAULT 0,
1429
+ cursor TEXT NOT NULL DEFAULT '',
1430
+ cursor_updated_at INTEGER NOT NULL DEFAULT 0,
1431
+ created_at INTEGER NOT NULL,
1432
+ updated_at INTEGER NOT NULL
1433
+ ) STRICT, WITHOUT ROWID;
1434
+ CREATE UNIQUE INDEX ilink_active_owner_idx
1435
+ ON ilink_accounts(owner_peer_id)
1436
+ WHERE status IN ('active', 'paused');
1437
+
1438
+ CREATE TABLE ilink_account_secrets (
1439
+ account_key TEXT PRIMARY KEY,
1440
+ account_generation INTEGER NOT NULL CHECK (account_generation > 0),
1441
+ nonce TEXT NOT NULL,
1442
+ ciphertext TEXT NOT NULL,
1443
+ auth_tag TEXT NOT NULL,
1444
+ updated_at INTEGER NOT NULL,
1445
+ FOREIGN KEY (account_key) REFERENCES ilink_accounts(account_key)
1446
+ ON DELETE CASCADE
1447
+ ) STRICT, WITHOUT ROWID;
1448
+
1449
+ CREATE TABLE ilink_login_offers (
1450
+ offer_id TEXT PRIMARY KEY,
1451
+ source_message_key TEXT NOT NULL,
1452
+ source_open_kfid TEXT NOT NULL,
1453
+ source_external_userid TEXT NOT NULL,
1454
+ secret_generation INTEGER NOT NULL CHECK (secret_generation >= 0),
1455
+ nonce TEXT NOT NULL,
1456
+ ciphertext TEXT NOT NULL,
1457
+ auth_tag TEXT NOT NULL,
1458
+ api_base_url TEXT NOT NULL,
1459
+ status TEXT NOT NULL DEFAULT 'waiting'
1460
+ CHECK (status IN (
1461
+ 'waiting', 'scanned', 'confirmed', 'expired', 'failed', 'cancelled'
1462
+ )),
1463
+ expires_at INTEGER NOT NULL,
1464
+ last_polled_at INTEGER NOT NULL DEFAULT 0,
1465
+ error_code TEXT NOT NULL DEFAULT '',
1466
+ created_at INTEGER NOT NULL,
1467
+ updated_at INTEGER NOT NULL
1468
+ ) STRICT, WITHOUT ROWID;
1469
+ CREATE UNIQUE INDEX ilink_one_pending_offer_idx
1470
+ ON ilink_login_offers(source_open_kfid, source_external_userid)
1471
+ WHERE status IN ('waiting', 'scanned');
1472
+
1473
+ CREATE TABLE ilink_enrollment_audit (
1474
+ offer_id TEXT PRIMARY KEY,
1475
+ source_message_key TEXT NOT NULL,
1476
+ source_open_kfid TEXT NOT NULL,
1477
+ source_external_userid TEXT NOT NULL,
1478
+ account_key TEXT NOT NULL DEFAULT '',
1479
+ result TEXT NOT NULL
1480
+ CHECK (result IN ('confirmed', 'expired', 'failed', 'cancelled')),
1481
+ offered_at INTEGER NOT NULL,
1482
+ completed_at INTEGER NOT NULL
1483
+ ) STRICT, WITHOUT ROWID;
1484
+
1485
+ CREATE TABLE inbound_messages (
1486
+ inbox_seq INTEGER PRIMARY KEY AUTOINCREMENT,
1487
+ message_key TEXT NOT NULL UNIQUE,
1488
+ open_kfid TEXT NOT NULL,
1489
+ msgid TEXT NOT NULL,
1490
+ external_userid TEXT NOT NULL DEFAULT '',
1491
+ channel TEXT NOT NULL
1492
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
1493
+ origin TEXT NOT NULL,
1494
+ msg_type TEXT NOT NULL,
1495
+ sent_at INTEGER NOT NULL DEFAULT 0,
1496
+ status TEXT NOT NULL CHECK (status IN (${sqlList(INBOUND_STATUSES)})),
1497
+ deferred INTEGER NOT NULL DEFAULT 0 CHECK (deferred IN (0, 1)),
1498
+ primary_message_key TEXT,
1499
+ payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
1500
+ codex_turn_id TEXT NOT NULL DEFAULT '',
1501
+ client_input_id TEXT NOT NULL DEFAULT '',
1502
+ steering_boundary INTEGER NOT NULL DEFAULT 0,
1503
+ error_message TEXT NOT NULL DEFAULT '',
1504
+ created_at INTEGER NOT NULL,
1505
+ updated_at INTEGER NOT NULL,
1506
+ UNIQUE (channel, open_kfid, msgid),
1507
+ UNIQUE (message_key, open_kfid, external_userid),
1508
+ UNIQUE (message_key, channel, open_kfid, external_userid),
1509
+ FOREIGN KEY (
1510
+ primary_message_key, channel, open_kfid, external_userid
1511
+ ) REFERENCES inbound_messages(
1512
+ message_key, channel, open_kfid, external_userid
1513
+ )
1514
+ ) STRICT;
1515
+
1516
+ CREATE TABLE inbound_media (
1517
+ media_seq INTEGER PRIMARY KEY AUTOINCREMENT,
1518
+ message_key TEXT NOT NULL,
1519
+ channel TEXT NOT NULL
1520
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
1521
+ open_kfid TEXT NOT NULL,
1522
+ external_userid TEXT NOT NULL,
1523
+ position INTEGER NOT NULL CHECK (position >= 0),
1524
+ kind TEXT NOT NULL,
1525
+ media_id TEXT NOT NULL,
1526
+ filename TEXT NOT NULL DEFAULT '',
1527
+ sent_at INTEGER NOT NULL DEFAULT 0,
1528
+ remembered_at INTEGER NOT NULL,
1529
+ UNIQUE (message_key, position),
1530
+ FOREIGN KEY (message_key, channel, open_kfid, external_userid)
1531
+ REFERENCES inbound_messages(
1532
+ message_key, channel, open_kfid, external_userid
1533
+ ) ON DELETE CASCADE
1534
+ ) STRICT;
1535
+
1536
+ CREATE TABLE ilink_inbound_images (
1537
+ message_key TEXT NOT NULL,
1538
+ position INTEGER NOT NULL CHECK (position >= 0),
1539
+ account_key TEXT NOT NULL,
1540
+ peer_id TEXT NOT NULL,
1541
+ secret_generation INTEGER NOT NULL CHECK (secret_generation >= 0),
1542
+ nonce TEXT NOT NULL,
1543
+ ciphertext TEXT NOT NULL,
1544
+ auth_tag TEXT NOT NULL,
1545
+ created_at INTEGER NOT NULL,
1546
+ PRIMARY KEY (message_key, position),
1547
+ FOREIGN KEY (message_key, account_key, peer_id)
1548
+ REFERENCES inbound_messages(message_key, open_kfid, external_userid)
1549
+ ON DELETE CASCADE
1550
+ ) STRICT, WITHOUT ROWID;
1551
+ CREATE INDEX ilink_inbound_images_created_idx
1552
+ ON ilink_inbound_images(created_at);
1553
+
1554
+ CREATE TABLE ilink_reply_windows (
1555
+ reply_window_id INTEGER PRIMARY KEY AUTOINCREMENT,
1556
+ account_key TEXT NOT NULL,
1557
+ peer_id TEXT NOT NULL,
1558
+ account_generation INTEGER NOT NULL CHECK (account_generation > 0),
1559
+ source_message_key TEXT NOT NULL UNIQUE,
1560
+ source_inbox_seq INTEGER NOT NULL CHECK (source_inbox_seq > 0),
1561
+ provider_seq INTEGER,
1562
+ issued_at INTEGER NOT NULL,
1563
+ expires_at INTEGER NOT NULL CHECK (expires_at > issued_at),
1564
+ max_sends INTEGER NOT NULL DEFAULT 10 CHECK (max_sends BETWEEN 1 AND 10),
1565
+ next_send_index INTEGER NOT NULL DEFAULT 0 CHECK (next_send_index >= 0),
1566
+ reserved_send_count INTEGER NOT NULL DEFAULT 0
1567
+ CHECK (reserved_send_count >= 0),
1568
+ transmitted_send_count INTEGER NOT NULL DEFAULT 0
1569
+ CHECK (transmitted_send_count >= 0),
1570
+ state TEXT NOT NULL DEFAULT 'open'
1571
+ CHECK (state IN ('open', 'superseded', 'closed', 'cancelled')),
1572
+ secret_generation INTEGER NOT NULL CHECK (secret_generation >= 0),
1573
+ created_at INTEGER NOT NULL,
1574
+ updated_at INTEGER NOT NULL,
1575
+ FOREIGN KEY (account_key) REFERENCES ilink_accounts(account_key),
1576
+ FOREIGN KEY (source_message_key) REFERENCES inbound_messages(message_key),
1577
+ CHECK (reserved_send_count + transmitted_send_count <= max_sends)
1578
+ ) STRICT;
1579
+ CREATE UNIQUE INDEX ilink_one_open_window_idx
1580
+ ON ilink_reply_windows(account_key, peer_id) WHERE state = 'open';
1581
+ CREATE INDEX ilink_reply_windows_expiry_idx
1582
+ ON ilink_reply_windows(expires_at, state);
1583
+ CREATE INDEX ilink_reply_windows_updated_idx
1584
+ ON ilink_reply_windows(updated_at, state);
1585
+
1586
+ CREATE TABLE ilink_reply_window_secrets (
1587
+ reply_window_id INTEGER PRIMARY KEY,
1588
+ nonce TEXT NOT NULL,
1589
+ ciphertext TEXT NOT NULL,
1590
+ auth_tag TEXT NOT NULL,
1591
+ updated_at INTEGER NOT NULL,
1592
+ FOREIGN KEY (reply_window_id)
1593
+ REFERENCES ilink_reply_windows(reply_window_id) ON DELETE CASCADE
1594
+ ) STRICT, WITHOUT ROWID;
1595
+
1596
+ CREATE TABLE send_attempts (
1597
+ attempt_key TEXT PRIMARY KEY,
1598
+ source_message_key TEXT NOT NULL,
1599
+ open_kfid TEXT NOT NULL,
1600
+ external_userid TEXT NOT NULL,
1601
+ channel TEXT NOT NULL
1602
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
1603
+ reply_window_id INTEGER,
1604
+ send_index INTEGER NOT NULL CHECK (send_index >= 0 AND send_index < 1000),
1605
+ source TEXT NOT NULL,
1606
+ sent_type TEXT NOT NULL,
1607
+ payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
1608
+ metadata_json TEXT CHECK (metadata_json IS NULL OR json_valid(metadata_json)),
1609
+ fingerprint TEXT NOT NULL,
1610
+ client_message_id TEXT NOT NULL UNIQUE,
1611
+ status TEXT NOT NULL CHECK (status IN (${sqlList(SEND_STATUSES)})),
1612
+ wecom_msgid TEXT NOT NULL DEFAULT '',
1613
+ error_code TEXT NOT NULL DEFAULT '',
1614
+ error_message TEXT NOT NULL DEFAULT '',
1615
+ fail_type INTEGER NOT NULL DEFAULT 0,
1616
+ created_at INTEGER NOT NULL,
1617
+ updated_at INTEGER NOT NULL,
1618
+ UNIQUE (source_message_key, send_index),
1619
+ FOREIGN KEY (
1620
+ source_message_key, channel, open_kfid, external_userid
1621
+ )
1622
+ REFERENCES inbound_messages(
1623
+ message_key, channel, open_kfid, external_userid
1624
+ ),
1625
+ FOREIGN KEY (reply_window_id)
1626
+ REFERENCES ilink_reply_windows(reply_window_id)
1627
+ ) STRICT;
1628
+
1629
+ CREATE TABLE agent_sessions (
1630
+ token_hash TEXT PRIMARY KEY,
1631
+ source_message_key TEXT NOT NULL,
1632
+ open_kfid TEXT NOT NULL,
1633
+ external_userid TEXT NOT NULL,
1634
+ channel TEXT NOT NULL
1635
+ CHECK (channel IN ('wechat_kf', 'weixin_ilink')),
1636
+ reply_window_id INTEGER,
1637
+ boundary_inbox_seq INTEGER NOT NULL CHECK (boundary_inbox_seq >= 0),
1638
+ memory_thread_id TEXT NOT NULL DEFAULT '',
1639
+ media_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(media_json)),
1640
+ expires_at INTEGER NOT NULL,
1641
+ closed_at INTEGER NOT NULL DEFAULT 0,
1642
+ created_at INTEGER NOT NULL,
1643
+ updated_at INTEGER NOT NULL,
1644
+ FOREIGN KEY (
1645
+ source_message_key, channel, open_kfid, external_userid
1646
+ )
1647
+ REFERENCES inbound_messages(
1648
+ message_key, channel, open_kfid, external_userid
1649
+ ) ON DELETE CASCADE
1650
+ ) STRICT;
1651
+
1652
+ CREATE TABLE delivery_failures (
1653
+ wecom_msgid TEXT PRIMARY KEY,
1654
+ fail_type INTEGER NOT NULL,
1655
+ observed_at INTEGER NOT NULL,
1656
+ matched_attempt_key TEXT NOT NULL DEFAULT '',
1657
+ matched_at INTEGER NOT NULL DEFAULT 0
1658
+ ) STRICT;
1659
+
1660
+ CREATE TABLE agent_artifacts (
1661
+ token_hash TEXT NOT NULL,
1662
+ ref TEXT NOT NULL,
1663
+ bytes BLOB NOT NULL,
1664
+ filename TEXT NOT NULL,
1665
+ content_type TEXT NOT NULL,
1666
+ metadata_json TEXT CHECK (metadata_json IS NULL OR json_valid(metadata_json)),
1667
+ created_at INTEGER NOT NULL,
1668
+ PRIMARY KEY (token_hash, ref),
1669
+ FOREIGN KEY (token_hash) REFERENCES agent_sessions(token_hash)
1670
+ ON DELETE CASCADE
1671
+ ) STRICT, WITHOUT ROWID;
1672
+
1673
+ CREATE INDEX inbound_pending_idx
1674
+ ON inbound_messages(
1675
+ status, channel, open_kfid, external_userid, inbox_seq
1676
+ );
1677
+ CREATE INDEX inbound_primary_idx
1678
+ ON inbound_messages(primary_message_key, inbox_seq);
1679
+ CREATE INDEX inbound_deferred_idx
1680
+ ON inbound_messages(deferred, status, inbox_seq);
1681
+ CREATE UNIQUE INDEX conversation_thread_idx
1682
+ ON conversations(thread_id) WHERE thread_id <> '';
1683
+ CREATE INDEX send_status_idx
1684
+ ON send_attempts(channel, status, created_at, send_index);
1685
+ CREATE UNIQUE INDEX send_wecom_msgid_idx
1686
+ ON send_attempts(channel, wecom_msgid) WHERE wecom_msgid <> '';
1687
+ CREATE INDEX send_conversation_idx
1688
+ ON send_attempts(
1689
+ channel, open_kfid, external_userid, updated_at DESC
1690
+ );
1691
+ CREATE INDEX media_conversation_idx
1692
+ ON inbound_media(
1693
+ channel, open_kfid, external_userid, remembered_at DESC
1694
+ );
1695
+ CREATE INDEX agent_session_source_idx
1696
+ ON agent_sessions(source_message_key, closed_at, expires_at);
1697
+
1698
+ CREATE TRIGGER ilink_session_window_insert_guard
1699
+ BEFORE INSERT ON agent_sessions WHEN (
1700
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
1701
+ (NEW.channel = 'weixin_ilink' AND (NEW.reply_window_id IS NULL OR NOT EXISTS (
1702
+ SELECT 1 FROM ilink_reply_windows AS window
1703
+ WHERE window.reply_window_id = NEW.reply_window_id
1704
+ AND window.account_key = NEW.open_kfid
1705
+ AND window.peer_id = NEW.external_userid
1706
+ AND window.source_inbox_seq = NEW.boundary_inbox_seq
1707
+ AND window.state = 'open'
1708
+ )))
1709
+ ) BEGIN SELECT RAISE(ABORT, 'agent session channel/window mismatch'); END;
1710
+ CREATE TRIGGER ilink_session_window_update_guard
1711
+ BEFORE UPDATE OF channel, reply_window_id, open_kfid,
1712
+ external_userid, boundary_inbox_seq ON agent_sessions WHEN (
1713
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
1714
+ (NEW.channel = 'weixin_ilink' AND (NEW.reply_window_id IS NULL OR NOT EXISTS (
1715
+ SELECT 1 FROM ilink_reply_windows AS window
1716
+ WHERE window.reply_window_id = NEW.reply_window_id
1717
+ AND window.account_key = NEW.open_kfid
1718
+ AND window.peer_id = NEW.external_userid
1719
+ AND window.source_inbox_seq = NEW.boundary_inbox_seq
1720
+ AND window.state = 'open'
1721
+ )))
1722
+ ) BEGIN SELECT RAISE(ABORT, 'agent session channel/window mismatch'); END;
1723
+ CREATE TRIGGER ilink_attempt_window_insert_guard
1724
+ BEFORE INSERT ON send_attempts WHEN (
1725
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
1726
+ (NEW.channel = 'weixin_ilink' AND (NEW.reply_window_id IS NULL OR NOT EXISTS (
1727
+ SELECT 1 FROM ilink_reply_windows AS window
1728
+ WHERE window.reply_window_id = NEW.reply_window_id
1729
+ AND window.account_key = NEW.open_kfid
1730
+ AND window.peer_id = NEW.external_userid
1731
+ )))
1732
+ ) BEGIN SELECT RAISE(ABORT, 'send attempt channel/window mismatch'); END;
1733
+ CREATE TRIGGER ilink_attempt_window_update_guard
1734
+ BEFORE UPDATE OF channel, reply_window_id, open_kfid,
1735
+ external_userid ON send_attempts WHEN (
1736
+ (NEW.channel = 'wechat_kf' AND NEW.reply_window_id IS NOT NULL) OR
1737
+ (NEW.channel = 'weixin_ilink' AND (NEW.reply_window_id IS NULL OR NOT EXISTS (
1738
+ SELECT 1 FROM ilink_reply_windows AS window
1739
+ WHERE window.reply_window_id = NEW.reply_window_id
1740
+ AND window.account_key = NEW.open_kfid
1741
+ AND window.peer_id = NEW.external_userid
1742
+ )))
1743
+ ) BEGIN SELECT RAISE(ABORT, 'send attempt channel/window mismatch'); END;
1744
+ CREATE TRIGGER ilink_window_source_insert_guard
1745
+ BEFORE INSERT ON ilink_reply_windows WHEN NOT EXISTS (
1746
+ SELECT 1 FROM inbound_messages AS inbound
1747
+ WHERE inbound.message_key = NEW.source_message_key
1748
+ AND inbound.open_kfid = NEW.account_key
1749
+ AND inbound.external_userid = NEW.peer_id
1750
+ AND inbound.channel = 'weixin_ilink'
1751
+ ) BEGIN SELECT RAISE(ABORT, 'reply window source mismatch'); END;
1752
+ CREATE TRIGGER ilink_window_source_update_guard
1753
+ BEFORE UPDATE OF source_message_key, account_key, peer_id
1754
+ ON ilink_reply_windows WHEN NOT EXISTS (
1755
+ SELECT 1 FROM inbound_messages AS inbound
1756
+ WHERE inbound.message_key = NEW.source_message_key
1757
+ AND inbound.open_kfid = NEW.account_key
1758
+ AND inbound.external_userid = NEW.peer_id
1759
+ AND inbound.channel = 'weixin_ilink'
1760
+ ) BEGIN SELECT RAISE(ABORT, 'reply window source mismatch'); END;
1761
+ CREATE TRIGGER ilink_window_delete_guard
1762
+ BEFORE DELETE ON ilink_reply_windows WHEN EXISTS (
1763
+ SELECT 1 FROM agent_sessions WHERE reply_window_id = OLD.reply_window_id
1764
+ ) BEGIN SELECT RAISE(ABORT, 'reply window still has agent sessions'); END;
1765
+ `);
1766
+ this.#database.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
1767
+ this.#database.exec('COMMIT');
1768
+ }
1769
+ catch (error) {
1770
+ this.#database.exec('ROLLBACK');
1771
+ throw error;
1772
+ }
1773
+ }
1774
+ #transaction(operation) {
1775
+ if (this.#database.isTransaction)
1776
+ return operation();
1777
+ this.#database.exec('BEGIN IMMEDIATE');
1778
+ try {
1779
+ const result = operation();
1780
+ this.#database.exec('COMMIT');
1781
+ return result;
1782
+ }
1783
+ catch (error) {
1784
+ this.#database.exec('ROLLBACK');
1785
+ throw error;
1786
+ }
1787
+ }
1788
+ #ensureConversation(channel, accountKey, peerId, now = this.#now()) {
1789
+ if (!peerId)
1790
+ return;
1791
+ this.#database
1792
+ .prepare(`
1793
+ INSERT INTO conversations (
1794
+ channel, open_kfid, external_userid, updated_at
1795
+ ) VALUES (?, ?, ?, ?)
1796
+ ON CONFLICT(channel, open_kfid, external_userid) DO NOTHING
1797
+ `)
1798
+ .run(channel, accountKey, peerId, now);
1799
+ }
1800
+ #inboundRow(messageKey) {
1801
+ return rowAs(this.#database
1802
+ .prepare('SELECT * FROM inbound_messages WHERE message_key = ?')
1803
+ .get(messageKey));
1804
+ }
1805
+ #attemptRow(attemptKey) {
1806
+ return rowAs(this.#database
1807
+ .prepare('SELECT * FROM send_attempts WHERE attempt_key = ?')
1808
+ .get(attemptKey));
1809
+ }
1810
+ #agentSessionRow(token) {
1811
+ return rowAs(this.#database
1812
+ .prepare('SELECT * FROM agent_sessions WHERE token_hash = ?')
1813
+ .get(sha256(requiredText(token, 'agent session token'))));
1814
+ }
1815
+ #validatedAgentSession(token) {
1816
+ const row = this.#agentSessionRow(token);
1817
+ if (!row) {
1818
+ throw new AgentSessionError('Unknown agent session');
1819
+ }
1820
+ const now = this.#now();
1821
+ if (row.closed_at !== 0) {
1822
+ throw new AgentSessionError('Agent session is closed', 'closed_agent_session');
1823
+ }
1824
+ if (row.expires_at <= now) {
1825
+ throw new AgentSessionError('Agent session has expired', 'expired_agent_session');
1826
+ }
1827
+ const inbound = this.#inboundRow(row.source_message_key);
1828
+ const laterCustomer = inbound
1829
+ ? this.#database.prepare(`
1830
+ SELECT 1 FROM inbound_messages
1831
+ WHERE channel = ? AND open_kfid = ? AND external_userid = ?
1832
+ AND inbox_seq > ?
1833
+ AND origin = 'customer'
1834
+ AND status <> 'ignored'
1835
+ AND NOT (channel = 'weixin_ilink' AND status = 'absorbed')
1836
+ LIMIT 1
1837
+ `).get(row.channel, row.open_kfid, row.external_userid, row.boundary_inbox_seq)
1838
+ : undefined;
1839
+ const valid = inbound !== undefined &&
1840
+ inbound.open_kfid === row.open_kfid &&
1841
+ inbound.external_userid === row.external_userid &&
1842
+ inbound.channel === row.channel &&
1843
+ ['processing', 'preparing'].includes(inbound.status) &&
1844
+ laterCustomer === undefined;
1845
+ if (!valid) {
1846
+ throw new AgentSessionError('Agent session no longer matches the active conversation direction', 'stale_agent_session');
1847
+ }
1848
+ const used = rowAs(this.#database.prepare(`
1849
+ SELECT COUNT(*) AS count FROM send_attempts
1850
+ WHERE source_message_key = ?
1851
+ `).get(row.source_message_key));
1852
+ if (row.channel === 'wechat_kf' && Number(used?.count || 0) >= 5) {
1853
+ throw new AgentSessionError('WeChat permits at most five sends for this conversation turn', 'send_budget_exceeded');
1854
+ }
1855
+ return mapAgentSession(row, token);
1856
+ }
1857
+ #insertAttempt({ sourceMessageKey, accountKey, peerId, sendIndex, source = 'codex_tool', sentType, payload, metadata, channel, replyWindowId, }) {
1858
+ const index = Number(sendIndex);
1859
+ const inbound = this.#inboundRow(sourceMessageKey);
1860
+ if (!inbound)
1861
+ throw new Error(`Unknown inbound message: ${sourceMessageKey}`);
1862
+ if (accountKey !== inbound.open_kfid ||
1863
+ peerId !== inbound.external_userid ||
1864
+ (channel !== undefined && channel !== inbound.channel)) {
1865
+ throw new SendInvariantError('Send attempt identity does not match its source');
1866
+ }
1867
+ const actualChannel = inbound.channel;
1868
+ const maximum = actualChannel === 'wechat_kf' ? 5 : 1_000;
1869
+ if (!Number.isInteger(index) || index < 0 || index >= maximum) {
1870
+ throw new Error(`sendIndex must be an integer between 0 and ${maximum - 1}`);
1871
+ }
1872
+ const stableClientId = stableClientMessageId(sourceMessageKey, index);
1873
+ const exactPayload = payload ? canonicalValue(payload) : undefined;
1874
+ const payloadJson = encodeJson(exactPayload);
1875
+ const actualFingerprint = sha256(`${sentType}\0${payloadJson || ''}`);
1876
+ const actualAttemptKey = stableAttemptKey(sourceMessageKey, index);
1877
+ const now = this.#now();
1878
+ const result = this.#database
1879
+ .prepare(`
1880
+ INSERT INTO send_attempts (
1881
+ attempt_key, source_message_key, open_kfid, external_userid,
1882
+ channel, reply_window_id, send_index, source, sent_type,
1883
+ payload_json, metadata_json,
1884
+ fingerprint, client_message_id, status,
1885
+ wecom_msgid, error_code, error_message, fail_type,
1886
+ created_at, updated_at
1887
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1888
+ ON CONFLICT(source_message_key, send_index) DO NOTHING
1889
+ `)
1890
+ .run(actualAttemptKey, sourceMessageKey, accountKey, peerId, actualChannel, replyWindowId || null, index, String(source), requiredText(sentType, 'sentType'), payloadJson, encodeJson(metadata), actualFingerprint, stableClientId, 'pending', '', '', '', 0, now, now);
1891
+ const existing = rowAs(this.#database
1892
+ .prepare(`
1893
+ SELECT * FROM send_attempts
1894
+ WHERE source_message_key = ? AND send_index = ?
1895
+ `)
1896
+ .get(sourceMessageKey, index));
1897
+ if (!existing)
1898
+ throw new Error('Unable to reserve send attempt');
1899
+ if (existing.fingerprint !== actualFingerprint ||
1900
+ existing.client_message_id !== stableClientId ||
1901
+ existing.sent_type !== sentType ||
1902
+ existing.open_kfid !== accountKey ||
1903
+ existing.external_userid !== peerId ||
1904
+ existing.channel !== actualChannel ||
1905
+ Number(existing.reply_window_id || 0) !== Number(replyWindowId || 0) ||
1906
+ existing.source !== String(source)) {
1907
+ throw new SendInvariantError(`Send attempt invariant conflict for ${sourceMessageKey}:${index}`);
1908
+ }
1909
+ const attempt = mapAttempt(existing);
1910
+ if (!attempt)
1911
+ throw new Error('Unable to map reserved send attempt');
1912
+ return { inserted: result.changes === 1, attempt };
1913
+ }
1914
+ #settleSourceMessage(sourceMessageKey, now = this.#now()) {
1915
+ const activeRow = rowAs(this.#database
1916
+ .prepare(`
1917
+ SELECT COUNT(*) AS count
1918
+ FROM send_attempts
1919
+ WHERE source_message_key = ? AND status IN ('pending', 'sending')
1920
+ `)
1921
+ .get(sourceMessageKey));
1922
+ const active = Number(activeRow?.count ?? 0);
1923
+ if (Number(active) > 0)
1924
+ return;
1925
+ const activeAgent = rowAs(this.#database.prepare(`
1926
+ SELECT COUNT(*) AS count FROM agent_sessions
1927
+ WHERE source_message_key = ? AND closed_at = 0 AND expires_at > ?
1928
+ `).get(sourceMessageKey, now));
1929
+ if (Number(activeAgent?.count || 0) > 0)
1930
+ return;
1931
+ const source = this.#inboundRow(sourceMessageKey);
1932
+ const laterCustomer = source
1933
+ ? this.#database.prepare(`
1934
+ SELECT 1 FROM inbound_messages
1935
+ WHERE channel = ? AND open_kfid = ? AND external_userid = ?
1936
+ AND inbox_seq > ? AND origin = 'customer'
1937
+ AND status IN (
1938
+ 'received', 'processing', 'preparing', 'ready',
1939
+ 'steering', 'steered', 'failed'
1940
+ )
1941
+ LIMIT 1
1942
+ `).get(source.channel, source.open_kfid, source.external_userid, source.inbox_seq)
1943
+ : undefined;
1944
+ if (laterCustomer)
1945
+ return;
1946
+ this.#database
1947
+ .prepare(`
1948
+ UPDATE inbound_messages
1949
+ SET status = CASE WHEN status = 'suppressed' THEN status ELSE 'completed' END,
1950
+ payload_json = NULL,
1951
+ updated_at = ?
1952
+ WHERE message_key = ? AND status IN ('ready', 'processing', 'preparing')
1953
+ `)
1954
+ .run(now, sourceMessageKey);
1955
+ }
1956
+ #applyDeliveryFailure(attempt, failType, now) {
1957
+ this.#database.prepare(`
1958
+ UPDATE send_attempts
1959
+ SET status = 'failed', fail_type = ?, error_code = 'msg_send_fail',
1960
+ error_message = ?, updated_at = ?
1961
+ WHERE attempt_key = ?
1962
+ `).run(failType, `WeChat reported delivery failure (fail_type=${failType})`, now, attempt.attempt_key);
1963
+ this.#database.prepare(`
1964
+ UPDATE delivery_failures
1965
+ SET matched_attempt_key = ?, matched_at = ?
1966
+ WHERE wecom_msgid = ?
1967
+ `).run(attempt.attempt_key, now, attempt.wecom_msgid);
1968
+ }
1969
+ #suppressGroup(messageKey, reason, now) {
1970
+ this.#database.prepare(`
1971
+ UPDATE inbound_messages
1972
+ SET status = 'suppressed', error_message = ?, updated_at = ?
1973
+ WHERE (message_key = ? OR primary_message_key = ?)
1974
+ AND status NOT IN ('completed', 'ignored', 'absorbed')
1975
+ `).run(reason, now, messageKey, messageKey);
1976
+ this.#database.prepare(`
1977
+ UPDATE send_attempts
1978
+ SET status = 'failed', error_code = 'suppressed',
1979
+ error_message = ?, updated_at = ?
1980
+ WHERE source_message_key = ? AND status = 'pending'
1981
+ `).run(reason, now, messageKey);
1982
+ }
1983
+ #finishSending(attemptId, status, fields) {
1984
+ const current = this.#attemptRow(attemptId);
1985
+ if (!current)
1986
+ throw new Error(`Unknown send attempt: ${attemptId}`);
1987
+ if (current.status === status)
1988
+ return mapAttempt(current);
1989
+ if (current.status !== 'sending') {
1990
+ throw new Error(`Cannot mark send ${status} in status ${current.status}`);
1991
+ }
1992
+ const now = this.#now();
1993
+ this.#database.prepare(`
1994
+ UPDATE send_attempts
1995
+ SET status = ?, wecom_msgid = ?, error_code = ?, error_message = ?,
1996
+ updated_at = ?
1997
+ WHERE attempt_key = ? AND status = 'sending'
1998
+ `).run(status, fields.providerMessageId || '', fields.errorCode || '', fields.errorMessage || '', now, attemptId);
1999
+ this.#settleSourceMessage(current.source_message_key, now);
2000
+ return mapAttempt(this.#attemptRow(attemptId));
2001
+ }
2002
+ integrityCheck() {
2003
+ return rowsAs(this.#database.prepare('PRAGMA integrity_check').all());
2004
+ }
2005
+ foreignKeyCheck() {
2006
+ return rowsAs(this.#database.prepare('PRAGMA foreign_key_check').all());
2007
+ }
2008
+ checkpoint(mode = 'TRUNCATE') {
2009
+ if (!['PASSIVE', 'FULL', 'RESTART', 'TRUNCATE'].includes(mode)) {
2010
+ throw new Error(`Unsupported checkpoint mode: ${mode}`);
2011
+ }
2012
+ return rowAs(this.#database.prepare(`PRAGMA wal_checkpoint(${mode})`).get());
2013
+ }
2014
+ getCursor(accountKey) {
2015
+ const cursor = rowAs(this.#database
2016
+ .prepare('SELECT cursor FROM sync_cursors WHERE open_kfid = ?')
2017
+ .get(String(accountKey)))?.cursor;
2018
+ return cursor === undefined ? '' : String(cursor);
2019
+ }
2020
+ listSyncAccountKeys() {
2021
+ return rowsAs(this.#database.prepare(`
2022
+ SELECT open_kfid FROM sync_cursors ORDER BY open_kfid
2023
+ `).all()).map((row) => row.open_kfid);
2024
+ }
2025
+ registerSyncAccountKey(accountKey) {
2026
+ const service = requiredText(accountKey, 'accountKey');
2027
+ this.#database.prepare(`
2028
+ INSERT INTO sync_cursors (open_kfid, cursor, updated_at)
2029
+ VALUES (?, '', ?)
2030
+ ON CONFLICT(open_kfid) DO NOTHING
2031
+ `).run(service, this.#now());
2032
+ }
2033
+ /** @internal Shared inbox boundary for channel-specific persistence stores. */
2034
+ insertInboundMessages({ accountKey, entries, now, }) {
2035
+ const service = requiredText(accountKey, 'accountKey');
2036
+ if (!Array.isArray(entries))
2037
+ throw new Error('entries must be an array');
2038
+ const timestamp = now === undefined ? this.#now() : Number(now);
2039
+ if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
2040
+ throw new Error('now must be a non-negative safe integer');
2041
+ }
2042
+ const messages = entries.map(({ message, deferred = false }) => ({
2043
+ message: inboundInsert(service, message),
2044
+ deferred: Boolean(deferred),
2045
+ }));
2046
+ return this.#transaction(() => {
2047
+ const statement = this.#database.prepare(`
2048
+ INSERT INTO inbound_messages (
2049
+ message_key, open_kfid, msgid, external_userid, channel, origin, msg_type,
2050
+ sent_at, status, deferred, payload_json, created_at, updated_at
2051
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2052
+ ON CONFLICT(channel, open_kfid, msgid) DO NOTHING
2053
+ `);
2054
+ const findByProviderIdentity = this.#database.prepare(`
2055
+ SELECT message_key, inbox_seq, external_userid
2056
+ FROM inbound_messages
2057
+ WHERE channel = ? AND open_kfid = ? AND msgid = ?
2058
+ `);
2059
+ return messages.map(({ message, deferred }) => {
2060
+ const result = statement.run(message.messageKey, service, message.providerMessageId, message.peerId, message.channel, message.origin, message.type, message.sentAt, message.status, deferred ? 1 : 0, encodeJson(message.payload), timestamp, timestamp);
2061
+ this.#ensureConversation(message.channel, service, message.peerId, timestamp);
2062
+ const row = rowAs(findByProviderIdentity.get(message.channel, service, message.providerMessageId));
2063
+ if (!row || row.external_userid !== message.peerId) {
2064
+ throw new Error('Inbound message dedupe identity conflicts');
2065
+ }
2066
+ return {
2067
+ messageKey: row.message_key,
2068
+ inboxSeq: Number(row.inbox_seq),
2069
+ inserted: result.changes === 1,
2070
+ };
2071
+ });
2072
+ });
2073
+ }
2074
+ ingestSyncPage({ accountKey, expectedCursor = '', nextCursor = '', messages, deferred = false, }) {
2075
+ const service = requiredText(accountKey, 'accountKey');
2076
+ if (!Array.isArray(messages))
2077
+ throw new Error('messages must be an array');
2078
+ return this.#transaction(() => {
2079
+ const actualCursor = this.getCursor(service);
2080
+ if (actualCursor !== String(expectedCursor || '')) {
2081
+ throw new CursorConflictError(service, expectedCursor || '', actualCursor);
2082
+ }
2083
+ const now = this.#now();
2084
+ const insertedMessageKeys = this.insertInboundMessages({
2085
+ accountKey: service,
2086
+ entries: messages.map((message) => ({ message, deferred })),
2087
+ now,
2088
+ }).filter(({ inserted }) => inserted)
2089
+ .map(({ messageKey }) => messageKey);
2090
+ this.#database
2091
+ .prepare(`
2092
+ INSERT INTO sync_cursors (open_kfid, cursor, updated_at)
2093
+ VALUES (?, ?, ?)
2094
+ ON CONFLICT(open_kfid) DO UPDATE SET
2095
+ cursor = excluded.cursor,
2096
+ updated_at = excluded.updated_at
2097
+ `)
2098
+ .run(service, String(nextCursor || ''), now);
2099
+ return { insertedMessageKeys, cursor: String(nextCursor || '') };
2100
+ });
2101
+ }
2102
+ promoteDeferredConversation({ channel, accountKey, peerId, }) {
2103
+ return this.#transaction(() => {
2104
+ const service = requiredText(accountKey, 'accountKey');
2105
+ const customer = String(peerId || '');
2106
+ this.#database.prepare(`
2107
+ UPDATE inbound_messages SET deferred = 0, updated_at = ?
2108
+ WHERE channel = ? AND open_kfid = ? AND external_userid = ?
2109
+ AND deferred = 1 AND status = 'received'
2110
+ `).run(this.#now(), channel, service, customer);
2111
+ return rowsAs(this.#database.prepare(`
2112
+ SELECT * FROM inbound_messages
2113
+ WHERE channel = ? AND open_kfid = ? AND external_userid = ?
2114
+ AND deferred = 0 AND status = 'received'
2115
+ ORDER BY inbox_seq
2116
+ `).all(channel, service, customer)).map((row) => mapInbound(row));
2117
+ });
2118
+ }
2119
+ activateNextDeferredConversation(channels = ['wechat_kf', 'weixin_ilink']) {
2120
+ const selected = [...new Set(channels)];
2121
+ if (!selected.length)
2122
+ return [];
2123
+ const channelPlaceholders = selected.map(() => '?').join(',');
2124
+ return this.#transaction(() => {
2125
+ const next = rowAs(this.#database.prepare(`
2126
+ SELECT open_kfid, external_userid, channel FROM inbound_messages
2127
+ WHERE deferred = 1 AND status = 'received'
2128
+ AND channel IN (${channelPlaceholders})
2129
+ ORDER BY inbox_seq LIMIT 1
2130
+ `).get(...selected));
2131
+ if (!next)
2132
+ return [];
2133
+ this.#database.prepare(`
2134
+ UPDATE inbound_messages SET deferred = 0, updated_at = ?
2135
+ WHERE open_kfid = ? AND external_userid = ?
2136
+ AND channel = ?
2137
+ AND deferred = 1 AND status = 'received'
2138
+ `).run(this.#now(), next.open_kfid, next.external_userid, next.channel);
2139
+ return rowsAs(this.#database.prepare(`
2140
+ SELECT * FROM inbound_messages
2141
+ WHERE open_kfid = ? AND external_userid = ?
2142
+ AND channel = ?
2143
+ AND deferred = 0 AND status = 'received'
2144
+ ORDER BY inbox_seq
2145
+ `).all(next.open_kfid, next.external_userid, next.channel))
2146
+ .map((row) => mapInbound(row));
2147
+ });
2148
+ }
2149
+ getInbound(messageKey) {
2150
+ return mapInbound(this.#inboundRow(messageKey));
2151
+ }
2152
+ createAgentSession({ messageKey, boundaryMessageKey = messageKey, ttlMs = 15 * 60 * 1000, }) {
2153
+ return this.#transaction(() => {
2154
+ const inbound = this.#inboundRow(requiredText(messageKey, 'messageKey'));
2155
+ if (!inbound || !['processing', 'preparing'].includes(inbound.status)) {
2156
+ throw new AgentSessionError('Agent session requires an active inbound message');
2157
+ }
2158
+ const boundary = this.#inboundRow(boundaryMessageKey);
2159
+ if (!boundary ||
2160
+ boundary.open_kfid !== inbound.open_kfid ||
2161
+ boundary.external_userid !== inbound.external_userid ||
2162
+ boundary.channel !== inbound.channel) {
2163
+ throw new AgentSessionError('Agent session boundary is outside the conversation');
2164
+ }
2165
+ const boundedTtl = Math.max(1_000, Math.min(Number(ttlMs) || 0, 60 * 60 * 1000));
2166
+ const memoryThreadId = this.getConversation(inbound.channel, inbound.open_kfid, inbound.external_userid)?.memoryThreadId || '';
2167
+ const now = this.#now();
2168
+ const replyWindowId = inbound.channel === 'weixin_ilink'
2169
+ ? Number(rowAs(this.#database.prepare(`
2170
+ SELECT reply_window_id FROM ilink_reply_windows
2171
+ WHERE source_message_key = ?
2172
+ `).get(boundary.message_key))?.reply_window_id || 0)
2173
+ : 0;
2174
+ if (inbound.channel === 'weixin_ilink' && !replyWindowId) {
2175
+ throw new AgentSessionError('iLink message has no reply window');
2176
+ }
2177
+ const token = `ws_${randomBytes(24).toString('base64url')}`;
2178
+ this.#database.prepare(`
2179
+ UPDATE agent_sessions SET closed_at = ?, updated_at = ?
2180
+ WHERE channel = ? AND open_kfid = ? AND external_userid = ?
2181
+ AND closed_at = 0 AND expires_at > ?
2182
+ `).run(now, now, inbound.channel, inbound.open_kfid, inbound.external_userid, now);
2183
+ this.#database.prepare(`
2184
+ INSERT INTO agent_sessions (
2185
+ token_hash, source_message_key, open_kfid, external_userid,
2186
+ channel, reply_window_id,
2187
+ boundary_inbox_seq, memory_thread_id,
2188
+ media_json, expires_at,
2189
+ closed_at, created_at, updated_at
2190
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
2191
+ `).run(sha256(token), inbound.message_key, inbound.open_kfid, inbound.external_userid, inbound.channel, replyWindowId || null, boundary.inbox_seq, memoryThreadId, encodeJson(this.listRecentMedia({
2192
+ channel: inbound.channel,
2193
+ accountKey: inbound.open_kfid,
2194
+ peerId: inbound.external_userid,
2195
+ limit: 10,
2196
+ })) || '[]', now + boundedTtl, now, now);
2197
+ return mapAgentSession(this.#agentSessionRow(token), token);
2198
+ });
2199
+ }
2200
+ getAgentSession(token) {
2201
+ return this.#validatedAgentSession(token);
2202
+ }
2203
+ closeAgentSession(token) {
2204
+ const now = this.#now();
2205
+ return Number(this.#database.prepare(`
2206
+ UPDATE agent_sessions SET closed_at = ?, updated_at = ?
2207
+ WHERE token_hash = ? AND closed_at = 0
2208
+ `).run(now, now, sha256(requiredText(token, 'agent session token'))).changes) === 1;
2209
+ }
2210
+ closeAgentSessions(messageKey) {
2211
+ const now = this.#now();
2212
+ return Number(this.#database.prepare(`
2213
+ UPDATE agent_sessions SET closed_at = ?, updated_at = ?
2214
+ WHERE source_message_key = ? AND closed_at = 0
2215
+ `).run(now, now, String(messageKey)).changes);
2216
+ }
2217
+ registerAgentArtifact({ sessionToken, bytes, filename, contentType, metadata, }) {
2218
+ const session = this.#validatedAgentSession(sessionToken);
2219
+ if (!Buffer.isBuffer(bytes) || bytes.length < 6 || bytes.length > MAX_WECHAT_IMAGE_BYTES) {
2220
+ throw new AgentSessionError('Agent image artifact must contain 6 bytes to 2 MiB');
2221
+ }
2222
+ const format = detectImageFormat(bytes);
2223
+ if (!format || format.mimeType !== contentType || !['image/png', 'image/jpeg'].includes(contentType)) {
2224
+ throw new AgentSessionError('Agent image artifact must be matching PNG or JPEG bytes');
2225
+ }
2226
+ const safeFilename = String(filename || 'generated-image')
2227
+ .replace(/[\r\n"\\/]/gu, '_');
2228
+ if (Buffer.byteLength(safeFilename, 'utf8') > 128) {
2229
+ throw new AgentSessionError('Agent image artifact filename exceeds 128 UTF-8 bytes');
2230
+ }
2231
+ const tokenHash = sha256(session.token);
2232
+ const count = rowAs(this.#database.prepare(`
2233
+ SELECT COUNT(*) AS count FROM agent_artifacts WHERE token_hash = ?
2234
+ `).get(tokenHash));
2235
+ if (Number(count?.count || 0) >= 5) {
2236
+ throw new AgentSessionError('Agent session permits at most five artifacts');
2237
+ }
2238
+ const ref = `artifact:${Number(count?.count || 0)}`;
2239
+ this.#database.prepare(`
2240
+ INSERT INTO agent_artifacts (
2241
+ token_hash, ref, bytes, filename, content_type, metadata_json, created_at
2242
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
2243
+ `).run(tokenHash, ref, bytes, safeFilename, contentType, encodeJson(metadata), this.#now());
2244
+ return ref;
2245
+ }
2246
+ getAgentArtifact(sessionToken, ref) {
2247
+ const session = this.#validatedAgentSession(sessionToken);
2248
+ const row = rowAs(this.#database.prepare(`
2249
+ SELECT bytes, filename, content_type, metadata_json FROM agent_artifacts
2250
+ WHERE token_hash = ? AND ref = ?
2251
+ `).get(sha256(session.token), String(ref)));
2252
+ if (!row) {
2253
+ throw new AgentSessionError('The generated artifact is unavailable', 'invalid_media_reference');
2254
+ }
2255
+ const metadata = objectJson(row.metadata_json);
2256
+ return {
2257
+ bytes: Buffer.from(row.bytes),
2258
+ filename: row.filename,
2259
+ contentType: row.content_type,
2260
+ ...(metadata ? { metadata } : {}),
2261
+ };
2262
+ }
2263
+ reserveAgentSend({ sessionToken, sentType, payload, metadata, }) {
2264
+ return this.#transaction(() => {
2265
+ const session = this.#validatedAgentSession(sessionToken);
2266
+ if (session.channel !== 'wechat_kf') {
2267
+ throw new AgentSessionError('Agent session is bound to another channel', 'wrong_channel');
2268
+ }
2269
+ const indexRow = rowAs(this.#database.prepare(`
2270
+ SELECT COALESCE(MAX(send_index) + 1, 0) AS next_index
2271
+ FROM send_attempts WHERE source_message_key = ?
2272
+ `).get(session.messageKey));
2273
+ const sendIndex = Number(indexRow?.next_index ?? 0);
2274
+ if (!Number.isInteger(sendIndex) || sendIndex < 0 || sendIndex >= 5) {
2275
+ throw new AgentSessionError('WeChat permits at most five sends for this conversation turn', 'send_budget_exceeded');
2276
+ }
2277
+ const directionRow = rowAs(this.#database.prepare(`
2278
+ SELECT MAX(inbox_seq) AS direction FROM inbound_messages
2279
+ WHERE message_key = ? OR (
2280
+ primary_message_key = ? AND status IN ('steering', 'steered')
2281
+ )
2282
+ `).get(session.messageKey, session.messageKey));
2283
+ const reserved = this.#insertAttempt({
2284
+ sourceMessageKey: session.messageKey,
2285
+ accountKey: session.accountKey,
2286
+ peerId: session.peerId,
2287
+ sendIndex,
2288
+ source: 'mcp_tool',
2289
+ sentType,
2290
+ payload,
2291
+ metadata: {
2292
+ ...(metadata || {}),
2293
+ direction: Number(directionRow?.direction || session.boundaryInboxSeq),
2294
+ },
2295
+ }).attempt;
2296
+ const claimed = this.#database.prepare(`
2297
+ UPDATE send_attempts SET status = 'sending', updated_at = ?
2298
+ WHERE attempt_key = ? AND status = 'pending'
2299
+ `).run(this.#now(), reserved.attemptId);
2300
+ if (claimed.changes !== 1) {
2301
+ throw new SendInvariantError(`Cannot claim MCP send ${reserved.attemptId}`);
2302
+ }
2303
+ return mapAttempt(this.#attemptRow(reserved.attemptId));
2304
+ });
2305
+ }
2306
+ getAttempt(attemptId) {
2307
+ return mapAttempt(this.#attemptRow(String(attemptId || '')));
2308
+ }
2309
+ reserveQueueNotice(messageKey, content = 'Your conversation is queued. Please wait.') {
2310
+ return this.#transaction(() => {
2311
+ const inbound = this.#inboundRow(requiredText(messageKey, 'messageKey'));
2312
+ if (!inbound || inbound.status !== 'received' || inbound.deferred === 1) {
2313
+ throw new Error('Queue notice requires a live received message');
2314
+ }
2315
+ const existing = rowAs(this.#database.prepare(`
2316
+ SELECT * FROM send_attempts
2317
+ WHERE source_message_key = ? AND source = 'queue_notice'
2318
+ LIMIT 1
2319
+ `).get(messageKey));
2320
+ if (existing)
2321
+ return mapAttempt(existing);
2322
+ const next = rowAs(this.#database.prepare(`
2323
+ SELECT COALESCE(MAX(send_index) + 1, 0) AS send_index
2324
+ FROM send_attempts WHERE source_message_key = ?
2325
+ `).get(messageKey));
2326
+ return this.#insertAttempt({
2327
+ sourceMessageKey: messageKey,
2328
+ accountKey: inbound.open_kfid,
2329
+ peerId: inbound.external_userid,
2330
+ sendIndex: Number(next?.send_index || 0),
2331
+ source: 'queue_notice',
2332
+ sentType: 'text',
2333
+ payload: { msgtype: 'text', text: { content: String(content) } },
2334
+ }).attempt;
2335
+ });
2336
+ }
2337
+ listMessageAttempts(messageKey) {
2338
+ return rowsAs(this.#database.prepare(`
2339
+ SELECT * FROM send_attempts
2340
+ WHERE source_message_key = ? ORDER BY send_index
2341
+ `).all(String(messageKey || ''))).map((row) => mapAttempt(row));
2342
+ }
2343
+ finalizeAgentExecution({ messageKey, steeringMessageKeys = [], attemptIds, }) {
2344
+ if (!attemptIds.length || attemptIds.length > 1_000) {
2345
+ throw new Error('Agent execution must reference 1 to 1000 MCP attempts');
2346
+ }
2347
+ return this.#transaction(() => {
2348
+ const primary = this.#inboundRow(messageKey);
2349
+ if (!primary)
2350
+ throw new Error(`Unknown inbound message: ${messageKey}`);
2351
+ const uniqueAttempts = [...new Set(attemptIds.map(String))];
2352
+ if (uniqueAttempts.length !== attemptIds.length) {
2353
+ throw new Error('Agent execution contains duplicate MCP attempts');
2354
+ }
2355
+ const durable = rowsAs(this.#database.prepare(`
2356
+ SELECT attempt_key, status, channel, reply_window_id, error_code
2357
+ FROM send_attempts
2358
+ WHERE source_message_key = ? AND source = 'mcp_tool'
2359
+ `).all(messageKey));
2360
+ const reported = new Set(uniqueAttempts);
2361
+ const counts = new Map();
2362
+ for (const attempt of durable) {
2363
+ if ([
2364
+ 'abandoned_before_transmit',
2365
+ 'cancelled_before_transmit',
2366
+ 'reply_window_expired',
2367
+ 'reply_quota_exhausted',
2368
+ ].includes(attempt.error_code)) {
2369
+ continue;
2370
+ }
2371
+ const key = attempt.channel === 'weixin_ilink'
2372
+ ? `ilink:${Number(attempt.reply_window_id || 0)}`
2373
+ : 'wechat_kf';
2374
+ if ((attempt.channel === 'weixin_ilink' && !attempt.reply_window_id) ||
2375
+ (attempt.channel === 'wechat_kf' && attempt.reply_window_id)) {
2376
+ throw new Error('Agent execution contains a channel/window mismatch');
2377
+ }
2378
+ counts.set(key, (counts.get(key) || 0) + 1);
2379
+ }
2380
+ if ([...counts].some(([key, count]) => count > (key === 'wechat_kf' ? 5 : 10))) {
2381
+ throw new Error('Agent execution exceeds a channel reply-window budget');
2382
+ }
2383
+ if (durable.length !== uniqueAttempts.length ||
2384
+ durable.some((attempt) => !reported.has(attempt.attempt_key) ||
2385
+ !['accepted', 'failed', 'uncertain'].includes(attempt.status))) {
2386
+ throw new Error('Agent execution does not match every terminal MCP attempt');
2387
+ }
2388
+ const now = this.#now();
2389
+ const updated = this.#database.prepare(`
2390
+ UPDATE inbound_messages
2391
+ SET status = 'completed', payload_json = NULL, updated_at = ?
2392
+ WHERE message_key = ? AND status IN (
2393
+ 'processing', 'preparing', 'ready', 'completed'
2394
+ )
2395
+ `).run(now, messageKey);
2396
+ if (updated.changes !== 1) {
2397
+ throw new Error(`Cannot finalize agent execution in status ${primary.status}`);
2398
+ }
2399
+ const steeringKeys = [...new Set(steeringMessageKeys.map(String))];
2400
+ if (steeringKeys.length) {
2401
+ const steeringPlaceholders = steeringKeys.map(() => '?').join(',');
2402
+ const absorbed = this.#database.prepare(`
2403
+ UPDATE inbound_messages
2404
+ SET status = 'absorbed', payload_json = NULL, updated_at = ?
2405
+ WHERE message_key IN (${steeringPlaceholders})
2406
+ AND status = 'steered' AND primary_message_key = ?
2407
+ `).run(now, ...steeringKeys, messageKey);
2408
+ if (absorbed.changes !== steeringKeys.length) {
2409
+ throw new Error('Not every steering message belongs to the MCP execution');
2410
+ }
2411
+ }
2412
+ this.#database.prepare(`
2413
+ UPDATE agent_sessions SET closed_at = ?, updated_at = ?
2414
+ WHERE source_message_key = ? AND closed_at = 0
2415
+ `).run(now, now, messageKey);
2416
+ this.#database.prepare(`
2417
+ UPDATE conversations SET memory_thread_id = '', updated_at = ?
2418
+ WHERE channel = ? AND open_kfid = ? AND external_userid = ?
2419
+ `).run(now, primary.channel, primary.open_kfid, primary.external_userid);
2420
+ return this.getInbound(messageKey);
2421
+ });
2422
+ }
2423
+ listPendingInbound({ statuses = [
2424
+ 'received',
2425
+ 'processing',
2426
+ 'preparing',
2427
+ 'steering',
2428
+ 'steered',
2429
+ 'ready',
2430
+ ], channel, accountKey, peerId, limit = 100, } = {}) {
2431
+ const selected = [...new Set(statuses)];
2432
+ if (!selected.length)
2433
+ return [];
2434
+ const hasIdentity = channel !== undefined || accountKey !== undefined ||
2435
+ peerId !== undefined;
2436
+ if (hasIdentity &&
2437
+ (channel === undefined || !accountKey || !peerId)) {
2438
+ throw new Error('channel, accountKey, and peerId must be provided together');
2439
+ }
2440
+ const clauses = [`status IN (${selected.map(() => '?').join(',')})`];
2441
+ const parameters = [...selected];
2442
+ if (channel) {
2443
+ clauses.push('channel = ?');
2444
+ parameters.push(channel);
2445
+ }
2446
+ if (accountKey) {
2447
+ clauses.push('open_kfid = ?');
2448
+ parameters.push(String(accountKey));
2449
+ }
2450
+ if (peerId) {
2451
+ clauses.push('external_userid = ?');
2452
+ parameters.push(String(peerId));
2453
+ }
2454
+ parameters.push(Math.max(1, Math.min(Number(limit) || 100, 1000)));
2455
+ return rowsAs(this.#database
2456
+ .prepare(`
2457
+ SELECT * FROM inbound_messages
2458
+ WHERE ${clauses.join(' AND ')}
2459
+ ORDER BY inbox_seq
2460
+ LIMIT ?
2461
+ `)
2462
+ .all(...parameters))
2463
+ .map((row) => mapInbound(row));
2464
+ }
2465
+ getConversation(channel, accountKey, peerId) {
2466
+ return mapConversation(rowAs(this.#database
2467
+ .prepare(`
2468
+ SELECT * FROM conversations
2469
+ WHERE channel = ? AND open_kfid = ? AND external_userid = ?
2470
+ `)
2471
+ .get(channel, String(accountKey), String(peerId))));
2472
+ }
2473
+ setConversationThread({ channel, accountKey, peerId, threadId, memoryThreadId = '', }) {
2474
+ return this.#transaction(() => {
2475
+ const now = this.#now();
2476
+ this.#database
2477
+ .prepare(`
2478
+ INSERT INTO conversations (
2479
+ channel, open_kfid, external_userid,
2480
+ thread_id, memory_thread_id, updated_at
2481
+ ) VALUES (?, ?, ?, ?, ?, ?)
2482
+ ON CONFLICT(channel, open_kfid, external_userid) DO UPDATE SET
2483
+ thread_id = excluded.thread_id,
2484
+ memory_thread_id = excluded.memory_thread_id,
2485
+ updated_at = excluded.updated_at
2486
+ `)
2487
+ .run(channel, accountKey, peerId, String(threadId || ''), String(memoryThreadId || ''), now);
2488
+ return this.getConversation(channel, accountKey, peerId);
2489
+ });
2490
+ }
2491
+ getAuthorization(peerId) {
2492
+ const row = rowAs(this.#database
2493
+ .prepare('SELECT * FROM authorizations WHERE external_userid = ?')
2494
+ .get(String(peerId)));
2495
+ if (!row)
2496
+ return undefined;
2497
+ return {
2498
+ peerId: row.external_userid,
2499
+ authorized: row.authorized === 1,
2500
+ consecutiveMatches: row.consecutive_matches,
2501
+ lastAccountKey: row.last_open_kfid,
2502
+ lastMessageKey: row.last_message_key,
2503
+ authorizedAt: row.authorized_at,
2504
+ updatedAt: row.updated_at,
2505
+ };
2506
+ }
2507
+ evaluateAuthorization({ messageKey, accountKey, peerId, isTrigger, requiredConsecutive = 3, confirmationText = 'Code accepted. You can continue the conversation.', }) {
2508
+ return this.#transaction(() => {
2509
+ const message = this.#inboundRow(messageKey);
2510
+ if (!message)
2511
+ throw new Error(`Unknown inbound message: ${messageKey}`);
2512
+ if (message.channel !== 'wechat_kf' ||
2513
+ message.open_kfid !== accountKey ||
2514
+ message.external_userid !== peerId) {
2515
+ throw new Error('Authorization target does not match inbound message');
2516
+ }
2517
+ const current = this.getAuthorization(peerId);
2518
+ if (current?.authorized && current.lastMessageKey === messageKey) {
2519
+ return {
2520
+ decision: 'duplicate',
2521
+ consecutiveMatches: current.consecutiveMatches,
2522
+ };
2523
+ }
2524
+ if (current?.authorized) {
2525
+ return {
2526
+ decision: 'already_authorized',
2527
+ consecutiveMatches: current.consecutiveMatches,
2528
+ };
2529
+ }
2530
+ if (current?.lastMessageKey === messageKey) {
2531
+ return {
2532
+ decision: 'duplicate',
2533
+ consecutiveMatches: current.consecutiveMatches,
2534
+ };
2535
+ }
2536
+ const threshold = Math.max(1, Number(requiredConsecutive) || 3);
2537
+ const consecutiveMatches = isTrigger
2538
+ ? (current?.lastAccountKey === accountKey
2539
+ ? current.consecutiveMatches
2540
+ : 0) + 1
2541
+ : 0;
2542
+ const newlyAuthorized = consecutiveMatches >= threshold;
2543
+ const now = this.#now();
2544
+ this.#database
2545
+ .prepare(`
2546
+ INSERT INTO authorizations (
2547
+ external_userid, authorized, consecutive_matches,
2548
+ last_open_kfid, last_message_key, authorized_at, updated_at
2549
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
2550
+ ON CONFLICT(external_userid) DO UPDATE SET
2551
+ authorized = excluded.authorized,
2552
+ consecutive_matches = excluded.consecutive_matches,
2553
+ last_open_kfid = excluded.last_open_kfid,
2554
+ last_message_key = excluded.last_message_key,
2555
+ authorized_at = excluded.authorized_at,
2556
+ updated_at = excluded.updated_at
2557
+ `)
2558
+ .run(peerId, newlyAuthorized ? 1 : 0, consecutiveMatches, accountKey, messageKey, newlyAuthorized ? now : 0, now);
2559
+ if (newlyAuthorized) {
2560
+ this.#insertAttempt({
2561
+ sourceMessageKey: messageKey,
2562
+ accountKey,
2563
+ peerId,
2564
+ sendIndex: 0,
2565
+ source: 'authorization',
2566
+ sentType: 'text',
2567
+ payload: {
2568
+ msgtype: 'text',
2569
+ text: { content: String(confirmationText) },
2570
+ },
2571
+ });
2572
+ this.#database
2573
+ .prepare(`
2574
+ UPDATE inbound_messages
2575
+ SET status = 'ready',
2576
+ updated_at = ?
2577
+ WHERE message_key = ?
2578
+ `)
2579
+ .run(now, messageKey);
2580
+ }
2581
+ else {
2582
+ this.#database
2583
+ .prepare(`
2584
+ UPDATE inbound_messages
2585
+ SET status = 'ignored', payload_json = NULL, updated_at = ?
2586
+ WHERE message_key = ?
2587
+ `)
2588
+ .run(now, messageKey);
2589
+ }
2590
+ return {
2591
+ decision: newlyAuthorized ? 'authorized_now' : 'blocked',
2592
+ consecutiveMatches,
2593
+ };
2594
+ });
2595
+ }
2596
+ claimInbound({ messageKey, clientInputId = messageKey, }) {
2597
+ return this.#transaction(() => {
2598
+ const row = this.#inboundRow(messageKey);
2599
+ if (!row)
2600
+ throw new Error(`Unknown inbound message: ${messageKey}`);
2601
+ if (['processing', 'preparing'].includes(row.status)) {
2602
+ if (clientInputId && row.client_input_id !== String(clientInputId)) {
2603
+ this.#database
2604
+ .prepare(`
2605
+ UPDATE inbound_messages
2606
+ SET client_input_id = ?, updated_at = ?
2607
+ WHERE message_key = ?
2608
+ `)
2609
+ .run(String(clientInputId), this.#now(), messageKey);
2610
+ }
2611
+ return this.getInbound(messageKey);
2612
+ }
2613
+ if (row.status !== 'received' && row.status !== 'failed') {
2614
+ throw new Error(`Cannot claim inbound message in status ${row.status}`);
2615
+ }
2616
+ const now = this.#now();
2617
+ this.#database
2618
+ .prepare(`
2619
+ UPDATE inbound_messages
2620
+ SET status = 'processing',
2621
+ client_input_id = ?,
2622
+ error_message = '',
2623
+ updated_at = ?
2624
+ WHERE message_key = ?
2625
+ `)
2626
+ .run(String(clientInputId || messageKey), now, messageKey);
2627
+ return this.getInbound(messageKey);
2628
+ });
2629
+ }
2630
+ markInboundPreparing(messageKey, codexTurnId = '') {
2631
+ const result = this.#database
2632
+ .prepare(`
2633
+ UPDATE inbound_messages
2634
+ SET status = 'preparing', codex_turn_id = ?, updated_at = ?
2635
+ WHERE message_key = ? AND status IN ('processing', 'preparing')
2636
+ `)
2637
+ .run(String(codexTurnId || ''), this.#now(), messageKey);
2638
+ if (result.changes !== 1) {
2639
+ throw new Error(`Cannot mark inbound preparing: ${messageKey}`);
2640
+ }
2641
+ return this.getInbound(messageKey);
2642
+ }
2643
+ beginInboundSteering({ messageKey, primaryMessageKey, clientInputId = messageKey, }) {
2644
+ return this.#transaction(() => {
2645
+ const message = this.#inboundRow(messageKey);
2646
+ const primary = this.#inboundRow(primaryMessageKey);
2647
+ if (!message || !primary)
2648
+ throw new Error('Unknown steer message group');
2649
+ if (!['processing', 'preparing'].includes(primary.status)) {
2650
+ throw new Error(`Primary message is not steerable in status ${primary.status}`);
2651
+ }
2652
+ if (message.channel !== primary.channel ||
2653
+ message.open_kfid !== primary.open_kfid ||
2654
+ message.external_userid !== primary.external_userid) {
2655
+ throw new Error('Steer message must belong to the primary conversation');
2656
+ }
2657
+ const updated = this.#database
2658
+ .prepare(`
2659
+ UPDATE inbound_messages
2660
+ SET status = 'steering',
2661
+ primary_message_key = ?,
2662
+ codex_turn_id = ?,
2663
+ client_input_id = ?,
2664
+ steering_boundary = 0,
2665
+ updated_at = ?
2666
+ WHERE message_key = ? AND status = 'received'
2667
+ `)
2668
+ .run(primaryMessageKey, String(primary.codex_turn_id || ''), String(clientInputId || messageKey), this.#now(), messageKey);
2669
+ if (updated.changes !== 1) {
2670
+ throw new Error(`Cannot begin inbound steering: ${messageKey}`);
2671
+ }
2672
+ return this.getInbound(messageKey);
2673
+ });
2674
+ }
2675
+ confirmInboundSteered(messageKey, { codexTurnId = '', steeringBoundary = 0, } = {}) {
2676
+ const updated = this.#database
2677
+ .prepare(`
2678
+ UPDATE inbound_messages
2679
+ SET status = 'steered', codex_turn_id = ?, steering_boundary = ?,
2680
+ updated_at = ?
2681
+ WHERE message_key = ? AND status = 'steering'
2682
+ `)
2683
+ .run(String(codexTurnId || ''), Number(steeringBoundary || 0), this.#now(), String(messageKey));
2684
+ if (updated.changes !== 1) {
2685
+ throw new Error(`Cannot confirm inbound steered: ${messageKey}`);
2686
+ }
2687
+ return this.getInbound(messageKey);
2688
+ }
2689
+ requeueInboundSteering(messageKey, primaryMessageKey) {
2690
+ return this.#transaction(() => {
2691
+ const updated = this.#database
2692
+ .prepare(`
2693
+ UPDATE inbound_messages
2694
+ SET status = 'received',
2695
+ primary_message_key = NULL,
2696
+ codex_turn_id = '',
2697
+ client_input_id = '',
2698
+ steering_boundary = 0,
2699
+ error_message = '',
2700
+ updated_at = ?
2701
+ WHERE message_key = ? AND status = 'steering'
2702
+ AND primary_message_key = ?
2703
+ `)
2704
+ .run(this.#now(), String(messageKey), String(primaryMessageKey));
2705
+ if (updated.changes !== 1) {
2706
+ throw new Error(`Cannot requeue steering ${messageKey} for primary ${primaryMessageKey}`);
2707
+ }
2708
+ return this.getInbound(messageKey);
2709
+ });
2710
+ }
2711
+ failInbound(messageKey, error) {
2712
+ return this.#transaction(() => {
2713
+ const now = this.#now();
2714
+ const message = errorMessage(error) || 'unknown error';
2715
+ const primary = this.#database
2716
+ .prepare(`
2717
+ UPDATE inbound_messages
2718
+ SET status = 'failed', error_message = ?, updated_at = ?
2719
+ WHERE message_key = ? AND status IN ('processing', 'preparing')
2720
+ `)
2721
+ .run(message, now, messageKey);
2722
+ if (primary.changes === 1) {
2723
+ this.#database
2724
+ .prepare(`
2725
+ UPDATE inbound_messages
2726
+ SET status = 'received', primary_message_key = NULL,
2727
+ codex_turn_id = '', client_input_id = '', steering_boundary = 0,
2728
+ error_message = ?, updated_at = ?
2729
+ WHERE primary_message_key = ? AND status IN ('steering', 'steered')
2730
+ AND codex_turn_id = ''
2731
+ `)
2732
+ .run(message, now, messageKey);
2733
+ }
2734
+ return this.getInbound(messageKey);
2735
+ });
2736
+ }
2737
+ deferActiveInbound(messageKey) {
2738
+ return this.#transaction(() => {
2739
+ const attempts = rowAs(this.#database.prepare(`
2740
+ SELECT COUNT(*) AS count FROM send_attempts
2741
+ WHERE source_message_key = ?
2742
+ `).get(messageKey));
2743
+ if (Number(attempts?.count || 0) > 0)
2744
+ return false;
2745
+ const now = this.#now();
2746
+ const updated = this.#database.prepare(`
2747
+ UPDATE inbound_messages
2748
+ SET status = 'received', deferred = 1,
2749
+ primary_message_key = NULL, codex_turn_id = '',
2750
+ client_input_id = '', steering_boundary = 0,
2751
+ error_message = '', updated_at = ?
2752
+ WHERE (message_key = ? OR primary_message_key = ?)
2753
+ AND status IN (
2754
+ 'failed', 'processing', 'preparing', 'steering', 'steered'
2755
+ )
2756
+ `).run(now, messageKey, messageKey);
2757
+ this.#database.prepare(`
2758
+ UPDATE agent_sessions SET closed_at = ?, updated_at = ?
2759
+ WHERE source_message_key = ? AND closed_at = 0
2760
+ `).run(now, now, messageKey);
2761
+ return updated.changes > 0;
2762
+ });
2763
+ }
2764
+ markInboundIgnored(messageKey) {
2765
+ const updated = this.#database
2766
+ .prepare(`
2767
+ UPDATE inbound_messages
2768
+ SET status = 'ignored', payload_json = NULL, updated_at = ?
2769
+ WHERE message_key = ? AND status = 'received'
2770
+ `)
2771
+ .run(this.#now(), messageKey);
2772
+ if (updated.changes !== 1) {
2773
+ throw new Error(`Cannot ignore inbound message: ${messageKey}`);
2774
+ }
2775
+ return this.getInbound(messageKey);
2776
+ }
2777
+ markInboundCompleted(messageKey) {
2778
+ const updated = this.#database
2779
+ .prepare(`
2780
+ UPDATE inbound_messages
2781
+ SET status = 'completed', payload_json = NULL, updated_at = ?
2782
+ WHERE message_key = ? AND status IN ('received', 'processing', 'preparing')
2783
+ `)
2784
+ .run(this.#now(), messageKey);
2785
+ if (updated.changes !== 1) {
2786
+ throw new Error(`Cannot complete inbound message: ${messageKey}`);
2787
+ }
2788
+ return this.getInbound(messageKey);
2789
+ }
2790
+ suppressInbound(messageKey, reason = 'automation_suppressed') {
2791
+ return this.#transaction(() => {
2792
+ const now = this.#now();
2793
+ this.#suppressGroup(messageKey, reason, now);
2794
+ return this.getInbound(messageKey);
2795
+ });
2796
+ }
2797
+ listRecentConversationAttempts({ channel, accountKey, peerId, limit = 20, }) {
2798
+ return rowsAs(this.#database
2799
+ .prepare(`
2800
+ SELECT * FROM send_attempts
2801
+ WHERE channel = ? AND open_kfid = ? AND external_userid = ?
2802
+ AND status IN ('accepted', 'failed', 'uncertain')
2803
+ ORDER BY updated_at DESC
2804
+ LIMIT ?
2805
+ `)
2806
+ .all(channel, requiredText(accountKey, 'accountKey'), requiredText(peerId, 'peerId'), Math.max(1, Math.min(Number(limit) || 20, 100))))
2807
+ .map((row) => mapAttempt(row));
2808
+ }
2809
+ beginNextSend(channel) {
2810
+ return this.#transaction(() => {
2811
+ const candidate = rowAs(this.#database.prepare(`
2812
+ SELECT * FROM send_attempts
2813
+ WHERE channel = ? AND status = 'pending' AND NOT EXISTS (
2814
+ SELECT 1 FROM send_attempts AS active
2815
+ WHERE active.status = 'sending'
2816
+ AND active.channel = send_attempts.channel
2817
+ AND active.open_kfid = send_attempts.open_kfid
2818
+ AND active.external_userid = send_attempts.external_userid
2819
+ )
2820
+ ORDER BY created_at, send_index
2821
+ LIMIT 1
2822
+ `).get(channel));
2823
+ if (!candidate)
2824
+ return undefined;
2825
+ const claimed = this.#database.prepare(`
2826
+ UPDATE send_attempts SET status = 'sending', updated_at = ?
2827
+ WHERE attempt_key = ? AND status = 'pending'
2828
+ `).run(this.#now(), candidate.attempt_key);
2829
+ if (claimed.changes !== 1)
2830
+ throw new SendInvariantError('Send claim lost');
2831
+ return mapAttempt(this.#attemptRow(candidate.attempt_key));
2832
+ });
2833
+ }
2834
+ completeSend(attemptId, { providerMessageId }) {
2835
+ const acceptedMessageId = requiredText(providerMessageId, 'providerMessageId');
2836
+ return this.#transaction(() => {
2837
+ const accepted = this.#finishSending(attemptId, 'accepted', {
2838
+ providerMessageId: acceptedMessageId,
2839
+ });
2840
+ if (accepted.channel !== 'wechat_kf')
2841
+ return accepted;
2842
+ const failure = rowAs(this.#database.prepare(`
2843
+ SELECT fail_type FROM delivery_failures WHERE wecom_msgid = ?
2844
+ `).get(acceptedMessageId));
2845
+ if (!failure)
2846
+ return accepted;
2847
+ const current = this.#attemptRow(attemptId);
2848
+ this.#applyDeliveryFailure(current, Number(failure.fail_type || 0), this.#now());
2849
+ return mapAttempt(this.#attemptRow(attemptId));
2850
+ });
2851
+ }
2852
+ failSend(attemptId, error) {
2853
+ const attemptKey = String(attemptId);
2854
+ return this.#transaction(() => {
2855
+ const current = this.#attemptRow(attemptKey);
2856
+ if (!current)
2857
+ throw new Error(`Unknown send attempt: ${attemptKey}`);
2858
+ if (current.status !== 'sending') {
2859
+ if (current.status === 'failed')
2860
+ return mapAttempt(current);
2861
+ throw new Error(`Cannot fail send attempt in status ${current.status}`);
2862
+ }
2863
+ const now = this.#now();
2864
+ this.#database
2865
+ .prepare(`
2866
+ UPDATE send_attempts
2867
+ SET status = 'failed', error_code = ?, error_message = ?, updated_at = ?
2868
+ WHERE attempt_key = ?
2869
+ `)
2870
+ .run(errorCode(error), errorMessage(error) || 'send failed', now, attemptKey);
2871
+ this.#settleSourceMessage(current.source_message_key, now);
2872
+ return mapAttempt(this.#attemptRow(attemptKey));
2873
+ });
2874
+ }
2875
+ markSendUncertain(attemptId, error) {
2876
+ return this.#transaction(() => this.#finishSending(attemptId, 'uncertain', {
2877
+ errorCode: errorCode(error),
2878
+ errorMessage: errorMessage(error) || 'send outcome uncertain',
2879
+ }));
2880
+ }
2881
+ markSendMsgFailed({ providerMessageId, failType, }) {
2882
+ if (!String(providerMessageId || ''))
2883
+ return false;
2884
+ return this.#transaction(() => {
2885
+ const messageId = String(providerMessageId);
2886
+ const fail = Number(failType || 0);
2887
+ const now = this.#now();
2888
+ this.#database.prepare(`
2889
+ INSERT INTO delivery_failures (
2890
+ wecom_msgid, fail_type, observed_at
2891
+ ) VALUES (?, ?, ?)
2892
+ ON CONFLICT(wecom_msgid) DO UPDATE SET
2893
+ fail_type = excluded.fail_type,
2894
+ observed_at = excluded.observed_at
2895
+ `).run(messageId, fail, now);
2896
+ const current = rowAs(this.#database
2897
+ .prepare(`
2898
+ SELECT * FROM send_attempts
2899
+ WHERE channel = 'wechat_kf'
2900
+ AND wecom_msgid = ? AND status = 'accepted'
2901
+ ORDER BY updated_at DESC LIMIT 1
2902
+ `)
2903
+ .get(messageId));
2904
+ if (!current)
2905
+ return false;
2906
+ this.#applyDeliveryFailure(current, fail, now);
2907
+ return true;
2908
+ });
2909
+ }
2910
+ recoverStartup() {
2911
+ return this.#transaction(() => {
2912
+ const now = this.#now();
2913
+ this.#database.prepare(`
2914
+ UPDATE agent_sessions SET closed_at = ?, updated_at = ?
2915
+ WHERE closed_at = 0
2916
+ `).run(now, now);
2917
+ const sending = this.#database
2918
+ .prepare(`
2919
+ UPDATE send_attempts
2920
+ SET status = 'uncertain', error_code = 'startup_recovery',
2921
+ error_message = 'Process exited while send outcome was unknown',
2922
+ updated_at = ?
2923
+ WHERE status = 'sending'
2924
+ `)
2925
+ .run(now).changes;
2926
+ const sources = rowsAs(this.#database
2927
+ .prepare(`
2928
+ SELECT DISTINCT source_message_key FROM send_attempts
2929
+ WHERE status = 'uncertain' AND error_code = 'startup_recovery'
2930
+ `)
2931
+ .all());
2932
+ for (const source of sources) {
2933
+ this.#settleSourceMessage(source.source_message_key, now);
2934
+ }
2935
+ this.#database.prepare(`
2936
+ UPDATE inbound_messages
2937
+ SET status = 'received', primary_message_key = NULL,
2938
+ codex_turn_id = '', client_input_id = '', steering_boundary = 0,
2939
+ error_message = '', updated_at = ?
2940
+ WHERE status IN ('steering', 'steered') AND codex_turn_id = ''
2941
+ `).run(now);
2942
+ return {
2943
+ uncertainSends: Number(sending),
2944
+ inbound: rowsAs(this.#database
2945
+ .prepare(`
2946
+ SELECT * FROM inbound_messages
2947
+ WHERE status IN (
2948
+ 'received', 'failed', 'processing', 'preparing',
2949
+ 'steering', 'steered', 'ready'
2950
+ )
2951
+ AND deferred = 0
2952
+ ORDER BY inbox_seq
2953
+ `)
2954
+ .all()).map((row) => mapInbound(row)),
2955
+ };
2956
+ });
2957
+ }
2958
+ rememberInboundMedia({ messageKey, attachments, sentAt = 0, }) {
2959
+ if (!Array.isArray(attachments))
2960
+ throw new Error('attachments must be an array');
2961
+ return this.#transaction(() => {
2962
+ const message = this.#inboundRow(messageKey);
2963
+ if (!message)
2964
+ throw new Error(`Unknown inbound message: ${messageKey}`);
2965
+ this.#database
2966
+ .prepare('DELETE FROM inbound_media WHERE message_key = ?')
2967
+ .run(messageKey);
2968
+ const now = this.#now();
2969
+ const insert = this.#database.prepare(`
2970
+ INSERT INTO inbound_media (
2971
+ message_key, channel, open_kfid, external_userid, position, kind,
2972
+ media_id, filename, sent_at, remembered_at
2973
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2974
+ `);
2975
+ attachments.forEach((attachment, index) => {
2976
+ insert.run(messageKey, message.channel, message.open_kfid, message.external_userid, index, attachment.kind, requiredText(attachment.mediaId, 'attachment mediaId'), String(attachment?.filename || ''), Number(sentAt || message.sent_at || 0), now);
2977
+ });
2978
+ return this.listRecentMedia({
2979
+ channel: message.channel,
2980
+ accountKey: message.open_kfid,
2981
+ peerId: message.external_userid,
2982
+ limit: attachments.length,
2983
+ }).filter((item) => item.messageKey === messageKey);
2984
+ });
2985
+ }
2986
+ listRecentMedia({ channel, accountKey, peerId, limit = 10, maxAgeMs = 3 * 24 * 60 * 60 * 1000, }) {
2987
+ const rows = rowsAs(this.#database
2988
+ .prepare(`
2989
+ SELECT * FROM inbound_media
2990
+ WHERE channel = ? AND open_kfid = ? AND external_userid = ?
2991
+ AND remembered_at >= ?
2992
+ ORDER BY remembered_at DESC, media_seq DESC
2993
+ LIMIT ?
2994
+ `)
2995
+ .all(channel, String(accountKey), String(peerId), this.#now() - Number(maxAgeMs), Math.max(0, Math.min(Number(limit) || 10, 50))));
2996
+ return rows.map((row, index) => ({
2997
+ ref: `media:${index}`,
2998
+ messageKey: String(row.message_key),
2999
+ channel: row.channel,
3000
+ accountKey: String(row.open_kfid),
3001
+ peerId: String(row.external_userid),
3002
+ kind: 'image',
3003
+ mediaId: String(row.media_id),
3004
+ filename: String(row.filename),
3005
+ sentAt: Number(row.sent_at),
3006
+ rememberedAt: Number(row.remembered_at),
3007
+ }));
3008
+ }
3009
+ cleanup({ mediaMaxAgeMs = 3 * 24 * 60 * 60 * 1000, payloadMaxAgeMs = 7 * 24 * 60 * 60 * 1000, acceptedAuditMaxAgeMs = 30 * 24 * 60 * 60 * 1000, } = {}) {
3010
+ return this.#transaction(() => {
3011
+ const now = this.#now();
3012
+ const wecomMedia = this.#database
3013
+ .prepare('DELETE FROM inbound_media WHERE remembered_at < ?')
3014
+ .run(now - mediaMaxAgeMs).changes;
3015
+ const ilinkMedia = this.#database
3016
+ .prepare(`
3017
+ DELETE FROM ilink_inbound_images
3018
+ WHERE created_at < ? AND EXISTS (
3019
+ SELECT 1 FROM inbound_messages AS inbound
3020
+ WHERE inbound.message_key = ilink_inbound_images.message_key
3021
+ AND inbound.status IN (
3022
+ 'completed', 'absorbed', 'failed', 'ignored', 'suppressed'
3023
+ )
3024
+ )
3025
+ `)
3026
+ .run(now - mediaMaxAgeMs).changes;
3027
+ const inboundPayloads = this.#database
3028
+ .prepare(`
3029
+ UPDATE inbound_messages SET payload_json = NULL
3030
+ WHERE updated_at < ? AND status IN (
3031
+ 'completed', 'absorbed', 'failed', 'ignored', 'suppressed'
3032
+ )
3033
+ `)
3034
+ .run(now - payloadMaxAgeMs).changes;
3035
+ const sendPayloads = this.#database
3036
+ .prepare(`
3037
+ UPDATE send_attempts SET payload_json = NULL, metadata_json = NULL
3038
+ WHERE updated_at < ? AND status IN ('accepted', 'failed')
3039
+ `)
3040
+ .run(now - payloadMaxAgeMs).changes;
3041
+ const audits = this.#database
3042
+ .prepare(`
3043
+ DELETE FROM send_attempts
3044
+ WHERE updated_at < ? AND status IN ('accepted', 'failed')
3045
+ `)
3046
+ .run(now - acceptedAuditMaxAgeMs).changes;
3047
+ this.#database.prepare(`
3048
+ DELETE FROM delivery_failures WHERE observed_at < ?
3049
+ `).run(now - acceptedAuditMaxAgeMs);
3050
+ this.#database.prepare(`
3051
+ DELETE FROM agent_sessions
3052
+ WHERE expires_at < ? OR (closed_at > 0 AND closed_at < ?)
3053
+ `).run(now, now - 60 * 60 * 1000);
3054
+ this.#database.prepare(`
3055
+ DELETE FROM ilink_reply_window_secrets
3056
+ WHERE reply_window_id IN (
3057
+ SELECT reply_window_id FROM ilink_reply_windows WHERE expires_at <= ?
3058
+ )
3059
+ `).run(now);
3060
+ this.#database.prepare(`
3061
+ UPDATE ilink_reply_windows
3062
+ SET state = 'closed', reserved_send_count = 0, updated_at = ?
3063
+ WHERE state = 'open' AND expires_at <= ?
3064
+ AND NOT EXISTS (
3065
+ SELECT 1 FROM send_attempts
3066
+ WHERE send_attempts.reply_window_id = ilink_reply_windows.reply_window_id
3067
+ AND send_attempts.status IN ('pending', 'sending')
3068
+ )
3069
+ AND NOT EXISTS (
3070
+ SELECT 1 FROM inbound_messages
3071
+ WHERE inbound_messages.message_key = ilink_reply_windows.source_message_key
3072
+ AND inbound_messages.status IN (
3073
+ 'received', 'failed', 'processing', 'preparing',
3074
+ 'steering', 'steered', 'ready'
3075
+ )
3076
+ )
3077
+ `).run(now, now);
3078
+ const ilinkReplyWindows = this.#database.prepare(`
3079
+ DELETE FROM ilink_reply_windows
3080
+ WHERE state <> 'open' AND updated_at < ?
3081
+ AND NOT EXISTS (
3082
+ SELECT 1 FROM send_attempts
3083
+ WHERE send_attempts.reply_window_id = ilink_reply_windows.reply_window_id
3084
+ )
3085
+ `).run(now - acceptedAuditMaxAgeMs).changes;
3086
+ this.#database.prepare(`
3087
+ DELETE FROM ilink_account_secrets
3088
+ WHERE account_key IN (
3089
+ SELECT account_key FROM ilink_accounts
3090
+ WHERE status IN ('disabled', 'revoked')
3091
+ )
3092
+ `).run();
3093
+ return {
3094
+ media: Number(wecomMedia) + Number(ilinkMedia),
3095
+ inboundPayloads: Number(inboundPayloads),
3096
+ sendPayloads: Number(sendPayloads),
3097
+ audits: Number(audits),
3098
+ ilinkReplyWindows: Number(ilinkReplyWindows),
3099
+ };
3100
+ });
3101
+ }
3102
+ }