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