@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,310 @@
|
|
|
1
|
+
import { createDecipheriv } from 'node:crypto';
|
|
2
|
+
import { detectImageFormat, MAX_WECHAT_IMAGE_BYTES, } from '../lib/image-format.js';
|
|
3
|
+
const ILINK_CDN_ORIGIN = 'https://novac2c.cdn.weixin.qq.com';
|
|
4
|
+
const ILINK_CDN_DOWNLOAD_PATH = '/c2c/download';
|
|
5
|
+
const ILINK_CDN_HOSTNAME = 'novac2c.cdn.weixin.qq.com';
|
|
6
|
+
const MAX_LOCATOR_CHARACTERS = 32 * 1024;
|
|
7
|
+
const MAX_ENCRYPTED_IMAGE_BYTES = MAX_WECHAT_IMAGE_BYTES + 16;
|
|
8
|
+
const MAX_IMAGE_DIMENSION = 10_000;
|
|
9
|
+
const MAX_IMAGE_PIXELS = 25_000_000;
|
|
10
|
+
export const DEFAULT_ILINK_IMAGE_TIMEOUT_MS = 10_000;
|
|
11
|
+
export class IlinkInboundImageError extends Error {
|
|
12
|
+
kind;
|
|
13
|
+
constructor(kind, message) {
|
|
14
|
+
// Deliberately do not retain a cause: fetch and crypto errors can contain
|
|
15
|
+
// the signed CDN URL or other provider-controlled values.
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'IlinkInboundImageError';
|
|
18
|
+
this.kind = kind;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
function invalidLocator() {
|
|
25
|
+
return new IlinkInboundImageError('invalid_locator', 'iLink image contains an invalid or missing CDN locator');
|
|
26
|
+
}
|
|
27
|
+
function invalidKey() {
|
|
28
|
+
return new IlinkInboundImageError('invalid_key', 'iLink image contains an invalid or missing AES key');
|
|
29
|
+
}
|
|
30
|
+
function parseHexKey(value) {
|
|
31
|
+
if (typeof value !== 'string' || !/^[0-9a-fA-F]{32}$/u.test(value)) {
|
|
32
|
+
throw invalidKey();
|
|
33
|
+
}
|
|
34
|
+
return Buffer.from(value, 'hex');
|
|
35
|
+
}
|
|
36
|
+
function parseBase64Key(value) {
|
|
37
|
+
if (typeof value !== 'string' ||
|
|
38
|
+
value.length < 1 ||
|
|
39
|
+
value.length > 64 ||
|
|
40
|
+
value.length % 4 === 1 ||
|
|
41
|
+
!/^[A-Za-z0-9+/]+={0,2}$/u.test(value)) {
|
|
42
|
+
throw invalidKey();
|
|
43
|
+
}
|
|
44
|
+
const unpadded = value.replace(/=+$/u, '');
|
|
45
|
+
const decoded = Buffer.from(value, 'base64');
|
|
46
|
+
if (decoded.toString('base64').replace(/=+$/u, '') !== unpadded) {
|
|
47
|
+
throw invalidKey();
|
|
48
|
+
}
|
|
49
|
+
if (decoded.length === 16)
|
|
50
|
+
return decoded;
|
|
51
|
+
if (decoded.length === 32 &&
|
|
52
|
+
/^[0-9a-fA-F]{32}$/u.test(decoded.toString('ascii'))) {
|
|
53
|
+
return Buffer.from(decoded.toString('ascii'), 'hex');
|
|
54
|
+
}
|
|
55
|
+
throw invalidKey();
|
|
56
|
+
}
|
|
57
|
+
function validateDownloadUrl(raw) {
|
|
58
|
+
if (!raw ||
|
|
59
|
+
raw !== raw.trim() ||
|
|
60
|
+
raw.length > MAX_LOCATOR_CHARACTERS ||
|
|
61
|
+
/[\u0000-\u001f\u007f]/u.test(raw)) {
|
|
62
|
+
throw invalidLocator();
|
|
63
|
+
}
|
|
64
|
+
let url;
|
|
65
|
+
try {
|
|
66
|
+
url = new URL(raw);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw invalidLocator();
|
|
70
|
+
}
|
|
71
|
+
if (url.protocol !== 'https:' ||
|
|
72
|
+
url.hostname !== ILINK_CDN_HOSTNAME ||
|
|
73
|
+
url.username ||
|
|
74
|
+
url.password ||
|
|
75
|
+
url.port ||
|
|
76
|
+
url.hash) {
|
|
77
|
+
throw invalidLocator();
|
|
78
|
+
}
|
|
79
|
+
return url.href;
|
|
80
|
+
}
|
|
81
|
+
function buildDownloadUrl(queryParam) {
|
|
82
|
+
if (typeof queryParam !== 'string' ||
|
|
83
|
+
!queryParam ||
|
|
84
|
+
queryParam !== queryParam.trim() ||
|
|
85
|
+
queryParam.length > MAX_LOCATOR_CHARACTERS ||
|
|
86
|
+
/[\u0000-\u001f\u007f]/u.test(queryParam)) {
|
|
87
|
+
throw invalidLocator();
|
|
88
|
+
}
|
|
89
|
+
const query = new URLSearchParams({ encrypted_query_param: queryParam });
|
|
90
|
+
return `${ILINK_CDN_ORIGIN}${ILINK_CDN_DOWNLOAD_PATH}?${query}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Extract only the original-image CDN URL and AES key. `url`, `thumb_media`,
|
|
94
|
+
* and all non-image media fields are intentionally ignored.
|
|
95
|
+
*/
|
|
96
|
+
export function extractIlinkInboundImageLocator(imageItem) {
|
|
97
|
+
if (!isRecord(imageItem) || !isRecord(imageItem.media)) {
|
|
98
|
+
throw invalidLocator();
|
|
99
|
+
}
|
|
100
|
+
const media = imageItem.media;
|
|
101
|
+
const downloadUrl = media.full_url !== undefined
|
|
102
|
+
? validateDownloadUrl(typeof media.full_url === 'string' ? media.full_url : '')
|
|
103
|
+
: buildDownloadUrl(media.encrypt_query_param);
|
|
104
|
+
// The official 2.4.6 implementation observes two key encodings. The
|
|
105
|
+
// image-level raw hex key takes precedence and an invalid preferred key is
|
|
106
|
+
// rejected rather than silently falling back to another field.
|
|
107
|
+
const aesKey = imageItem.aeskey !== undefined
|
|
108
|
+
? parseHexKey(imageItem.aeskey)
|
|
109
|
+
: parseBase64Key(media.aes_key);
|
|
110
|
+
return Object.freeze({ downloadUrl, aesKey });
|
|
111
|
+
}
|
|
112
|
+
function normalizeTimeout(value) {
|
|
113
|
+
const timeoutMs = value ?? DEFAULT_ILINK_IMAGE_TIMEOUT_MS;
|
|
114
|
+
if (!Number.isSafeInteger(timeoutMs) ||
|
|
115
|
+
timeoutMs < 1 ||
|
|
116
|
+
timeoutMs > DEFAULT_ILINK_IMAGE_TIMEOUT_MS) {
|
|
117
|
+
throw new IlinkInboundImageError('download_failed', `iLink image timeout must be between 1 and ${DEFAULT_ILINK_IMAGE_TIMEOUT_MS} ms`);
|
|
118
|
+
}
|
|
119
|
+
return timeoutMs;
|
|
120
|
+
}
|
|
121
|
+
function declaredContentLength(response) {
|
|
122
|
+
const raw = response.headers.get('content-length');
|
|
123
|
+
if (raw === null)
|
|
124
|
+
return undefined;
|
|
125
|
+
if (!/^\d+$/u.test(raw)) {
|
|
126
|
+
throw new IlinkInboundImageError('download_failed', 'iLink image CDN returned an invalid response');
|
|
127
|
+
}
|
|
128
|
+
const length = Number(raw);
|
|
129
|
+
if (!Number.isSafeInteger(length)) {
|
|
130
|
+
throw new IlinkInboundImageError('response_too_large', 'iLink encrypted image exceeds the response size limit');
|
|
131
|
+
}
|
|
132
|
+
return length;
|
|
133
|
+
}
|
|
134
|
+
async function readBoundedBody(response) {
|
|
135
|
+
const length = declaredContentLength(response);
|
|
136
|
+
if (length !== undefined && length > MAX_ENCRYPTED_IMAGE_BYTES) {
|
|
137
|
+
throw new IlinkInboundImageError('response_too_large', 'iLink encrypted image exceeds the response size limit');
|
|
138
|
+
}
|
|
139
|
+
const reader = response.body?.getReader();
|
|
140
|
+
if (!reader) {
|
|
141
|
+
throw new IlinkInboundImageError('download_failed', 'iLink image CDN returned an empty response');
|
|
142
|
+
}
|
|
143
|
+
const chunks = [];
|
|
144
|
+
let total = 0;
|
|
145
|
+
while (true) {
|
|
146
|
+
const result = await reader.read();
|
|
147
|
+
if (result.done)
|
|
148
|
+
break;
|
|
149
|
+
const chunk = Buffer.from(result.value);
|
|
150
|
+
total += chunk.length;
|
|
151
|
+
if (total > MAX_ENCRYPTED_IMAGE_BYTES) {
|
|
152
|
+
void reader.cancel().catch(() => undefined);
|
|
153
|
+
throw new IlinkInboundImageError('response_too_large', 'iLink encrypted image exceeds the response size limit');
|
|
154
|
+
}
|
|
155
|
+
chunks.push(chunk);
|
|
156
|
+
}
|
|
157
|
+
return Buffer.concat(chunks, total);
|
|
158
|
+
}
|
|
159
|
+
async function fetchEncryptedImage(downloadUrl, fetchImpl, signal) {
|
|
160
|
+
let response;
|
|
161
|
+
try {
|
|
162
|
+
response = await fetchImpl(downloadUrl, {
|
|
163
|
+
method: 'GET',
|
|
164
|
+
redirect: 'error',
|
|
165
|
+
signal,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
if (signal.aborted) {
|
|
170
|
+
throw new IlinkInboundImageError('download_timeout', 'iLink image download timed out');
|
|
171
|
+
}
|
|
172
|
+
throw new IlinkInboundImageError('download_failed', 'iLink image CDN request failed');
|
|
173
|
+
}
|
|
174
|
+
if (response.redirected) {
|
|
175
|
+
throw new IlinkInboundImageError('download_failed', 'iLink image CDN redirect was rejected');
|
|
176
|
+
}
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
throw new IlinkInboundImageError('download_failed', `iLink image CDN request failed with HTTP ${response.status}`);
|
|
179
|
+
}
|
|
180
|
+
return readBoundedBody(response);
|
|
181
|
+
}
|
|
182
|
+
async function fetchWithTimeout(downloadUrl, fetchImpl, timeoutMs) {
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
let timer;
|
|
185
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
186
|
+
timer = setTimeout(() => {
|
|
187
|
+
controller.abort();
|
|
188
|
+
reject(new IlinkInboundImageError('download_timeout', 'iLink image download timed out'));
|
|
189
|
+
}, timeoutMs);
|
|
190
|
+
timer.unref();
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
return await Promise.race([
|
|
194
|
+
fetchEncryptedImage(downloadUrl, fetchImpl, controller.signal),
|
|
195
|
+
timeout,
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
if (timer !== undefined)
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function decryptImage(ciphertext, aesKey) {
|
|
204
|
+
if (ciphertext.length === 0 || ciphertext.length % 16 !== 0) {
|
|
205
|
+
throw new IlinkInboundImageError('decryption_failed', 'iLink image ciphertext is invalid');
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const decipher = createDecipheriv('aes-128-ecb', aesKey, null);
|
|
209
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
throw new IlinkInboundImageError('decryption_failed', 'iLink image decryption failed');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function validateDimensions(width, height) {
|
|
216
|
+
if (width < 1 || height < 1 ||
|
|
217
|
+
width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION ||
|
|
218
|
+
width * height > MAX_IMAGE_PIXELS) {
|
|
219
|
+
throw new IlinkInboundImageError('unsupported_image', 'iLink image dimensions exceed the safe decode limit');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function validatePng(bytes) {
|
|
223
|
+
if (bytes.length < 45 || bytes.readUInt32BE(8) !== 13 ||
|
|
224
|
+
bytes.subarray(12, 16).toString('ascii') !== 'IHDR' ||
|
|
225
|
+
bytes.subarray(bytes.length - 8, bytes.length - 4).toString('ascii') !== 'IEND') {
|
|
226
|
+
throw new IlinkInboundImageError('unsupported_image', 'iLink PNG structure is incomplete');
|
|
227
|
+
}
|
|
228
|
+
validateDimensions(bytes.readUInt32BE(16), bytes.readUInt32BE(20));
|
|
229
|
+
}
|
|
230
|
+
function validateJpeg(bytes) {
|
|
231
|
+
if (bytes.length < 12 || bytes.readUInt16BE(0) !== 0xffd8) {
|
|
232
|
+
throw new IlinkInboundImageError('unsupported_image', 'iLink JPEG structure is incomplete');
|
|
233
|
+
}
|
|
234
|
+
let offset = 2;
|
|
235
|
+
let imageEnd = 0;
|
|
236
|
+
let dimensions;
|
|
237
|
+
while (offset + 3 < bytes.length) {
|
|
238
|
+
if (bytes[offset] !== 0xff)
|
|
239
|
+
break;
|
|
240
|
+
while (bytes[offset] === 0xff)
|
|
241
|
+
offset += 1;
|
|
242
|
+
const marker = bytes[offset++];
|
|
243
|
+
if (marker === undefined || marker === 0xd9)
|
|
244
|
+
break;
|
|
245
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7))
|
|
246
|
+
continue;
|
|
247
|
+
if (offset + 2 > bytes.length)
|
|
248
|
+
break;
|
|
249
|
+
const length = bytes.readUInt16BE(offset);
|
|
250
|
+
if (length < 2 || offset + length > bytes.length)
|
|
251
|
+
break;
|
|
252
|
+
if ([
|
|
253
|
+
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7,
|
|
254
|
+
0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
|
|
255
|
+
].includes(marker)) {
|
|
256
|
+
if (length < 7)
|
|
257
|
+
break;
|
|
258
|
+
dimensions = {
|
|
259
|
+
height: bytes.readUInt16BE(offset + 3),
|
|
260
|
+
width: bytes.readUInt16BE(offset + 5),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (marker === 0xda) {
|
|
264
|
+
const eoi = bytes.indexOf(Buffer.from([0xff, 0xd9]), offset + length);
|
|
265
|
+
if (eoi >= 0)
|
|
266
|
+
imageEnd = eoi + 2;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
offset += length;
|
|
270
|
+
}
|
|
271
|
+
if (!imageEnd) {
|
|
272
|
+
throw new IlinkInboundImageError('unsupported_image', 'iLink JPEG structure is incomplete');
|
|
273
|
+
}
|
|
274
|
+
if (!dimensions) {
|
|
275
|
+
throw new IlinkInboundImageError('unsupported_image', 'iLink JPEG dimensions are missing');
|
|
276
|
+
}
|
|
277
|
+
validateDimensions(dimensions.width, dimensions.height);
|
|
278
|
+
bytes.fill(0, imageEnd);
|
|
279
|
+
return bytes.subarray(0, imageEnd);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Download and decrypt one iLink image entirely in memory. No URL, key, or
|
|
283
|
+
* provider error body is retained in thrown errors.
|
|
284
|
+
*/
|
|
285
|
+
export async function downloadIlinkInboundImage(imageItem, options = {}) {
|
|
286
|
+
const locator = extractIlinkInboundImageLocator(imageItem);
|
|
287
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
288
|
+
if (typeof fetchImpl !== 'function') {
|
|
289
|
+
locator.aesKey.fill(0);
|
|
290
|
+
throw new IlinkInboundImageError('download_failed', 'iLink image download is unavailable');
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const ciphertext = await fetchWithTimeout(locator.downloadUrl, fetchImpl, normalizeTimeout(options.timeoutMs));
|
|
294
|
+
const bytes = decryptImage(ciphertext, locator.aesKey);
|
|
295
|
+
if (bytes.length > MAX_WECHAT_IMAGE_BYTES) {
|
|
296
|
+
throw new IlinkInboundImageError('image_too_large', 'iLink image exceeds the 2 MiB image size limit');
|
|
297
|
+
}
|
|
298
|
+
const format = detectImageFormat(bytes);
|
|
299
|
+
if (format?.mimeType !== 'image/png' && format?.mimeType !== 'image/jpeg') {
|
|
300
|
+
throw new IlinkInboundImageError('unsupported_image', 'iLink image must be PNG or JPEG');
|
|
301
|
+
}
|
|
302
|
+
if (format.mimeType === 'image/png')
|
|
303
|
+
validatePng(bytes);
|
|
304
|
+
const sanitized = format.mimeType === 'image/jpeg' ? validateJpeg(bytes) : bytes;
|
|
305
|
+
return Object.freeze({ bytes: sanitized, contentType: format.mimeType });
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
locator.aesKey.fill(0);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { normalizeIlinkInboundMessage, } from './message.js';
|
|
2
|
+
import { IlinkClient, IlinkProtocolError } from './protocol/client.js';
|
|
3
|
+
const DEFAULT_BACKOFF_MIN_MS = 250;
|
|
4
|
+
const DEFAULT_BACKOFF_MAX_MS = 10_000;
|
|
5
|
+
function abortableSleep(milliseconds, signal) {
|
|
6
|
+
if (signal.aborted)
|
|
7
|
+
return Promise.reject(signal.reason);
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const timeout = setTimeout(done, milliseconds);
|
|
10
|
+
function done() {
|
|
11
|
+
signal.removeEventListener('abort', aborted);
|
|
12
|
+
resolve();
|
|
13
|
+
}
|
|
14
|
+
function aborted() {
|
|
15
|
+
clearTimeout(timeout);
|
|
16
|
+
signal.removeEventListener('abort', aborted);
|
|
17
|
+
reject(signal.reason);
|
|
18
|
+
}
|
|
19
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function validRuntimeAccount(account) {
|
|
23
|
+
return Boolean(account &&
|
|
24
|
+
typeof account.accountKey === 'string' &&
|
|
25
|
+
typeof account.providerAccountId === 'string' &&
|
|
26
|
+
account.providerAccountId &&
|
|
27
|
+
typeof account.ownerPeerId === 'string' &&
|
|
28
|
+
account.ownerPeerId &&
|
|
29
|
+
Number.isSafeInteger(account.generation) &&
|
|
30
|
+
account.generation > 0 &&
|
|
31
|
+
typeof account.cursor === 'string' &&
|
|
32
|
+
typeof account.botToken === 'string' &&
|
|
33
|
+
account.botToken &&
|
|
34
|
+
typeof account.baseUrl === 'string' &&
|
|
35
|
+
account.baseUrl);
|
|
36
|
+
}
|
|
37
|
+
function sameRuntime(running, desired) {
|
|
38
|
+
const current = running.account;
|
|
39
|
+
return current.accountKey === desired.accountKey &&
|
|
40
|
+
current.providerAccountId === desired.providerAccountId &&
|
|
41
|
+
current.ownerPeerId === desired.ownerPeerId &&
|
|
42
|
+
current.generation === desired.generation &&
|
|
43
|
+
running.cursor === desired.cursor &&
|
|
44
|
+
current.botToken === desired.botToken &&
|
|
45
|
+
current.baseUrl === desired.baseUrl;
|
|
46
|
+
}
|
|
47
|
+
function fenceRejection(error) {
|
|
48
|
+
if (!error || typeof error !== 'object' || !('code' in error))
|
|
49
|
+
return false;
|
|
50
|
+
return [
|
|
51
|
+
'account_not_found',
|
|
52
|
+
'account_not_active',
|
|
53
|
+
'generation_conflict',
|
|
54
|
+
'cursor_conflict',
|
|
55
|
+
].includes(String(error.code));
|
|
56
|
+
}
|
|
57
|
+
function defaultClientFactory(account) {
|
|
58
|
+
return new IlinkClient({
|
|
59
|
+
token: account.botToken,
|
|
60
|
+
baseUrl: account.baseUrl,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export class IlinkListenerManager {
|
|
64
|
+
#host;
|
|
65
|
+
#createClient;
|
|
66
|
+
#logger;
|
|
67
|
+
#sleep;
|
|
68
|
+
#backoffMinMs;
|
|
69
|
+
#backoffMaxMs;
|
|
70
|
+
#listeners = new Map();
|
|
71
|
+
#tasks = new Set();
|
|
72
|
+
#refreshTail = Promise.resolve();
|
|
73
|
+
#started = false;
|
|
74
|
+
#closed = false;
|
|
75
|
+
#backlogReadyNotified = false;
|
|
76
|
+
constructor({ host, createClient = defaultClientFactory, logger = console, sleep = abortableSleep, backoffMinMs = DEFAULT_BACKOFF_MIN_MS, backoffMaxMs = DEFAULT_BACKOFF_MAX_MS, }) {
|
|
77
|
+
if (!Number.isSafeInteger(backoffMinMs) ||
|
|
78
|
+
!Number.isSafeInteger(backoffMaxMs) ||
|
|
79
|
+
backoffMinMs < 1 ||
|
|
80
|
+
backoffMaxMs < backoffMinMs) {
|
|
81
|
+
throw new Error('Invalid iLink listener backoff configuration');
|
|
82
|
+
}
|
|
83
|
+
this.#host = host;
|
|
84
|
+
this.#createClient = createClient;
|
|
85
|
+
this.#logger = logger;
|
|
86
|
+
this.#sleep = sleep;
|
|
87
|
+
this.#backoffMinMs = backoffMinMs;
|
|
88
|
+
this.#backoffMaxMs = backoffMaxMs;
|
|
89
|
+
}
|
|
90
|
+
start() {
|
|
91
|
+
if (this.#closed) {
|
|
92
|
+
return Promise.reject(new Error('iLink listener manager is closed'));
|
|
93
|
+
}
|
|
94
|
+
this.#started = true;
|
|
95
|
+
return this.refresh();
|
|
96
|
+
}
|
|
97
|
+
refresh() {
|
|
98
|
+
if (!this.#started) {
|
|
99
|
+
return Promise.reject(new Error('iLink listener manager is not started'));
|
|
100
|
+
}
|
|
101
|
+
if (this.#closed)
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
const refresh = this.#refreshTail.then(() => this.#reconcile());
|
|
104
|
+
this.#refreshTail = refresh.catch(() => undefined);
|
|
105
|
+
return refresh;
|
|
106
|
+
}
|
|
107
|
+
async #reconcile() {
|
|
108
|
+
if (this.#closed)
|
|
109
|
+
return;
|
|
110
|
+
const desired = new Map();
|
|
111
|
+
for (const account of this.#host.listActiveRuntimeAccounts()) {
|
|
112
|
+
if (!validRuntimeAccount(account) || desired.has(account.accountKey)) {
|
|
113
|
+
throw new Error('Invalid iLink runtime account list');
|
|
114
|
+
}
|
|
115
|
+
desired.set(account.accountKey, account);
|
|
116
|
+
}
|
|
117
|
+
const retiring = [];
|
|
118
|
+
for (const [accountKey, running] of this.#listeners) {
|
|
119
|
+
const account = desired.get(accountKey);
|
|
120
|
+
if (!account || !sameRuntime(running.state, account)) {
|
|
121
|
+
this.#listeners.delete(accountKey);
|
|
122
|
+
running.state.controller.abort();
|
|
123
|
+
retiring.push(running);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
await Promise.allSettled(retiring.map((running) => running.task));
|
|
127
|
+
if (this.#closed)
|
|
128
|
+
return;
|
|
129
|
+
for (const [accountKey, account] of desired) {
|
|
130
|
+
if (!this.#listeners.has(accountKey))
|
|
131
|
+
this.#startAccount(account);
|
|
132
|
+
}
|
|
133
|
+
await this.#notifyBacklogReadyIfAll();
|
|
134
|
+
}
|
|
135
|
+
#startAccount(account) {
|
|
136
|
+
this.#backlogReadyNotified = false;
|
|
137
|
+
const state = {
|
|
138
|
+
account,
|
|
139
|
+
controller: new AbortController(),
|
|
140
|
+
client: this.#createClient(account),
|
|
141
|
+
cursor: account.cursor,
|
|
142
|
+
pendingMessageKeys: Object.freeze([]),
|
|
143
|
+
startedAt: Date.now(),
|
|
144
|
+
catchingUp: true,
|
|
145
|
+
};
|
|
146
|
+
const task = this.#run(state).finally(() => {
|
|
147
|
+
this.#tasks.delete(task);
|
|
148
|
+
if (this.#listeners.get(account.accountKey)?.state === state) {
|
|
149
|
+
this.#listeners.delete(account.accountKey);
|
|
150
|
+
}
|
|
151
|
+
void this.#notifyBacklogReadyIfAll();
|
|
152
|
+
});
|
|
153
|
+
this.#tasks.add(task);
|
|
154
|
+
this.#listeners.set(account.accountKey, { state, task });
|
|
155
|
+
}
|
|
156
|
+
async #run(state) {
|
|
157
|
+
try {
|
|
158
|
+
try {
|
|
159
|
+
await state.client.notifyStart?.();
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
if (!state.controller.signal.aborted && !this.#closed) {
|
|
163
|
+
this.#logger.warn?.('[ilink-listener] notifyStart failed; continuing');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!state.controller.signal.aborted && !this.#closed) {
|
|
167
|
+
await this.#poll(state);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
try {
|
|
172
|
+
// Deliberately do not pass the listener AbortSignal. notifyStop must be
|
|
173
|
+
// able to finish after the in-flight long poll has been cancelled.
|
|
174
|
+
await state.client.notifyStop?.();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
this.#logger.warn?.('[ilink-listener] notifyStop failed; ignored');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async #poll(state) {
|
|
182
|
+
let failures = 0;
|
|
183
|
+
const { signal } = state.controller;
|
|
184
|
+
while (!this.#closed && !signal.aborted) {
|
|
185
|
+
try {
|
|
186
|
+
if (state.pendingMessageKeys.length > 0) {
|
|
187
|
+
this.#host.enqueue(state.pendingMessageKeys);
|
|
188
|
+
state.pendingMessageKeys = Object.freeze([]);
|
|
189
|
+
}
|
|
190
|
+
const expectedCursor = state.cursor;
|
|
191
|
+
const response = await state.client.getUpdates({ get_updates_buf: expectedCursor }, { signal });
|
|
192
|
+
if (signal.aborted || this.#closed)
|
|
193
|
+
return;
|
|
194
|
+
const nextCursor = typeof response.get_updates_buf === 'string'
|
|
195
|
+
? response.get_updates_buf
|
|
196
|
+
: expectedCursor;
|
|
197
|
+
if ((response.msgs?.length || 0) > 0 && nextCursor === expectedCursor) {
|
|
198
|
+
throw new Error('iLink getUpdates returned messages without cursor progress');
|
|
199
|
+
}
|
|
200
|
+
const pair = {
|
|
201
|
+
accountKey: state.account.accountKey,
|
|
202
|
+
botId: state.account.providerAccountId,
|
|
203
|
+
ownerUserId: state.account.ownerPeerId,
|
|
204
|
+
};
|
|
205
|
+
const messages = (response.msgs ?? []).flatMap((message, index) => {
|
|
206
|
+
const normalized = normalizeIlinkInboundMessage(message, pair, { cursor: expectedCursor, index });
|
|
207
|
+
return normalized ? [normalized] : [];
|
|
208
|
+
});
|
|
209
|
+
let committed;
|
|
210
|
+
try {
|
|
211
|
+
committed = this.#host.commitPage({
|
|
212
|
+
accountKey: state.account.accountKey,
|
|
213
|
+
expectedGeneration: state.account.generation,
|
|
214
|
+
expectedCursor,
|
|
215
|
+
nextCursor,
|
|
216
|
+
messages,
|
|
217
|
+
deferredBefore: state.startedAt,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (fenceRejection(error))
|
|
222
|
+
return;
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
state.cursor = committed.cursor;
|
|
226
|
+
state.pendingMessageKeys = Object.freeze([
|
|
227
|
+
...committed.insertedMessageKeys,
|
|
228
|
+
]);
|
|
229
|
+
if (state.pendingMessageKeys.length > 0) {
|
|
230
|
+
this.#host.enqueue(state.pendingMessageKeys);
|
|
231
|
+
state.pendingMessageKeys = Object.freeze([]);
|
|
232
|
+
}
|
|
233
|
+
if (state.catchingUp &&
|
|
234
|
+
((response.msgs?.length || 0) === 0 ||
|
|
235
|
+
messages.some(({ message }) => message.sentAt >= state.startedAt))) {
|
|
236
|
+
state.catchingUp = false;
|
|
237
|
+
await this.#notifyBacklogReadyIfAll();
|
|
238
|
+
}
|
|
239
|
+
else if ((committed.deferredMessageCount || 0) > 0 &&
|
|
240
|
+
![...this.#listeners.values()].some(({ state: other }) => other.catchingUp)) {
|
|
241
|
+
await this.#host.backlogReady?.();
|
|
242
|
+
}
|
|
243
|
+
failures = 0;
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (signal.aborted || this.#closed)
|
|
247
|
+
return;
|
|
248
|
+
const sessionExpired = error instanceof IlinkProtocolError &&
|
|
249
|
+
error.kind === 'business' &&
|
|
250
|
+
(error.errcode === -14 || error.ret === -14);
|
|
251
|
+
if (state.catchingUp) {
|
|
252
|
+
state.catchingUp = false;
|
|
253
|
+
await this.#notifyBacklogReadyIfAll();
|
|
254
|
+
}
|
|
255
|
+
const delay = Math.min(this.#backoffMaxMs, this.#backoffMinMs * 2 ** Math.min(failures, 30));
|
|
256
|
+
failures += 1;
|
|
257
|
+
this.#logger.warn?.(`[ilink-listener] poll cycle failed; retry_ms=${delay}`);
|
|
258
|
+
try {
|
|
259
|
+
await this.#sleep(delay, signal);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
if (signal.aborted || this.#closed)
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (sessionExpired && !signal.aborted && !this.#closed) {
|
|
266
|
+
try {
|
|
267
|
+
await state.client.notifyStart?.();
|
|
268
|
+
failures = 0;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
this.#logger.warn?.('[ilink-listener] expired session refresh failed; retrying');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async #notifyBacklogReadyIfAll() {
|
|
278
|
+
if (this.#closed || this.#backlogReadyNotified ||
|
|
279
|
+
[...this.#listeners.values()].some(({ state }) => state.catchingUp)) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.#backlogReadyNotified = true;
|
|
283
|
+
await this.#host.backlogReady?.();
|
|
284
|
+
}
|
|
285
|
+
async waitForIdle() {
|
|
286
|
+
while (this.#tasks.size > 0) {
|
|
287
|
+
await Promise.allSettled([...this.#tasks]);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async close() {
|
|
291
|
+
if (this.#closed) {
|
|
292
|
+
await this.waitForIdle();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
this.#closed = true;
|
|
296
|
+
for (const running of this.#listeners.values()) {
|
|
297
|
+
running.state.controller.abort();
|
|
298
|
+
}
|
|
299
|
+
await this.#refreshTail;
|
|
300
|
+
for (const running of this.#listeners.values()) {
|
|
301
|
+
running.state.controller.abort();
|
|
302
|
+
}
|
|
303
|
+
await this.waitForIdle();
|
|
304
|
+
this.#listeners.clear();
|
|
305
|
+
}
|
|
306
|
+
}
|