@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.
- package/.env.example +46 -0
- package/CHANGELOG.md +95 -0
- package/LICENSE +202 -0
- package/README.md +150 -0
- package/README.zh-CN.md +79 -0
- package/THIRD_PARTY_NOTICES +31 -0
- package/assets/ilink-login-card.png +0 -0
- package/bin/kintio.js +3 -0
- package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
- package/dist/cli.js +3 -0
- package/dist/daemon.js +28 -0
- package/dist/index.js +70 -0
- package/dist/mcp-relay.js +11 -0
- package/dist/src/agent/runtime.js +1 -0
- package/dist/src/app.js +34 -0
- package/dist/src/cli.js +578 -0
- package/dist/src/config.js +237 -0
- package/dist/src/domain/message.js +23 -0
- package/dist/src/domain/send-contract.js +205 -0
- package/dist/src/domain/wecom-message.js +281 -0
- package/dist/src/ilink/executor.js +306 -0
- package/dist/src/ilink/inbound-image.js +310 -0
- package/dist/src/ilink/listener.js +306 -0
- package/dist/src/ilink/login-manager.js +198 -0
- package/dist/src/ilink/login-store.js +197 -0
- package/dist/src/ilink/media-gateway.js +83 -0
- package/dist/src/ilink/media.js +267 -0
- package/dist/src/ilink/message.js +247 -0
- package/dist/src/ilink/protocol/client.js +464 -0
- package/dist/src/ilink/protocol/types.js +35 -0
- package/dist/src/ilink/qr.js +109 -0
- package/dist/src/ilink/secret-box.js +143 -0
- package/dist/src/ilink/sqlite-store.js +1194 -0
- package/dist/src/ilink/store-types.js +63 -0
- package/dist/src/lib/image-format.js +23 -0
- package/dist/src/lib/path-identity.js +38 -0
- package/dist/src/lib/private-directory.js +51 -0
- package/dist/src/lib/text.js +19 -0
- package/dist/src/lib/wecom-crypto.js +74 -0
- package/dist/src/lib/xml.js +8 -0
- package/dist/src/mcp/conversation-memory-server.js +179 -0
- package/dist/src/mcp/ilink-server.js +158 -0
- package/dist/src/mcp/ipc-host.js +275 -0
- package/dist/src/mcp/ipc-protocol.js +226 -0
- package/dist/src/mcp/stdio-relay.js +122 -0
- package/dist/src/mcp/wechat-kf-executor.js +295 -0
- package/dist/src/mcp/wechat-kf-server.js +208 -0
- package/dist/src/routes/wecom.js +89 -0
- package/dist/src/runtime/daemon-protocol.js +202 -0
- package/dist/src/runtime/managed-skill.js +49 -0
- package/dist/src/runtime/native-daemon.js +325 -0
- package/dist/src/runtime/single-instance-lock.js +167 -0
- package/dist/src/runtime.js +503 -0
- package/dist/src/services/codex-agent.js +542 -0
- package/dist/src/services/codex-app-server.js +436 -0
- package/dist/src/services/conversation-processor.js +762 -0
- package/dist/src/services/image-stager.js +49 -0
- package/dist/src/services/media-gateway.js +83 -0
- package/dist/src/services/wecom-api.js +311 -0
- package/dist/src/services/wecom-sync.js +316 -0
- package/dist/src/state/persistence.js +124 -0
- package/dist/src/state/sqlite-store.js +3102 -0
- package/dist/src/supervisor.js +212 -0
- package/dist/src/types.js +1 -0
- package/dist/src/version.js +1 -0
- 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
|
+
}
|