@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,1194 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {} from '../state/sqlite-store.js';
3
+ import { normalizeIlinkBaseUrl } from './protocol/client.js';
4
+ import { ILINK_CHANNEL, ILINK_MAX_PROVIDER_ID_BYTES, ILINK_REPLY_WINDOW_LIFETIME_MS, ILINK_REPLY_WINDOW_MAX_SENDS, assertIlinkAccountKey, assertIlinkEncryptedSecret, createIlinkAccountKey, } from './store-types.js';
5
+ const MAX_UPSTREAM_CLOCK_SKEW_MS = 5 * 60 * 1_000;
6
+ const MAX_CURSOR_BYTES = 256 * 1024;
7
+ export class IlinkSqliteStoreError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.name = 'IlinkSqliteStoreError';
12
+ this.code = code;
13
+ }
14
+ }
15
+ function fail(code, message) {
16
+ throw new IlinkSqliteStoreError(code, message);
17
+ }
18
+ function rowAs(row) {
19
+ return row === undefined ? undefined : row;
20
+ }
21
+ function rowsAs(rows) {
22
+ return rows;
23
+ }
24
+ function positiveInteger(value, label) {
25
+ if (!Number.isSafeInteger(value) || value <= 0) {
26
+ fail('invalid_input', `${label} must be a positive safe integer`);
27
+ }
28
+ return value;
29
+ }
30
+ function nonNegativeInteger(value, label) {
31
+ if (!Number.isSafeInteger(value) || value < 0) {
32
+ fail('invalid_input', `${label} must be a non-negative safe integer`);
33
+ }
34
+ return value;
35
+ }
36
+ function boundedCursor(value, label) {
37
+ if (typeof value !== 'string' ||
38
+ Buffer.byteLength(value, 'utf8') > MAX_CURSOR_BYTES ||
39
+ value.includes('\0')) {
40
+ fail('invalid_input', `${label} is invalid`);
41
+ }
42
+ return value;
43
+ }
44
+ function providerIdentity(value, label) {
45
+ if (!value ||
46
+ value !== value.trim() ||
47
+ Buffer.byteLength(value, 'utf8') > ILINK_MAX_PROVIDER_ID_BYTES ||
48
+ /[\u0000-\u001f\u007f]/u.test(value)) {
49
+ fail('invalid_input', `${label} is invalid`);
50
+ }
51
+ return value;
52
+ }
53
+ function sealedSecret(row) {
54
+ const result = {
55
+ nonce: row.nonce,
56
+ ciphertext: row.ciphertext,
57
+ authTag: row.auth_tag,
58
+ };
59
+ assertIlinkEncryptedSecret(result);
60
+ return result;
61
+ }
62
+ function canonicalValue(value) {
63
+ if (value === undefined || value === null)
64
+ return null;
65
+ if (typeof value === 'string' ||
66
+ typeof value === 'boolean') {
67
+ return value;
68
+ }
69
+ if (typeof value === 'number') {
70
+ if (!Number.isFinite(value))
71
+ fail('invalid_input', 'JSON numbers must be finite');
72
+ return value;
73
+ }
74
+ if (Array.isArray(value))
75
+ return value.map(canonicalValue);
76
+ if (typeof value !== 'object' || Buffer.isBuffer(value)) {
77
+ fail('invalid_input', `Unsupported JSON value: ${typeof value}`);
78
+ }
79
+ const source = value;
80
+ const output = {};
81
+ for (const key of Object.keys(source).sort()) {
82
+ if (source[key] !== undefined)
83
+ output[key] = canonicalValue(source[key]);
84
+ }
85
+ return output;
86
+ }
87
+ function encodeJson(value) {
88
+ return JSON.stringify(canonicalValue(value));
89
+ }
90
+ function decodeObject(value) {
91
+ if (!value)
92
+ return undefined;
93
+ const parsed = JSON.parse(value);
94
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
95
+ ? parsed
96
+ : undefined;
97
+ }
98
+ function sha256(value) {
99
+ return createHash('sha256').update(value, 'utf8').digest('hex');
100
+ }
101
+ function stableAttemptKey(messageKey, sendIndex) {
102
+ return `sa_${sha256(`${messageKey}\0${sendIndex}`).slice(0, 29)}`;
103
+ }
104
+ function stableClientMessageId(messageKey, sendIndex) {
105
+ return `wb_${sha256(`${messageKey}\0${sendIndex}`).slice(0, 29)}`;
106
+ }
107
+ function mapAccount(row) {
108
+ assertIlinkAccountKey(row.account_key);
109
+ return {
110
+ accountKey: row.account_key,
111
+ channel: ILINK_CHANNEL,
112
+ providerAccountId: row.provider_account_id,
113
+ ownerPeerId: row.owner_peer_id,
114
+ baseUrl: row.base_url,
115
+ generation: Number(row.generation),
116
+ status: row.status,
117
+ pauseUntil: Number(row.pause_until),
118
+ createdAt: Number(row.created_at),
119
+ updatedAt: Number(row.updated_at),
120
+ };
121
+ }
122
+ function mapAccountSecret(row) {
123
+ const account = mapAccount(row);
124
+ if (Number(row.account_generation) !== account.generation) {
125
+ fail('generation_conflict', 'iLink account secret generation is stale');
126
+ }
127
+ return {
128
+ accountKey: account.accountKey,
129
+ ownerPeerId: account.ownerPeerId,
130
+ accountGeneration: Number(row.account_generation),
131
+ sealedBotToken: sealedSecret(row),
132
+ updatedAt: Number(row.secret_updated_at),
133
+ };
134
+ }
135
+ function mapReplyWindow(row) {
136
+ assertIlinkAccountKey(row.account_key);
137
+ return {
138
+ replyWindowId: Number(row.reply_window_id),
139
+ accountKey: row.account_key,
140
+ peerId: row.peer_id,
141
+ accountGeneration: Number(row.account_generation),
142
+ sourceMessageKey: row.source_message_key,
143
+ sourceInboxSeq: Number(row.source_inbox_seq),
144
+ issuedAt: Number(row.issued_at),
145
+ expiresAt: Number(row.expires_at),
146
+ maxSends: Number(row.max_sends),
147
+ nextSendIndex: Number(row.next_send_index),
148
+ reservedSendCount: Number(row.reserved_send_count),
149
+ transmittedSendCount: Number(row.transmitted_send_count),
150
+ state: row.state,
151
+ createdAt: Number(row.created_at),
152
+ updatedAt: Number(row.updated_at),
153
+ };
154
+ }
155
+ function mapAttempt(row) {
156
+ const payload = decodeObject(row.payload_json);
157
+ const metadata = decodeObject(row.metadata_json);
158
+ return {
159
+ attemptId: row.attempt_key,
160
+ messageKey: row.source_message_key,
161
+ channel: ILINK_CHANNEL,
162
+ accountKey: row.open_kfid,
163
+ peerId: row.external_userid,
164
+ replyWindowId: Number(row.reply_window_id),
165
+ sendIndex: Number(row.send_index),
166
+ source: row.source,
167
+ type: row.sent_type,
168
+ ...(payload ? { payload } : {}),
169
+ ...(metadata ? { metadata } : {}),
170
+ fingerprint: row.fingerprint,
171
+ clientMessageId: row.client_message_id,
172
+ status: row.status,
173
+ providerMessageId: row.wecom_msgid,
174
+ errorCode: row.error_code,
175
+ errorMessage: row.error_message,
176
+ failType: Number(row.fail_type),
177
+ createdAt: Number(row.created_at),
178
+ updatedAt: Number(row.updated_at),
179
+ };
180
+ }
181
+ function entryIsNewer(entry, open, samePage) {
182
+ if (entry.providerSeq !== undefined &&
183
+ open.provider_seq !== null &&
184
+ entry.providerSeq !== open.provider_seq) {
185
+ return entry.providerSeq > open.provider_seq;
186
+ }
187
+ if (entry.message.sentAt !== open.issued_at) {
188
+ return entry.message.sentAt > open.issued_at;
189
+ }
190
+ if ((entry.providerSeq === undefined) !== (open.provider_seq === null))
191
+ return samePage;
192
+ const candidateNumeric = /^message:(\d+)$/u.exec(entry.message.providerMessageId)?.[1];
193
+ const openNumeric = /^message:(\d+)$/u.exec(String(open.provider_message_id || ''))?.[1];
194
+ return candidateNumeric !== undefined && openNumeric !== undefined
195
+ ? BigInt(candidateNumeric) > BigInt(openNumeric)
196
+ : samePage;
197
+ }
198
+ function compareEntries(left, right) {
199
+ if (left.providerSeq !== undefined &&
200
+ right.providerSeq !== undefined &&
201
+ left.providerSeq !== right.providerSeq) {
202
+ return left.providerSeq - right.providerSeq;
203
+ }
204
+ if (left.message.sentAt !== right.message.sentAt) {
205
+ return left.message.sentAt - right.message.sentAt;
206
+ }
207
+ const leftNumeric = /^message:(\d+)$/u.exec(left.message.providerMessageId)?.[1];
208
+ const rightNumeric = /^message:(\d+)$/u.exec(right.message.providerMessageId)?.[1];
209
+ if (leftNumeric === undefined || rightNumeric === undefined) {
210
+ return left.message.sync.index - right.message.sync.index;
211
+ }
212
+ const leftId = BigInt(leftNumeric);
213
+ const rightId = BigInt(rightNumeric);
214
+ return leftId < rightId ? -1 : leftId > rightId ? 1 : 0;
215
+ }
216
+ export class IlinkSqliteStore {
217
+ #database;
218
+ #inbox;
219
+ #clock;
220
+ constructor({ database, inbox, clock = Date.now, }) {
221
+ this.#database = database;
222
+ this.#inbox = inbox;
223
+ this.#clock = clock;
224
+ }
225
+ #now(explicit) {
226
+ return nonNegativeInteger(explicit ?? Number(this.#clock()), 'now');
227
+ }
228
+ #transaction(operation) {
229
+ if (this.#database.isTransaction)
230
+ return operation();
231
+ this.#database.exec('BEGIN IMMEDIATE');
232
+ try {
233
+ const result = operation();
234
+ this.#database.exec('COMMIT');
235
+ return result;
236
+ }
237
+ catch (error) {
238
+ this.#database.exec('ROLLBACK');
239
+ throw error;
240
+ }
241
+ }
242
+ #accountRow(accountKey) {
243
+ assertIlinkAccountKey(accountKey);
244
+ return rowAs(this.#database.prepare(`
245
+ SELECT * FROM ilink_accounts WHERE account_key = ?
246
+ `).get(accountKey));
247
+ }
248
+ #accountSecretRow(accountKey) {
249
+ assertIlinkAccountKey(accountKey);
250
+ return rowAs(this.#database.prepare(`
251
+ SELECT account.*, secret.account_generation,
252
+ secret.nonce, secret.ciphertext, secret.auth_tag,
253
+ secret.updated_at AS secret_updated_at
254
+ FROM ilink_accounts AS account
255
+ JOIN ilink_account_secrets AS secret USING (account_key)
256
+ WHERE account.account_key = ?
257
+ `).get(accountKey));
258
+ }
259
+ recoverPendingAttempts() {
260
+ return this.#transaction(() => {
261
+ const groups = rowsAs(this.#database.prepare(`
262
+ SELECT reply_window_id, COUNT(*) AS pending_count
263
+ FROM send_attempts
264
+ WHERE channel = 'weixin_ilink' AND status = 'pending'
265
+ GROUP BY reply_window_id
266
+ `).all());
267
+ let recovered = 0;
268
+ const now = this.#now();
269
+ for (const group of groups) {
270
+ const replyWindowId = Number(group.reply_window_id || 0);
271
+ const count = Number(group.pending_count || 0);
272
+ const window = replyWindowId
273
+ ? rowAs(this.#database.prepare(`
274
+ SELECT * FROM ilink_reply_windows WHERE reply_window_id = ?
275
+ `).get(replyWindowId))
276
+ : undefined;
277
+ if (!window || count <= 0 || window.reserved_send_count < count) {
278
+ fail('attempt_conflict', 'Pending iLink recovery counters are inconsistent');
279
+ }
280
+ const attempts = this.#database.prepare(`
281
+ UPDATE send_attempts
282
+ SET status = 'failed', error_code = 'abandoned_before_transmit',
283
+ error_message = 'iLink send stopped before network transmission',
284
+ updated_at = ?
285
+ WHERE channel = 'weixin_ilink' AND status = 'pending'
286
+ AND reply_window_id = ?
287
+ `).run(now, replyWindowId);
288
+ if (attempts.changes !== count) {
289
+ fail('attempt_conflict', 'Pending iLink recovery changed unexpectedly');
290
+ }
291
+ const updated = this.#database.prepare(`
292
+ UPDATE ilink_reply_windows
293
+ SET reserved_send_count = reserved_send_count - ?, updated_at = ?
294
+ WHERE reply_window_id = ? AND reserved_send_count >= ?
295
+ `).run(count, now, replyWindowId, count);
296
+ if (updated.changes !== 1) {
297
+ fail('attempt_conflict', 'Pending iLink recovery lost its window');
298
+ }
299
+ recovered += count;
300
+ }
301
+ return recovered;
302
+ });
303
+ }
304
+ releasePendingAttempt(attemptId, reason = 'cancelled_before_transmit') {
305
+ return this.#transaction(() => {
306
+ const attempt = rowAs(this.#database.prepare(`
307
+ SELECT * FROM send_attempts
308
+ WHERE attempt_key = ? AND channel = 'weixin_ilink' AND status = 'pending'
309
+ `).get(attemptId));
310
+ if (!attempt)
311
+ return false;
312
+ const now = this.#now();
313
+ const released = this.#database.prepare(`
314
+ UPDATE ilink_reply_windows
315
+ SET reserved_send_count = reserved_send_count - 1, updated_at = ?
316
+ WHERE reply_window_id = ? AND reserved_send_count > 0
317
+ `).run(now, attempt.reply_window_id);
318
+ if (released.changes !== 1) {
319
+ fail('attempt_conflict', 'Pending iLink reservation cannot be released');
320
+ }
321
+ this.#database.prepare(`
322
+ UPDATE send_attempts
323
+ SET status = 'failed', error_code = ?, error_message = ?,
324
+ updated_at = ?
325
+ WHERE attempt_key = ? AND status = 'pending'
326
+ `).run(reason, reason === 'reply_window_expired'
327
+ ? 'iLink reply window expired before network transmission'
328
+ : 'iLink send cancelled before network transmission', now, attempt.attempt_key);
329
+ return true;
330
+ });
331
+ }
332
+ registerAccount(input) {
333
+ assertIlinkEncryptedSecret(input.encryptedBotToken);
334
+ const accountKey = createIlinkAccountKey(input.providerAccountId);
335
+ const ownerPeerId = providerIdentity(input.ownerPeerId, 'ownerPeerId');
336
+ const baseUrl = normalizeIlinkBaseUrl(input.baseUrl);
337
+ const now = this.#now(input.now);
338
+ return this.#transaction(() => {
339
+ if (this.#accountRow(accountKey)) {
340
+ fail('account_exists', 'iLink account is already registered');
341
+ }
342
+ const owner = this.#database.prepare(`
343
+ SELECT 1 FROM ilink_accounts
344
+ WHERE owner_peer_id = ? AND status IN ('active', 'paused')
345
+ LIMIT 1
346
+ `).get(ownerPeerId);
347
+ if (owner)
348
+ fail('owner_conflict', 'iLink owner already has an active account');
349
+ this.#database.prepare(`
350
+ INSERT INTO ilink_accounts (
351
+ account_key, provider_account_id, owner_peer_id, base_url,
352
+ generation, status, pause_until, cursor, cursor_updated_at,
353
+ created_at, updated_at
354
+ ) VALUES (?, ?, ?, ?, 1, 'active', 0, '', 0, ?, ?)
355
+ `).run(accountKey, input.providerAccountId, ownerPeerId, baseUrl, now, now);
356
+ this.#database.prepare(`
357
+ INSERT INTO ilink_account_secrets (
358
+ account_key, account_generation, nonce, ciphertext, auth_tag,
359
+ updated_at
360
+ ) VALUES (?, 1, ?, ?, ?, ?)
361
+ `).run(accountKey, input.encryptedBotToken.nonce, input.encryptedBotToken.ciphertext, input.encryptedBotToken.authTag, now);
362
+ return mapAccount(this.#accountRow(accountKey));
363
+ });
364
+ }
365
+ rotateAccount(input) {
366
+ assertIlinkAccountKey(input.accountKey);
367
+ assertIlinkEncryptedSecret(input.encryptedBotToken);
368
+ positiveInteger(input.expectedGeneration, 'expectedGeneration');
369
+ providerIdentity(input.ownerPeerId, 'ownerPeerId');
370
+ if (createIlinkAccountKey(input.providerAccountId) !== input.accountKey) {
371
+ fail('pair_mismatch', 'iLink provider account does not match account key');
372
+ }
373
+ const baseUrl = normalizeIlinkBaseUrl(input.baseUrl);
374
+ const now = this.#now(input.now);
375
+ return this.#transaction(() => {
376
+ const current = this.#accountRow(input.accountKey);
377
+ if (!current)
378
+ fail('account_not_found', 'Unknown iLink account');
379
+ if (current.generation !== input.expectedGeneration) {
380
+ fail('generation_conflict', 'iLink account generation changed');
381
+ }
382
+ if (current.provider_account_id !== input.providerAccountId ||
383
+ current.owner_peer_id !== input.ownerPeerId) {
384
+ fail('pair_mismatch', 'iLink account binding cannot change during rotation');
385
+ }
386
+ const nextGeneration = current.generation + 1;
387
+ positiveInteger(nextGeneration, 'nextGeneration');
388
+ this.#cancelOpenWindows(input.accountKey, now, 'account_generation_changed');
389
+ const updated = this.#database.prepare(`
390
+ UPDATE ilink_accounts
391
+ SET base_url = ?, generation = ?, status = 'active', pause_until = 0,
392
+ updated_at = ?
393
+ WHERE account_key = ? AND generation = ?
394
+ `).run(baseUrl, nextGeneration, now, input.accountKey, input.expectedGeneration);
395
+ if (updated.changes !== 1) {
396
+ fail('generation_conflict', 'iLink account generation changed');
397
+ }
398
+ this.#database.prepare(`
399
+ INSERT INTO ilink_account_secrets (
400
+ account_key, account_generation, nonce, ciphertext, auth_tag, updated_at
401
+ ) VALUES (?, ?, ?, ?, ?, ?)
402
+ ON CONFLICT(account_key) DO UPDATE SET
403
+ account_generation = excluded.account_generation,
404
+ nonce = excluded.nonce,
405
+ ciphertext = excluded.ciphertext,
406
+ auth_tag = excluded.auth_tag,
407
+ updated_at = excluded.updated_at
408
+ `).run(input.accountKey, nextGeneration, input.encryptedBotToken.nonce, input.encryptedBotToken.ciphertext, input.encryptedBotToken.authTag, now);
409
+ return mapAccount(this.#accountRow(input.accountKey));
410
+ });
411
+ }
412
+ confirmEnrollment(input) {
413
+ const offerId = String(input.offerId || '');
414
+ if (!/^qo_[A-Za-z0-9_-]{1,128}$/u.test(offerId)) {
415
+ fail('invalid_input', 'iLink login offer ID is invalid');
416
+ }
417
+ positiveInteger(input.accountGeneration, 'accountGeneration');
418
+ positiveInteger(input.maxAccounts, 'maxAccounts');
419
+ return this.#transaction(() => {
420
+ const offer = rowAs(this.#database.prepare(`
421
+ SELECT source_message_key, source_open_kfid,
422
+ source_external_userid, created_at
423
+ FROM ilink_login_offers
424
+ WHERE offer_id = ? AND status IN ('waiting', 'scanned')
425
+ AND expires_at > ?
426
+ `).get(offerId, this.#now(input.now)));
427
+ if (!offer)
428
+ fail('invalid_input', 'Unknown or inactive iLink login offer');
429
+ const accountKey = createIlinkAccountKey(input.providerAccountId);
430
+ const existing = this.#accountRow(accountKey);
431
+ if (!existing || existing.status !== 'active') {
432
+ const active = rowAs(this.#database.prepare(`
433
+ SELECT COUNT(*) AS count FROM ilink_accounts WHERE status = 'active'
434
+ `).get());
435
+ if (Number(active?.count || 0) >= input.maxAccounts) {
436
+ fail('account_limit_reached', 'iLink account limit reached');
437
+ }
438
+ }
439
+ if (input.accountGeneration !== (existing?.generation || 0) + 1) {
440
+ fail('generation_conflict', 'iLink enrollment generation changed');
441
+ }
442
+ const account = existing
443
+ ? this.rotateAccount({
444
+ accountKey,
445
+ expectedGeneration: existing.generation,
446
+ providerAccountId: input.providerAccountId,
447
+ ownerPeerId: input.ownerPeerId,
448
+ baseUrl: input.baseUrl,
449
+ encryptedBotToken: input.encryptedBotToken,
450
+ now: input.now,
451
+ })
452
+ : this.registerAccount(input);
453
+ this.#database.prepare(`
454
+ INSERT INTO ilink_enrollment_audit (
455
+ offer_id, source_message_key, source_open_kfid,
456
+ source_external_userid, account_key, result, offered_at, completed_at
457
+ ) VALUES (?, ?, ?, ?, ?, 'confirmed', ?, ?)
458
+ `).run(offerId, offer.source_message_key, offer.source_open_kfid, offer.source_external_userid, account.accountKey, offer.created_at, this.#now(input.now));
459
+ const removed = this.#database.prepare(`
460
+ DELETE FROM ilink_login_offers
461
+ WHERE offer_id = ? AND status IN ('waiting', 'scanned')
462
+ `).run(offerId);
463
+ if (removed.changes !== 1)
464
+ fail('attempt_conflict', 'iLink login offer changed');
465
+ return account;
466
+ });
467
+ }
468
+ #cancelOpenWindows(accountKey, now, reason) {
469
+ const windowIds = rowsAs(this.#database.prepare(`
470
+ SELECT reply_window_id FROM ilink_reply_windows
471
+ WHERE account_key = ? AND state = 'open'
472
+ `).all(accountKey));
473
+ for (const { reply_window_id: replyWindowId } of windowIds) {
474
+ this.#retireWindow(replyWindowId, 'cancelled', now, reason);
475
+ }
476
+ }
477
+ getAccount(accountKey) {
478
+ const row = this.#accountRow(accountKey);
479
+ return row ? mapAccount(row) : undefined;
480
+ }
481
+ getAccountSecret(accountKey) {
482
+ const row = this.#accountSecretRow(accountKey);
483
+ return row ? mapAccountSecret(row) : undefined;
484
+ }
485
+ getAccountWithSecret(accountKey) {
486
+ const row = this.#accountSecretRow(accountKey);
487
+ return row
488
+ ? { account: mapAccount(row), secret: mapAccountSecret(row) }
489
+ : undefined;
490
+ }
491
+ listActiveAccounts() {
492
+ return rowsAs(this.#database.prepare(`
493
+ SELECT * FROM ilink_accounts
494
+ WHERE status = 'active'
495
+ ORDER BY created_at, account_key
496
+ `).all()).map(mapAccount);
497
+ }
498
+ listActiveAccountsWithSecrets() {
499
+ return rowsAs(this.#database.prepare(`
500
+ SELECT account.*, secret.account_generation,
501
+ secret.nonce, secret.ciphertext, secret.auth_tag,
502
+ secret.updated_at AS secret_updated_at
503
+ FROM ilink_accounts AS account
504
+ JOIN ilink_account_secrets AS secret USING (account_key)
505
+ WHERE account.status = 'active'
506
+ ORDER BY account.created_at, account.account_key
507
+ `).all()).map((row) => ({
508
+ account: mapAccount(row),
509
+ secret: mapAccountSecret(row),
510
+ }));
511
+ }
512
+ getCursor(accountKey) {
513
+ const row = this.#accountRow(accountKey);
514
+ if (!row)
515
+ return undefined;
516
+ return {
517
+ accountKey,
518
+ accountGeneration: Number(row.generation),
519
+ cursor: row.cursor,
520
+ updatedAt: Number(row.cursor_updated_at),
521
+ };
522
+ }
523
+ compareAndSetCursor(input) {
524
+ assertIlinkAccountKey(input.accountKey);
525
+ positiveInteger(input.expectedGeneration, 'expectedGeneration');
526
+ const expected = boundedCursor(input.expectedCursor, 'expectedCursor');
527
+ const next = boundedCursor(input.nextCursor, 'nextCursor');
528
+ const now = this.#now(input.now);
529
+ return this.#transaction(() => {
530
+ const updated = this.#database.prepare(`
531
+ UPDATE ilink_accounts
532
+ SET cursor = ?, cursor_updated_at = ?
533
+ WHERE account_key = ? AND generation = ? AND status = 'active'
534
+ AND cursor = ?
535
+ `).run(next, now, input.accountKey, input.expectedGeneration, expected);
536
+ if (updated.changes !== 1)
537
+ this.#cursorFailure(input.accountKey, input.expectedGeneration);
538
+ return this.getCursor(input.accountKey);
539
+ });
540
+ }
541
+ #cursorFailure(accountKey, expectedGeneration) {
542
+ const account = this.#accountRow(accountKey);
543
+ if (!account)
544
+ fail('account_not_found', 'Unknown iLink account');
545
+ if (account.status !== 'active') {
546
+ fail('account_not_active', 'iLink account is not active');
547
+ }
548
+ if (account.generation !== expectedGeneration) {
549
+ fail('generation_conflict', 'iLink account generation changed');
550
+ }
551
+ fail('cursor_conflict', 'iLink cursor changed');
552
+ }
553
+ commitPollPage(input) {
554
+ assertIlinkAccountKey(input.accountKey);
555
+ positiveInteger(input.expectedGeneration, 'expectedGeneration');
556
+ const expectedCursor = boundedCursor(input.expectedCursor, 'expectedCursor');
557
+ const nextCursor = boundedCursor(input.nextCursor, 'nextCursor');
558
+ if (!Array.isArray(input.messages)) {
559
+ fail('invalid_input', 'messages must be an array');
560
+ }
561
+ if (input.deferredBefore !== undefined) {
562
+ nonNegativeInteger(input.deferredBefore, 'deferredBefore');
563
+ }
564
+ const now = this.#now();
565
+ return this.#transaction(() => {
566
+ const account = this.#accountRow(input.accountKey);
567
+ if (!account)
568
+ fail('account_not_found', 'Unknown iLink account');
569
+ if (account.status !== 'active') {
570
+ fail('account_not_active', 'iLink account is not active');
571
+ }
572
+ if (account.generation !== input.expectedGeneration) {
573
+ fail('generation_conflict', 'iLink account generation changed');
574
+ }
575
+ if (account.cursor !== expectedCursor) {
576
+ fail('cursor_conflict', 'iLink cursor changed');
577
+ }
578
+ const insertedEntries = [];
579
+ const replyWindowIds = [];
580
+ const pageWindowSources = new Set();
581
+ for (const entry of input.messages) {
582
+ this.#validatePageEntry(entry, account, expectedCursor);
583
+ }
584
+ const inboxResults = this.#inbox.insertInboundMessages({
585
+ accountKey: input.accountKey,
586
+ entries: input.messages.map((entry) => ({
587
+ message: entry.message,
588
+ deferred: Boolean(input.deferred) || (input.deferredBefore !== undefined &&
589
+ entry.message.sentAt < input.deferredBefore),
590
+ })),
591
+ now,
592
+ });
593
+ for (const [index, entry] of input.messages.entries()) {
594
+ const inbox = inboxResults[index];
595
+ if (!inbox)
596
+ fail('dedupe_invariant', 'iLink inbox result is missing');
597
+ const { messageKey } = inbox;
598
+ const { message } = entry;
599
+ if (!inbox.inserted) {
600
+ this.#validateDuplicate(message, messageKey);
601
+ continue;
602
+ }
603
+ insertedEntries.push({ messageKey, entry, payload: message });
604
+ for (const image of entry.sealedImages || []) {
605
+ this.#database.prepare(`
606
+ INSERT INTO ilink_inbound_images (
607
+ message_key, position, account_key, peer_id, secret_generation,
608
+ nonce, ciphertext, auth_tag, created_at
609
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
610
+ `).run(messageKey, image.position, input.accountKey, message.conversation.peerId, image.secretGeneration, image.sealedLocator.nonce, image.sealedLocator.ciphertext, image.sealedLocator.authTag, now);
611
+ }
612
+ const replyWindowId = this.#insertReplyWindow(account, entry, messageKey, inbox.inboxSeq, pageWindowSources, now);
613
+ replyWindowIds.push(replyWindowId);
614
+ pageWindowSources.add(messageKey);
615
+ }
616
+ const cursorUpdate = this.#database.prepare(`
617
+ UPDATE ilink_accounts
618
+ SET cursor = ?, cursor_updated_at = ?
619
+ WHERE account_key = ? AND generation = ? AND status = 'active'
620
+ AND cursor = ?
621
+ `).run(nextCursor, now, input.accountKey, input.expectedGeneration, expectedCursor);
622
+ if (cursorUpdate.changes !== 1) {
623
+ this.#cursorFailure(input.accountKey, input.expectedGeneration);
624
+ }
625
+ const openMessageKey = this.#openWindow(input.accountKey, account.owner_peer_id)?.source_message_key || '';
626
+ const deliverable = insertedEntries.find(({ messageKey }) => messageKey === openMessageKey);
627
+ const deliverableDeferred = deliverable
628
+ ? Number(rowAs(this.#database.prepare(`
629
+ SELECT deferred FROM inbound_messages WHERE message_key = ?
630
+ `).get(deliverable.messageKey))?.deferred || 0) === 1
631
+ : false;
632
+ for (const entry of insertedEntries) {
633
+ if (entry.messageKey === openMessageKey)
634
+ continue;
635
+ this.#database.prepare(`
636
+ UPDATE inbound_messages
637
+ SET status = 'absorbed', deferred = 0, payload_json = NULL, updated_at = ?
638
+ WHERE message_key = ? AND status = 'received'
639
+ `).run(now, entry.messageKey);
640
+ }
641
+ const pageSummaries = [...insertedEntries]
642
+ .sort((left, right) => compareEntries(left.entry, right.entry))
643
+ .map(({ payload }) => payload.summary);
644
+ const backlog = deliverable
645
+ ? rowsAs(this.#database.prepare(`
646
+ SELECT message_key, payload_json FROM inbound_messages
647
+ WHERE channel = 'weixin_ilink'
648
+ AND open_kfid = ? AND external_userid = ?
649
+ AND status = 'received' AND message_key <> ?
650
+ AND inbox_seq < (
651
+ SELECT inbox_seq FROM inbound_messages WHERE message_key = ?
652
+ )
653
+ ORDER BY inbox_seq
654
+ `).all(input.accountKey, account.owner_peer_id, deliverable.messageKey, deliverable.messageKey))
655
+ : [];
656
+ if (backlog.length) {
657
+ const placeholders = backlog.map(() => '?').join(',');
658
+ this.#database.prepare(`
659
+ UPDATE inbound_messages
660
+ SET status = 'absorbed', deferred = 0, payload_json = NULL, updated_at = ?
661
+ WHERE message_key IN (${placeholders}) AND status = 'received'
662
+ `).run(now, ...backlog.map(({ message_key: key }) => key));
663
+ }
664
+ const backlogSummaries = backlog.flatMap(({ payload_json: payloadJson }) => {
665
+ const summary = decodeObject(payloadJson)?.summary;
666
+ return typeof summary === 'string' && summary ? [summary] : [];
667
+ });
668
+ const mergeKeys = deliverable
669
+ ? [...new Set([
670
+ ...insertedEntries.map(({ messageKey }) => messageKey),
671
+ ...backlog.map(({ message_key: key }) => key),
672
+ ])]
673
+ : [];
674
+ const mergedImageCount = mergeKeys.length
675
+ ? Number(rowAs(this.#database.prepare(`
676
+ SELECT COUNT(*) AS count FROM ilink_inbound_images
677
+ WHERE message_key IN (${mergeKeys.map(() => '?').join(',')})
678
+ `).get(...mergeKeys))?.count || 0)
679
+ : 0;
680
+ const mergedImages = mergeKeys.length
681
+ ? rowsAs(this.#database.prepare(`
682
+ SELECT image.nonce, image.ciphertext, image.auth_tag,
683
+ image.secret_generation
684
+ FROM ilink_inbound_images AS image
685
+ JOIN inbound_messages AS inbound USING (message_key)
686
+ WHERE image.message_key IN (${mergeKeys.map(() => '?').join(',')})
687
+ ORDER BY CASE WHEN image.message_key = ? THEN 0 ELSE 1 END,
688
+ inbound.inbox_seq DESC, image.position DESC
689
+ LIMIT 4
690
+ `).all(...mergeKeys, deliverable.messageKey))
691
+ : [];
692
+ if (mergeKeys.length) {
693
+ this.#database.prepare(`
694
+ DELETE FROM ilink_inbound_images
695
+ WHERE message_key IN (${mergeKeys.map(() => '?').join(',')})
696
+ `).run(...mergeKeys);
697
+ }
698
+ else if (insertedEntries.length) {
699
+ this.#database.prepare(`
700
+ DELETE FROM ilink_inbound_images
701
+ WHERE message_key IN (${insertedEntries.map(() => '?').join(',')})
702
+ `).run(...insertedEntries.map(({ messageKey }) => messageKey));
703
+ }
704
+ if (deliverable) {
705
+ for (const [position, image] of mergedImages.entries()) {
706
+ this.#database.prepare(`
707
+ INSERT INTO ilink_inbound_images (
708
+ message_key, position, account_key, peer_id, secret_generation,
709
+ nonce, ciphertext, auth_tag, created_at
710
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
711
+ `).run(deliverable.messageKey, position, input.accountKey, account.owner_peer_id, image.secret_generation, image.nonce, image.ciphertext, image.auth_tag, now);
712
+ }
713
+ }
714
+ if (deliverable &&
715
+ (backlogSummaries.length + pageSummaries.length > 1 || mergedImages.length)) {
716
+ const mergedPayload = {
717
+ ...deliverable.payload,
718
+ summary: [
719
+ ...backlogSummaries,
720
+ ...pageSummaries,
721
+ ...(mergedImageCount > 4
722
+ ? [`[iLink images: attached the latest 4 of ${mergedImageCount}]`]
723
+ : []),
724
+ ].join('\n'),
725
+ attachments: mergedImages.map((_, position) => ({
726
+ kind: 'image',
727
+ mediaId: `ilink:${position}`,
728
+ filename: `ilink-image-${position}`,
729
+ status: 'unresolved',
730
+ })),
731
+ };
732
+ this.#database.prepare(`
733
+ UPDATE inbound_messages SET payload_json = ?, updated_at = ?
734
+ WHERE message_key = ? AND status = 'received'
735
+ `).run(encodeJson(mergedPayload), now, deliverable.messageKey);
736
+ }
737
+ return {
738
+ insertedMessageKeys: deliverable && !deliverableDeferred ? [deliverable.messageKey] : [],
739
+ replyWindowIds,
740
+ deferredMessageCount: deliverableDeferred ? 1 : 0,
741
+ cursor: nextCursor,
742
+ };
743
+ });
744
+ }
745
+ #validatePageEntry(entry, account, expectedCursor) {
746
+ if (!entry || typeof entry !== 'object') {
747
+ fail('invalid_input', 'iLink poll entry is invalid');
748
+ }
749
+ const { message } = entry;
750
+ assertIlinkEncryptedSecret(entry.sealedContextToken);
751
+ nonNegativeInteger(entry.secretGeneration, 'secretGeneration');
752
+ const sealedImages = entry.sealedImages || [];
753
+ const imagePositions = new Set(message?.attachments.flatMap((attachment) => {
754
+ const matched = attachment.kind === 'image'
755
+ ? /^ilink:(\d+)$/u.exec(attachment.mediaId)
756
+ : null;
757
+ return matched?.[1] === undefined ? [] : [Number(matched[1])];
758
+ }) || []);
759
+ if (imagePositions.size !== (message?.attachments.length || 0) ||
760
+ sealedImages.length !== imagePositions.size) {
761
+ fail('invalid_input', 'iLink image locator count is inconsistent');
762
+ }
763
+ for (const image of sealedImages) {
764
+ nonNegativeInteger(image.position, 'image position');
765
+ nonNegativeInteger(image.secretGeneration, 'image secretGeneration');
766
+ assertIlinkEncryptedSecret(image.sealedLocator);
767
+ if (!imagePositions.has(image.position)) {
768
+ fail('invalid_input', 'iLink image locator position is inconsistent');
769
+ }
770
+ }
771
+ if (!message ||
772
+ message.conversation.channel !== ILINK_CHANNEL ||
773
+ message.conversation.accountKey !== account.account_key ||
774
+ message.conversation.peerId !== account.owner_peer_id) {
775
+ fail('pair_mismatch', 'iLink poll message does not match its account');
776
+ }
777
+ if (message.sync.cursor !== expectedCursor) {
778
+ fail('cursor_conflict', 'iLink message cursor does not match its page');
779
+ }
780
+ nonNegativeInteger(message.sync.index, 'message sync index');
781
+ nonNegativeInteger(message.sentAt, 'message sentAt');
782
+ if (entry.providerSeq !== undefined) {
783
+ nonNegativeInteger(entry.providerSeq, 'message providerSeq');
784
+ }
785
+ if (!message.providerMessageId ||
786
+ Buffer.byteLength(message.providerMessageId, 'utf8') >
787
+ ILINK_MAX_PROVIDER_ID_BYTES) {
788
+ fail('invalid_input', 'iLink message identity is invalid');
789
+ }
790
+ }
791
+ #validateDuplicate(message, messageKey) {
792
+ const existing = rowAs(this.#database.prepare(`
793
+ SELECT message_key, open_kfid, msgid, external_userid, channel
794
+ FROM inbound_messages
795
+ WHERE message_key = ?
796
+ `).get(messageKey));
797
+ if (!existing ||
798
+ existing.message_key !== messageKey ||
799
+ existing.open_kfid !== message.conversation.accountKey ||
800
+ existing.msgid !== message.providerMessageId ||
801
+ existing.external_userid !== message.conversation.peerId ||
802
+ existing.channel !== ILINK_CHANNEL) {
803
+ fail('dedupe_invariant', 'iLink message dedupe identity conflicts');
804
+ }
805
+ const window = this.#database.prepare(`
806
+ SELECT 1 FROM ilink_reply_windows WHERE source_message_key = ?
807
+ `).get(messageKey);
808
+ if (!window) {
809
+ fail('dedupe_invariant', 'Deduplicated iLink message has no reply window');
810
+ }
811
+ }
812
+ #openWindow(accountKey, peerId) {
813
+ return rowAs(this.#database.prepare(`
814
+ SELECT window.*, inbound.msgid AS provider_message_id
815
+ FROM ilink_reply_windows AS window
816
+ JOIN inbound_messages AS inbound
817
+ ON inbound.message_key = window.source_message_key
818
+ WHERE window.account_key = ? AND window.peer_id = ?
819
+ AND window.state = 'open'
820
+ `).get(accountKey, peerId));
821
+ }
822
+ #insertReplyWindow(account, entry, sourceMessageKey, sourceInboxSeq, pageWindowSources, now) {
823
+ const { message } = entry;
824
+ const current = this.#openWindow(account.account_key, message.conversation.peerId);
825
+ const becomesOpen = !current || entryIsNewer(entry, current, pageWindowSources.has(current.source_message_key));
826
+ if (current && becomesOpen) {
827
+ this.#retireWindow(current.reply_window_id, 'superseded', now, 'superseded_by_newer_ilink_message');
828
+ }
829
+ if (message.sentAt > now + MAX_UPSTREAM_CLOCK_SKEW_MS) {
830
+ fail('invalid_input', 'iLink message timestamp is too far in the future');
831
+ }
832
+ const expiresAt = Math.min(message.sentAt, now) +
833
+ ILINK_REPLY_WINDOW_LIFETIME_MS;
834
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= message.sentAt) {
835
+ fail('invalid_input', 'iLink reply window expiry is invalid');
836
+ }
837
+ const inserted = this.#database.prepare(`
838
+ INSERT INTO ilink_reply_windows (
839
+ account_key, peer_id, account_generation,
840
+ source_message_key, source_inbox_seq, provider_seq,
841
+ issued_at, expires_at, max_sends,
842
+ next_send_index, reserved_send_count, transmitted_send_count,
843
+ state, secret_generation, created_at, updated_at
844
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?)
845
+ `).run(account.account_key, message.conversation.peerId, account.generation, sourceMessageKey, sourceInboxSeq, entry.providerSeq ?? null, message.sentAt, expiresAt, ILINK_REPLY_WINDOW_MAX_SENDS, becomesOpen ? 'open' : 'superseded', entry.secretGeneration, now, now);
846
+ const replyWindowId = Number(inserted.lastInsertRowid);
847
+ positiveInteger(replyWindowId, 'replyWindowId');
848
+ if (becomesOpen) {
849
+ this.#database.prepare(`
850
+ INSERT INTO ilink_reply_window_secrets (
851
+ reply_window_id, nonce, ciphertext, auth_tag, updated_at
852
+ ) VALUES (?, ?, ?, ?, ?)
853
+ `).run(replyWindowId, entry.sealedContextToken.nonce, entry.sealedContextToken.ciphertext, entry.sealedContextToken.authTag, now);
854
+ }
855
+ return replyWindowId;
856
+ }
857
+ #retireWindow(replyWindowId, state, now, reason) {
858
+ this.#database.prepare(`
859
+ UPDATE send_attempts
860
+ SET status = 'failed', error_code = ?, error_message = ?, updated_at = ?
861
+ WHERE reply_window_id = ? AND channel = 'weixin_ilink'
862
+ AND status = 'pending'
863
+ `).run(reason, 'iLink reply window is no longer active', now, replyWindowId);
864
+ this.#database.prepare(`
865
+ UPDATE agent_sessions
866
+ SET closed_at = ?, updated_at = ?
867
+ WHERE reply_window_id = ? AND channel = 'weixin_ilink'
868
+ AND closed_at = 0
869
+ `).run(now, now, replyWindowId);
870
+ this.#database.prepare(`
871
+ UPDATE ilink_reply_windows
872
+ SET state = ?, reserved_send_count = 0, updated_at = ?
873
+ WHERE reply_window_id = ? AND state = 'open'
874
+ `).run(state, now, replyWindowId);
875
+ this.#database.prepare(`
876
+ DELETE FROM ilink_reply_window_secrets WHERE reply_window_id = ?
877
+ `).run(replyWindowId);
878
+ }
879
+ getReplyWindow(replyWindowId) {
880
+ positiveInteger(replyWindowId, 'replyWindowId');
881
+ const row = rowAs(this.#database.prepare(`
882
+ SELECT * FROM ilink_reply_windows WHERE reply_window_id = ?
883
+ `).get(replyWindowId));
884
+ return row ? mapReplyWindow(row) : undefined;
885
+ }
886
+ getReplyWindowSecret(replyWindowId) {
887
+ positiveInteger(replyWindowId, 'replyWindowId');
888
+ const row = rowAs(this.#database.prepare(`
889
+ SELECT window.*, secret.nonce, secret.ciphertext, secret.auth_tag,
890
+ secret.updated_at AS secret_updated_at
891
+ FROM ilink_reply_windows AS window
892
+ JOIN ilink_reply_window_secrets AS secret USING (reply_window_id)
893
+ WHERE window.reply_window_id = ?
894
+ `).get(replyWindowId));
895
+ if (!row)
896
+ return undefined;
897
+ assertIlinkAccountKey(row.account_key);
898
+ return {
899
+ replyWindowId: Number(row.reply_window_id),
900
+ accountKey: row.account_key,
901
+ peerId: row.peer_id,
902
+ accountGeneration: Number(row.account_generation),
903
+ secretGeneration: Number(row.secret_generation),
904
+ sourceMessageKey: row.source_message_key,
905
+ sourceInboxSeq: Number(row.source_inbox_seq),
906
+ issuedAt: Number(row.issued_at),
907
+ expiresAt: Number(row.expires_at),
908
+ sealedContextToken: sealedSecret(row),
909
+ updatedAt: Number(row.secret_updated_at),
910
+ };
911
+ }
912
+ getReplyWindowSecretBySource(messageKey) {
913
+ const row = rowAs(this.#database.prepare(`
914
+ SELECT reply_window_id FROM ilink_reply_windows
915
+ WHERE source_message_key = ?
916
+ `).get(String(messageKey || '')));
917
+ return row ? this.getReplyWindowSecret(Number(row.reply_window_id)) : undefined;
918
+ }
919
+ getInboundImageSecret(messageKey, position) {
920
+ const row = rowAs(this.#database.prepare(`
921
+ SELECT * FROM ilink_inbound_images
922
+ WHERE message_key = ? AND position = ?
923
+ `).get(String(messageKey || ''), position));
924
+ if (!row)
925
+ return undefined;
926
+ assertIlinkAccountKey(row.account_key);
927
+ return {
928
+ messageKey: row.message_key,
929
+ position: Number(row.position),
930
+ accountKey: row.account_key,
931
+ peerId: row.peer_id,
932
+ secretGeneration: Number(row.secret_generation),
933
+ sealedLocator: {
934
+ nonce: row.nonce,
935
+ ciphertext: row.ciphertext,
936
+ authTag: row.auth_tag,
937
+ },
938
+ };
939
+ }
940
+ reserveStartedSystemAttempt(input) {
941
+ const now = this.#now(input.now);
942
+ const payloadJson = encodeJson(input.payload);
943
+ return this.#transaction(() => {
944
+ const window = rowAs(this.#database.prepare(`
945
+ SELECT * FROM ilink_reply_windows
946
+ WHERE source_message_key = ?
947
+ `).get(input.messageKey));
948
+ if (!window)
949
+ fail('reply_window_not_found', 'iLink reply window is missing');
950
+ const account = this.#accountRow(window.account_key);
951
+ if (!account || account.status !== 'active' ||
952
+ account.generation !== window.account_generation || window.state !== 'open') {
953
+ fail('reply_window_inactive', 'iLink reply window is not active');
954
+ }
955
+ if (now >= window.expires_at) {
956
+ fail('reply_window_expired', 'iLink reply window has expired');
957
+ }
958
+ if (window.reserved_send_count + window.transmitted_send_count >=
959
+ window.max_sends) {
960
+ fail('reply_quota_exhausted', 'iLink reply window quota is exhausted');
961
+ }
962
+ const sending = this.#database.prepare(`
963
+ SELECT 1 FROM send_attempts
964
+ WHERE channel = 'weixin_ilink' AND status = 'sending'
965
+ AND open_kfid = ? AND external_userid = ? LIMIT 1
966
+ `).get(window.account_key, window.peer_id);
967
+ if (sending)
968
+ fail('send_in_progress', 'Another iLink send is in progress');
969
+ const physical = Number(rowAs(this.#database.prepare(`
970
+ SELECT COALESCE(MAX(send_index) + 1, 0) AS next_index
971
+ FROM send_attempts WHERE source_message_key = ?
972
+ `).get(input.messageKey))?.next_index || 0);
973
+ if (!Number.isSafeInteger(physical) || physical < 0 || physical >= 1_000) {
974
+ fail('attempt_conflict', 'iLink source attempt index is exhausted');
975
+ }
976
+ const attemptKey = stableAttemptKey(input.messageKey, physical);
977
+ const clientMessageId = stableClientMessageId(input.messageKey, physical);
978
+ const metadataJson = encodeJson({
979
+ ...(input.metadata || {}),
980
+ direction: window.source_inbox_seq,
981
+ replyWindowSendIndex: window.next_send_index,
982
+ });
983
+ const updated = this.#database.prepare(`
984
+ UPDATE ilink_reply_windows
985
+ SET next_send_index = next_send_index + 1,
986
+ transmitted_send_count = transmitted_send_count + 1, updated_at = ?
987
+ WHERE reply_window_id = ? AND state = 'open'
988
+ AND reserved_send_count + transmitted_send_count < max_sends
989
+ `).run(now, window.reply_window_id);
990
+ if (updated.changes !== 1)
991
+ fail('attempt_conflict', 'iLink system send lost quota');
992
+ this.#database.prepare(`
993
+ INSERT INTO send_attempts (
994
+ attempt_key, source_message_key, open_kfid, external_userid,
995
+ channel, reply_window_id, send_index, source, sent_type,
996
+ payload_json, metadata_json, fingerprint, client_message_id,
997
+ status, wecom_msgid, error_code, error_message, fail_type,
998
+ created_at, updated_at
999
+ ) VALUES (?, ?, ?, ?, 'weixin_ilink', ?, ?, ?, ?, ?, ?, ?, ?,
1000
+ 'sending', '', '', '', 0, ?, ?)
1001
+ `).run(attemptKey, input.messageKey, window.account_key, window.peer_id, window.reply_window_id, physical, input.source, input.sentType, payloadJson, metadataJson, sha256(`${input.sentType}\0${payloadJson}`), clientMessageId, now, now);
1002
+ return mapAttempt(rowAs(this.#database.prepare(`
1003
+ SELECT * FROM send_attempts WHERE attempt_key = ?
1004
+ `).get(attemptKey)));
1005
+ });
1006
+ }
1007
+ #sessionWindow(sessionToken) {
1008
+ if (!sessionToken)
1009
+ fail('invalid_agent_session', 'Agent session is required');
1010
+ return rowAs(this.#database.prepare(`
1011
+ SELECT
1012
+ window.*,
1013
+ session.source_message_key AS session_source_message_key,
1014
+ session.open_kfid AS session_open_kfid,
1015
+ session.external_userid AS session_external_userid,
1016
+ session.reply_window_id AS session_reply_window_id,
1017
+ session.boundary_inbox_seq,
1018
+ session.expires_at AS session_expires_at,
1019
+ session.closed_at AS session_closed_at,
1020
+ inbound.status AS session_inbound_status,
1021
+ account.status AS account_status,
1022
+ account.generation AS current_account_generation
1023
+ FROM agent_sessions AS session
1024
+ JOIN ilink_reply_windows AS window
1025
+ ON window.reply_window_id = session.reply_window_id
1026
+ JOIN ilink_accounts AS account
1027
+ ON account.account_key = window.account_key
1028
+ JOIN inbound_messages AS inbound
1029
+ ON inbound.message_key = session.source_message_key
1030
+ WHERE session.token_hash = ? AND session.channel = 'weixin_ilink'
1031
+ `).get(sha256(sessionToken)));
1032
+ }
1033
+ #validateSessionWindow(row, now) {
1034
+ if (!row ||
1035
+ row.session_closed_at !== 0 ||
1036
+ row.session_expires_at <= now ||
1037
+ !['processing', 'preparing'].includes(row.session_inbound_status) ||
1038
+ row.session_reply_window_id !== row.reply_window_id ||
1039
+ row.boundary_inbox_seq !== row.source_inbox_seq ||
1040
+ row.session_open_kfid !== row.account_key ||
1041
+ row.session_external_userid !== row.peer_id) {
1042
+ fail('invalid_agent_session', 'Agent session is not active for iLink');
1043
+ }
1044
+ if (row.account_status !== 'active' ||
1045
+ row.current_account_generation !== row.account_generation) {
1046
+ fail('generation_conflict', 'iLink account generation changed');
1047
+ }
1048
+ if (now >= row.expires_at) {
1049
+ fail('reply_window_expired', 'iLink reply window has expired');
1050
+ }
1051
+ if (row.state !== 'open') {
1052
+ fail('reply_window_inactive', 'iLink reply window is not active');
1053
+ }
1054
+ return row;
1055
+ }
1056
+ #prepareReplyAttempt(input, persistRejection) {
1057
+ const now = this.#now(input.now);
1058
+ if (!input.sentType)
1059
+ fail('invalid_input', 'sentType is required');
1060
+ const payloadJson = encodeJson(input.payload);
1061
+ const candidate = this.#sessionWindow(input.sessionToken);
1062
+ let window;
1063
+ let rejection;
1064
+ try {
1065
+ window = this.#validateSessionWindow(candidate, now);
1066
+ }
1067
+ catch (error) {
1068
+ if (!persistRejection || !candidate ||
1069
+ !(error instanceof IlinkSqliteStoreError) ||
1070
+ error.code !== 'reply_window_expired')
1071
+ throw error;
1072
+ window = candidate;
1073
+ rejection = error.code;
1074
+ }
1075
+ if (!rejection &&
1076
+ window.reserved_send_count + window.transmitted_send_count >= window.max_sends) {
1077
+ if (!persistRejection) {
1078
+ fail('reply_quota_exhausted', 'iLink reply window quota is exhausted');
1079
+ }
1080
+ rejection = 'reply_quota_exhausted';
1081
+ }
1082
+ const windowSendIndex = Number(window.next_send_index);
1083
+ const physicalIndexRow = rowAs(this.#database.prepare(`
1084
+ SELECT COALESCE(MAX(send_index) + 1, 0) AS next_index
1085
+ FROM send_attempts WHERE source_message_key = ?
1086
+ `).get(window.session_source_message_key));
1087
+ const sendIndex = Number(physicalIndexRow?.next_index ?? 0);
1088
+ if (!Number.isSafeInteger(sendIndex) || sendIndex < 0 || sendIndex >= 1000) {
1089
+ fail('reply_quota_exhausted', 'iLink source attempt index is exhausted');
1090
+ }
1091
+ const attemptKey = stableAttemptKey(window.session_source_message_key, sendIndex);
1092
+ const clientMessageId = stableClientMessageId(window.session_source_message_key, sendIndex);
1093
+ const metadataJson = encodeJson({
1094
+ ...(input.metadata || {}),
1095
+ direction: window.source_inbox_seq,
1096
+ replyWindowSendIndex: windowSendIndex,
1097
+ });
1098
+ if (!rejection) {
1099
+ const windowUpdate = this.#database.prepare(`
1100
+ UPDATE ilink_reply_windows
1101
+ SET next_send_index = next_send_index + 1,
1102
+ reserved_send_count = reserved_send_count + 1,
1103
+ updated_at = ?
1104
+ WHERE reply_window_id = ? AND state = 'open'
1105
+ AND account_generation = ?
1106
+ AND next_send_index = ?
1107
+ AND reserved_send_count + transmitted_send_count < max_sends
1108
+ `).run(now, window.reply_window_id, window.account_generation, windowSendIndex);
1109
+ if (windowUpdate.changes !== 1) {
1110
+ fail('attempt_conflict', 'iLink reply reservation lost its race');
1111
+ }
1112
+ }
1113
+ this.#database.prepare(`
1114
+ INSERT INTO send_attempts (
1115
+ attempt_key, source_message_key, open_kfid, external_userid,
1116
+ channel, reply_window_id, send_index, source, sent_type,
1117
+ payload_json, metadata_json, fingerprint, client_message_id,
1118
+ status, wecom_msgid, error_code, error_message, fail_type,
1119
+ created_at, updated_at
1120
+ ) VALUES (?, ?, ?, ?, 'weixin_ilink', ?, ?, 'mcp_tool', ?, ?, ?, ?, ?,
1121
+ ?, '', ?, ?, 0, ?, ?)
1122
+ `).run(attemptKey, window.session_source_message_key, window.account_key, window.peer_id, window.reply_window_id, sendIndex, input.sentType, payloadJson, metadataJson, sha256(`${input.sentType}\0${payloadJson}`), clientMessageId, rejection ? 'failed' : 'pending', rejection || '', rejection === 'reply_window_expired'
1123
+ ? 'iLink reply window expired before network transmission'
1124
+ : rejection === 'reply_quota_exhausted'
1125
+ ? 'iLink 10-message reply quota exhausted before network transmission'
1126
+ : '', now, now);
1127
+ const attempt = mapAttempt(rowAs(this.#database.prepare(`
1128
+ SELECT * FROM send_attempts WHERE attempt_key = ?
1129
+ `).get(attemptKey)));
1130
+ return rejection
1131
+ ? { kind: 'rejected', attempt, code: rejection }
1132
+ : { kind: 'reserved', attempt };
1133
+ }
1134
+ reserveReplyAttempt(input) {
1135
+ const result = this.#transaction(() => this.#prepareReplyAttempt(input, false));
1136
+ if (result.kind !== 'reserved')
1137
+ fail('attempt_conflict', 'Unexpected rejection');
1138
+ return result.attempt;
1139
+ }
1140
+ prepareReplyAttempt(input) {
1141
+ return this.#transaction(() => this.#prepareReplyAttempt(input, true));
1142
+ }
1143
+ #pendingReplyAttempt(input, now) {
1144
+ const window = this.#validateSessionWindow(this.#sessionWindow(input.sessionToken), now);
1145
+ const attempt = rowAs(this.#database.prepare(`
1146
+ SELECT * FROM send_attempts
1147
+ WHERE attempt_key = ? AND channel = 'weixin_ilink'
1148
+ `).get(input.attemptId));
1149
+ if (!attempt || attempt.status !== 'pending' ||
1150
+ attempt.reply_window_id !== window.reply_window_id ||
1151
+ attempt.open_kfid !== window.account_key ||
1152
+ attempt.external_userid !== window.peer_id)
1153
+ fail('attempt_conflict', 'iLink reply attempt is not reservable');
1154
+ return { window, attempt };
1155
+ }
1156
+ validatePendingReplyAttempt(input) {
1157
+ const now = this.#now(input.now);
1158
+ return this.#transaction(() => mapAttempt(this.#pendingReplyAttempt(input, now).attempt));
1159
+ }
1160
+ startReplyAttempt(input) {
1161
+ const now = this.#now(input.now);
1162
+ return this.#transaction(() => {
1163
+ const { window, attempt } = this.#pendingReplyAttempt(input, now);
1164
+ const sending = this.#database.prepare(`
1165
+ SELECT 1 FROM send_attempts
1166
+ WHERE channel = 'weixin_ilink' AND status = 'sending'
1167
+ AND open_kfid = ? AND external_userid = ?
1168
+ LIMIT 1
1169
+ `).get(window.account_key, window.peer_id);
1170
+ if (sending) {
1171
+ fail('send_in_progress', 'Another iLink send is already in progress');
1172
+ }
1173
+ const attemptUpdate = this.#database.prepare(`
1174
+ UPDATE send_attempts SET status = 'sending', updated_at = ?
1175
+ WHERE attempt_key = ? AND status = 'pending'
1176
+ `).run(now, attempt.attempt_key);
1177
+ const windowUpdate = this.#database.prepare(`
1178
+ UPDATE ilink_reply_windows
1179
+ SET reserved_send_count = reserved_send_count - 1,
1180
+ transmitted_send_count = transmitted_send_count + 1,
1181
+ updated_at = ?
1182
+ WHERE reply_window_id = ? AND state = 'open'
1183
+ AND reserved_send_count > 0
1184
+ AND reserved_send_count + transmitted_send_count <= max_sends
1185
+ `).run(now, window.reply_window_id);
1186
+ if (attemptUpdate.changes !== 1 || windowUpdate.changes !== 1) {
1187
+ fail('attempt_conflict', 'iLink reply attempt start lost its race');
1188
+ }
1189
+ return mapAttempt(rowAs(this.#database.prepare(`
1190
+ SELECT * FROM send_attempts WHERE attempt_key = ?
1191
+ `).get(attempt.attempt_key)));
1192
+ });
1193
+ }
1194
+ }