@kin-tio/cli 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.env.example +46 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +202 -0
  4. package/README.md +150 -0
  5. package/README.zh-CN.md +79 -0
  6. package/THIRD_PARTY_NOTICES +31 -0
  7. package/assets/ilink-login-card.png +0 -0
  8. package/bin/kintio.js +3 -0
  9. package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
  10. package/dist/cli.js +3 -0
  11. package/dist/daemon.js +28 -0
  12. package/dist/index.js +70 -0
  13. package/dist/mcp-relay.js +11 -0
  14. package/dist/src/agent/runtime.js +1 -0
  15. package/dist/src/app.js +34 -0
  16. package/dist/src/cli.js +578 -0
  17. package/dist/src/config.js +237 -0
  18. package/dist/src/domain/message.js +23 -0
  19. package/dist/src/domain/send-contract.js +205 -0
  20. package/dist/src/domain/wecom-message.js +281 -0
  21. package/dist/src/ilink/executor.js +306 -0
  22. package/dist/src/ilink/inbound-image.js +310 -0
  23. package/dist/src/ilink/listener.js +306 -0
  24. package/dist/src/ilink/login-manager.js +198 -0
  25. package/dist/src/ilink/login-store.js +197 -0
  26. package/dist/src/ilink/media-gateway.js +83 -0
  27. package/dist/src/ilink/media.js +267 -0
  28. package/dist/src/ilink/message.js +247 -0
  29. package/dist/src/ilink/protocol/client.js +464 -0
  30. package/dist/src/ilink/protocol/types.js +35 -0
  31. package/dist/src/ilink/qr.js +109 -0
  32. package/dist/src/ilink/secret-box.js +143 -0
  33. package/dist/src/ilink/sqlite-store.js +1194 -0
  34. package/dist/src/ilink/store-types.js +63 -0
  35. package/dist/src/lib/image-format.js +23 -0
  36. package/dist/src/lib/path-identity.js +38 -0
  37. package/dist/src/lib/private-directory.js +51 -0
  38. package/dist/src/lib/text.js +19 -0
  39. package/dist/src/lib/wecom-crypto.js +74 -0
  40. package/dist/src/lib/xml.js +8 -0
  41. package/dist/src/mcp/conversation-memory-server.js +179 -0
  42. package/dist/src/mcp/ilink-server.js +158 -0
  43. package/dist/src/mcp/ipc-host.js +275 -0
  44. package/dist/src/mcp/ipc-protocol.js +226 -0
  45. package/dist/src/mcp/stdio-relay.js +122 -0
  46. package/dist/src/mcp/wechat-kf-executor.js +295 -0
  47. package/dist/src/mcp/wechat-kf-server.js +208 -0
  48. package/dist/src/routes/wecom.js +89 -0
  49. package/dist/src/runtime/daemon-protocol.js +202 -0
  50. package/dist/src/runtime/managed-skill.js +49 -0
  51. package/dist/src/runtime/native-daemon.js +325 -0
  52. package/dist/src/runtime/single-instance-lock.js +167 -0
  53. package/dist/src/runtime.js +503 -0
  54. package/dist/src/services/codex-agent.js +542 -0
  55. package/dist/src/services/codex-app-server.js +436 -0
  56. package/dist/src/services/conversation-processor.js +762 -0
  57. package/dist/src/services/image-stager.js +49 -0
  58. package/dist/src/services/media-gateway.js +83 -0
  59. package/dist/src/services/wecom-api.js +311 -0
  60. package/dist/src/services/wecom-sync.js +316 -0
  61. package/dist/src/state/persistence.js +124 -0
  62. package/dist/src/state/sqlite-store.js +3102 -0
  63. package/dist/src/supervisor.js +212 -0
  64. package/dist/src/types.js +1 -0
  65. package/dist/src/version.js +1 -0
  66. package/package.json +72 -0
@@ -0,0 +1,464 @@
1
+ import crypto from 'node:crypto';
2
+ import { isIP } from 'node:net';
3
+ import { ILINK_QR_STATUSES, } from './types.js';
4
+ export const DEFAULT_ILINK_BASE_URL = 'https://ilinkai.weixin.qq.com/';
5
+ const DEFAULT_ILINK_ALLOWED_HOST_SUFFIXES = Object.freeze([
6
+ 'weixin.qq.com',
7
+ ]);
8
+ const DEFAULT_API_TIMEOUT_MS = 15_000;
9
+ const DEFAULT_LIFECYCLE_TIMEOUT_MS = 10_000;
10
+ const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
11
+ const DEFAULT_BOT_TYPE = '3';
12
+ const REFERENCE_APP_ID = 'bot';
13
+ const REFERENCE_CHANNEL_VERSION = '2.4.6';
14
+ const REFERENCE_APP_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
15
+ const MAX_LOCAL_TOKENS = 10;
16
+ const MAX_QR_LENGTH = 8_192;
17
+ const MAX_VERIFY_CODE_LENGTH = 64;
18
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
19
+ export class IlinkProtocolError extends Error {
20
+ kind;
21
+ operation;
22
+ status;
23
+ ret;
24
+ errcode;
25
+ constructor(kind, message, details = {}) {
26
+ super(message, details.cause === undefined ? undefined : { cause: details.cause });
27
+ this.name = 'IlinkProtocolError';
28
+ this.kind = kind;
29
+ this.operation = details.operation;
30
+ this.status = details.status;
31
+ this.ret = details.ret;
32
+ this.errcode = details.errcode;
33
+ }
34
+ }
35
+ function configurationError(message) {
36
+ return new IlinkProtocolError('configuration', message);
37
+ }
38
+ function unsafeUrlError(message) {
39
+ return new IlinkProtocolError('unsafe_url', message);
40
+ }
41
+ function normalizeTimeout(value, label) {
42
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
43
+ throw configurationError(`${label} must be a positive integer`);
44
+ }
45
+ return value;
46
+ }
47
+ function normalizeHostSuffix(raw) {
48
+ const suffix = raw.trim().toLowerCase().replace(/^\.+/u, '');
49
+ if (!suffix ||
50
+ suffix === 'localhost' ||
51
+ isIP(suffix) !== 0 ||
52
+ !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(suffix) ||
53
+ !suffix.includes('.') ||
54
+ suffix.includes('..')) {
55
+ throw configurationError(`Invalid iLink allowed host suffix: ${raw}`);
56
+ }
57
+ return suffix;
58
+ }
59
+ function normalizeAllowedHostSuffixes(rawSuffixes) {
60
+ const suffixes = [...new Set(rawSuffixes.map(normalizeHostSuffix))];
61
+ if (suffixes.length === 0) {
62
+ throw configurationError('At least one iLink allowed host suffix is required');
63
+ }
64
+ return Object.freeze(suffixes);
65
+ }
66
+ function hostnameAllowed(hostname, allowedHostSuffixes) {
67
+ const normalized = hostname.toLowerCase();
68
+ return allowedHostSuffixes.some((suffix) => normalized === suffix || normalized.endsWith(`.${suffix}`));
69
+ }
70
+ export function normalizeIlinkBaseUrl(rawBaseUrl, allowedHostSuffixes = DEFAULT_ILINK_ALLOWED_HOST_SUFFIXES) {
71
+ if (!rawBaseUrl || rawBaseUrl !== rawBaseUrl.trim()) {
72
+ throw unsafeUrlError('iLink baseUrl must be a non-empty URL without surrounding whitespace');
73
+ }
74
+ let url;
75
+ try {
76
+ url = new URL(rawBaseUrl);
77
+ }
78
+ catch {
79
+ throw unsafeUrlError('iLink baseUrl must be a valid HTTPS URL');
80
+ }
81
+ const suffixes = normalizeAllowedHostSuffixes(allowedHostSuffixes);
82
+ if (url.protocol !== 'https:' ||
83
+ url.username ||
84
+ url.password ||
85
+ url.port ||
86
+ url.search ||
87
+ url.hash ||
88
+ url.pathname !== '/' ||
89
+ isIP(url.hostname) !== 0 ||
90
+ !hostnameAllowed(url.hostname, suffixes)) {
91
+ throw unsafeUrlError('iLink baseUrl must be an allowlisted public HTTPS origin without credentials, port, path, query, or hash');
92
+ }
93
+ return `${url.origin}/`;
94
+ }
95
+ export function ilinkRedirectHostToBaseUrl(rawHost, allowedHostSuffixes = DEFAULT_ILINK_ALLOWED_HOST_SUFFIXES) {
96
+ if (!rawHost ||
97
+ rawHost !== rawHost.trim() ||
98
+ !/^[A-Za-z0-9.-]+$/u.test(rawHost) ||
99
+ rawHost.startsWith('.') ||
100
+ rawHost.endsWith('.') ||
101
+ rawHost.includes('..')) {
102
+ throw unsafeUrlError('iLink redirect_host must be a plain allowlisted hostname');
103
+ }
104
+ return normalizeIlinkBaseUrl(`https://${rawHost}/`, allowedHostSuffixes);
105
+ }
106
+ function randomWechatUin() {
107
+ const uint32 = crypto.randomBytes(4).readUInt32BE(0);
108
+ return Buffer.from(String(uint32), 'utf8').toString('base64');
109
+ }
110
+ function isJsonRecord(value) {
111
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
112
+ }
113
+ function optionalInteger(data, key, operation) {
114
+ const value = data[key];
115
+ if (value === undefined)
116
+ return undefined;
117
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
118
+ throw new IlinkProtocolError('invalid_response', `${operation} returned an invalid ${key}`, { operation });
119
+ }
120
+ return value;
121
+ }
122
+ function assertBusinessSuccess(data, operation) {
123
+ const ret = optionalInteger(data, 'ret', operation);
124
+ const errcode = optionalInteger(data, 'errcode', operation);
125
+ if ((ret !== undefined && ret !== 0) || (errcode !== undefined && errcode !== 0)) {
126
+ const errmsg = typeof data.errmsg === 'string' ? data.errmsg : '';
127
+ const codes = [
128
+ ret === undefined ? undefined : `ret=${ret}`,
129
+ errcode === undefined ? undefined : `errcode=${errcode}`,
130
+ ].filter((value) => value !== undefined);
131
+ throw new IlinkProtocolError('business', `${operation} failed: ${codes.join(' ')}${errmsg ? ` ${errmsg}` : ''}`, {
132
+ operation,
133
+ ...(ret === undefined ? {} : { ret }),
134
+ ...(errcode === undefined ? {} : { errcode }),
135
+ });
136
+ }
137
+ }
138
+ function requireOptionalString(data, key, operation) {
139
+ const value = data[key];
140
+ if (value !== undefined && typeof value !== 'string') {
141
+ throw new IlinkProtocolError('invalid_response', `${operation} returned an invalid ${key}`, { operation });
142
+ }
143
+ }
144
+ function requireString(data, key, operation) {
145
+ const value = data[key];
146
+ if (typeof value !== 'string' || !value) {
147
+ throw new IlinkProtocolError('invalid_response', `${operation} returned an invalid ${key}`, { operation });
148
+ }
149
+ return value;
150
+ }
151
+ async function boundedResponseText(response, operation) {
152
+ const declared = Number(response.headers.get('content-length') || 0);
153
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
154
+ throw new IlinkProtocolError('invalid_response', `${operation} response exceeds the size limit`, { operation, status: response.status });
155
+ }
156
+ if (!response.body)
157
+ return '';
158
+ const reader = response.body.getReader();
159
+ const chunks = [];
160
+ let size = 0;
161
+ try {
162
+ while (true) {
163
+ const { done, value } = await reader.read();
164
+ if (done)
165
+ break;
166
+ size += value.byteLength;
167
+ if (size > MAX_RESPONSE_BYTES) {
168
+ throw new IlinkProtocolError('invalid_response', `${operation} response exceeds the size limit`, { operation, status: response.status });
169
+ }
170
+ chunks.push(value);
171
+ }
172
+ }
173
+ finally {
174
+ reader.releaseLock();
175
+ }
176
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), size)
177
+ .toString('utf8');
178
+ }
179
+ function normalizedAbortReason(reason) {
180
+ return reason instanceof Error
181
+ ? reason
182
+ : new DOMException('The operation was aborted', 'AbortError');
183
+ }
184
+ function createRequestControl(externalSignal, timeoutMs) {
185
+ const controller = new AbortController();
186
+ const onExternalAbort = () => {
187
+ controller.abort(normalizedAbortReason(externalSignal?.reason));
188
+ };
189
+ if (externalSignal?.aborted) {
190
+ onExternalAbort();
191
+ }
192
+ else {
193
+ externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
194
+ }
195
+ const timer = setTimeout(() => {
196
+ controller.abort(new DOMException('The iLink request timed out', 'TimeoutError'));
197
+ }, timeoutMs);
198
+ timer.unref();
199
+ return {
200
+ signal: controller.signal,
201
+ cleanup: () => {
202
+ clearTimeout(timer);
203
+ externalSignal?.removeEventListener('abort', onExternalAbort);
204
+ },
205
+ };
206
+ }
207
+ function validateTokenList(tokens) {
208
+ if (tokens.length > MAX_LOCAL_TOKENS) {
209
+ throw configurationError(`local_token_list must contain at most ${MAX_LOCAL_TOKENS} tokens`);
210
+ }
211
+ return tokens.map((token) => {
212
+ const normalized = token.trim();
213
+ if (!normalized)
214
+ throw configurationError('local_token_list cannot contain empty tokens');
215
+ return normalized;
216
+ });
217
+ }
218
+ function validateBotType(botType) {
219
+ if (!/^\d{1,8}$/u.test(botType)) {
220
+ throw configurationError('bot_type must contain 1 to 8 digits');
221
+ }
222
+ return botType;
223
+ }
224
+ export class IlinkClient {
225
+ baseUrl;
226
+ allowedHostSuffixes;
227
+ timeoutMs;
228
+ longPollTimeoutMs;
229
+ fetch;
230
+ #token;
231
+ #appId;
232
+ #appClientVersion;
233
+ #baseInfo;
234
+ constructor(options = {}) {
235
+ this.allowedHostSuffixes = normalizeAllowedHostSuffixes(options.allowedHostSuffixes ?? DEFAULT_ILINK_ALLOWED_HOST_SUFFIXES);
236
+ this.baseUrl = normalizeIlinkBaseUrl(options.baseUrl ?? DEFAULT_ILINK_BASE_URL, this.allowedHostSuffixes);
237
+ this.timeoutMs = normalizeTimeout(options.timeoutMs ?? DEFAULT_API_TIMEOUT_MS, 'timeoutMs');
238
+ this.longPollTimeoutMs = normalizeTimeout(options.longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS, 'longPollTimeoutMs');
239
+ this.fetch = options.fetchImpl ?? globalThis.fetch;
240
+ if (typeof this.fetch !== 'function') {
241
+ throw configurationError('A fetch implementation is required');
242
+ }
243
+ this.#token = options.token?.trim() || undefined;
244
+ this.#appId = options.appId ?? REFERENCE_APP_ID;
245
+ this.#appClientVersion = options.appClientVersion ?? REFERENCE_APP_CLIENT_VERSION;
246
+ if (!this.#appId || !Number.isInteger(this.#appClientVersion) || this.#appClientVersion < 0) {
247
+ throw configurationError('Valid iLink appId and appClientVersion values are required');
248
+ }
249
+ this.#baseInfo = Object.freeze({
250
+ channel_version: options.baseInfo?.channel_version ?? REFERENCE_CHANNEL_VERSION,
251
+ bot_agent: options.baseInfo?.bot_agent ?? 'WechatBot/1.0.0',
252
+ });
253
+ }
254
+ resolveRedirectBaseUrl(redirectHost) {
255
+ return ilinkRedirectHostToBaseUrl(redirectHost, this.allowedHostSuffixes);
256
+ }
257
+ async getUpdates(request = {}, options = {}) {
258
+ const data = await this.#requestJson({
259
+ operation: 'getUpdates',
260
+ method: 'POST',
261
+ path: 'ilink/bot/getupdates',
262
+ token: this.#requireToken('getUpdates'),
263
+ body: {
264
+ get_updates_buf: request.get_updates_buf ?? '',
265
+ base_info: this.#baseInfo,
266
+ },
267
+ options,
268
+ defaultTimeoutMs: this.longPollTimeoutMs,
269
+ });
270
+ assertBusinessSuccess(data, 'getUpdates');
271
+ if (data.msgs !== undefined && !Array.isArray(data.msgs)) {
272
+ throw new IlinkProtocolError('invalid_response', 'getUpdates returned an invalid msgs field', { operation: 'getUpdates' });
273
+ }
274
+ requireOptionalString(data, 'get_updates_buf', 'getUpdates');
275
+ if (data.longpolling_timeout_ms !== undefined &&
276
+ (typeof data.longpolling_timeout_ms !== 'number' ||
277
+ !Number.isFinite(data.longpolling_timeout_ms) ||
278
+ data.longpolling_timeout_ms < 0)) {
279
+ throw new IlinkProtocolError('invalid_response', 'getUpdates returned an invalid longpolling_timeout_ms', { operation: 'getUpdates' });
280
+ }
281
+ return data;
282
+ }
283
+ async notifyStart(options = {}) {
284
+ const data = await this.#requestJson({
285
+ operation: 'notifyStart',
286
+ method: 'POST',
287
+ path: 'ilink/bot/msg/notifystart',
288
+ token: this.#requireToken('notifyStart'),
289
+ body: { base_info: this.#baseInfo },
290
+ options,
291
+ defaultTimeoutMs: DEFAULT_LIFECYCLE_TIMEOUT_MS,
292
+ });
293
+ assertBusinessSuccess(data, 'notifyStart');
294
+ requireOptionalString(data, 'errmsg', 'notifyStart');
295
+ return data;
296
+ }
297
+ async notifyStop(options = {}) {
298
+ const data = await this.#requestJson({
299
+ operation: 'notifyStop',
300
+ method: 'POST',
301
+ path: 'ilink/bot/msg/notifystop',
302
+ token: this.#requireToken('notifyStop'),
303
+ body: { base_info: this.#baseInfo },
304
+ options,
305
+ defaultTimeoutMs: DEFAULT_LIFECYCLE_TIMEOUT_MS,
306
+ });
307
+ assertBusinessSuccess(data, 'notifyStop');
308
+ requireOptionalString(data, 'errmsg', 'notifyStop');
309
+ return data;
310
+ }
311
+ async sendMessage(request, options = {}) {
312
+ if (!isJsonRecord(request.msg)) {
313
+ throw configurationError('sendMessage requires a msg object');
314
+ }
315
+ const data = await this.#requestJson({
316
+ operation: 'sendMessage',
317
+ method: 'POST',
318
+ path: 'ilink/bot/sendmessage',
319
+ token: this.#requireToken('sendMessage'),
320
+ body: { ...request, base_info: this.#baseInfo },
321
+ options,
322
+ defaultTimeoutMs: this.timeoutMs,
323
+ });
324
+ assertBusinessSuccess(data, 'sendMessage');
325
+ return data;
326
+ }
327
+ async getUploadUrl(request, options = {}) {
328
+ const data = await this.#requestJson({
329
+ operation: 'getUploadUrl',
330
+ method: 'POST',
331
+ path: 'ilink/bot/getuploadurl',
332
+ token: this.#requireToken('getUploadUrl'),
333
+ body: { ...request, base_info: this.#baseInfo },
334
+ options,
335
+ defaultTimeoutMs: this.timeoutMs,
336
+ });
337
+ assertBusinessSuccess(data, 'getUploadUrl');
338
+ requireOptionalString(data, 'upload_param', 'getUploadUrl');
339
+ requireOptionalString(data, 'upload_full_url', 'getUploadUrl');
340
+ return data;
341
+ }
342
+ async createQr(request = {}, options = {}) {
343
+ const botType = validateBotType(request.bot_type ?? DEFAULT_BOT_TYPE);
344
+ const localTokens = validateTokenList(request.local_token_list ?? []);
345
+ const query = new URLSearchParams({ bot_type: botType });
346
+ const data = await this.#requestJson({
347
+ operation: 'createQr',
348
+ method: 'POST',
349
+ path: `ilink/bot/get_bot_qrcode?${query}`,
350
+ body: { local_token_list: localTokens },
351
+ options,
352
+ defaultTimeoutMs: this.timeoutMs,
353
+ });
354
+ assertBusinessSuccess(data, 'createQr');
355
+ requireString(data, 'qrcode', 'createQr');
356
+ requireString(data, 'qrcode_img_content', 'createQr');
357
+ return data;
358
+ }
359
+ async getQrStatus(request, options = {}) {
360
+ if (!request.qrcode || request.qrcode.length > MAX_QR_LENGTH) {
361
+ throw configurationError(`qrcode must contain 1 to ${MAX_QR_LENGTH} characters`);
362
+ }
363
+ if (request.verify_code !== undefined &&
364
+ (!request.verify_code || request.verify_code.length > MAX_VERIFY_CODE_LENGTH)) {
365
+ throw configurationError(`verify_code must contain 1 to ${MAX_VERIFY_CODE_LENGTH} characters`);
366
+ }
367
+ const query = new URLSearchParams({ qrcode: request.qrcode });
368
+ if (request.verify_code !== undefined)
369
+ query.set('verify_code', request.verify_code);
370
+ const data = await this.#requestJson({
371
+ operation: 'getQrStatus',
372
+ method: 'GET',
373
+ path: `ilink/bot/get_qrcode_status?${query}`,
374
+ options,
375
+ defaultTimeoutMs: this.longPollTimeoutMs,
376
+ });
377
+ assertBusinessSuccess(data, 'getQrStatus');
378
+ const status = requireString(data, 'status', 'getQrStatus');
379
+ if (!ILINK_QR_STATUSES.includes(status)) {
380
+ throw new IlinkProtocolError('invalid_response', 'getQrStatus returned an unknown status', { operation: 'getQrStatus' });
381
+ }
382
+ for (const key of [
383
+ 'bot_token',
384
+ 'ilink_bot_id',
385
+ 'baseurl',
386
+ 'ilink_user_id',
387
+ 'redirect_host',
388
+ ]) {
389
+ requireOptionalString(data, key, 'getQrStatus');
390
+ }
391
+ if (typeof data.redirect_host === 'string') {
392
+ this.resolveRedirectBaseUrl(data.redirect_host);
393
+ }
394
+ if (typeof data.baseurl === 'string') {
395
+ data.baseurl = normalizeIlinkBaseUrl(data.baseurl, this.allowedHostSuffixes);
396
+ }
397
+ return data;
398
+ }
399
+ #requireToken(operation) {
400
+ if (!this.#token) {
401
+ throw configurationError(`${operation} requires an iLink bot token`);
402
+ }
403
+ return this.#token;
404
+ }
405
+ #commonHeaders() {
406
+ return {
407
+ 'iLink-App-Id': this.#appId,
408
+ 'iLink-App-ClientVersion': String(this.#appClientVersion),
409
+ };
410
+ }
411
+ #postHeaders(token) {
412
+ return {
413
+ 'Content-Type': 'application/json',
414
+ AuthorizationType: 'ilink_bot_token',
415
+ 'X-WECHAT-UIN': randomWechatUin(),
416
+ ...this.#commonHeaders(),
417
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
418
+ };
419
+ }
420
+ async #requestJson({ operation, method, path, token, body, options, defaultTimeoutMs, }) {
421
+ const baseUrl = options.baseUrl === undefined
422
+ ? this.baseUrl
423
+ : normalizeIlinkBaseUrl(options.baseUrl, this.allowedHostSuffixes);
424
+ const url = new URL(path, baseUrl);
425
+ const timeoutMs = normalizeTimeout(options.timeoutMs ?? defaultTimeoutMs, 'request timeoutMs');
426
+ const control = createRequestControl(options.signal, timeoutMs);
427
+ try {
428
+ control.signal.throwIfAborted();
429
+ const response = await this.fetch(url, {
430
+ method,
431
+ headers: method === 'POST' ? this.#postHeaders(token) : this.#commonHeaders(),
432
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
433
+ signal: control.signal,
434
+ redirect: 'error',
435
+ });
436
+ if (!response.ok) {
437
+ throw new IlinkProtocolError('http', `${operation} returned HTTP ${response.status}`, { operation, status: response.status });
438
+ }
439
+ const responseText = await boundedResponseText(response, operation);
440
+ let parsed;
441
+ try {
442
+ parsed = JSON.parse(responseText);
443
+ }
444
+ catch {
445
+ throw new IlinkProtocolError('invalid_json', `${operation} returned non-JSON HTTP ${response.status}`, { operation, status: response.status });
446
+ }
447
+ if (!isJsonRecord(parsed)) {
448
+ throw new IlinkProtocolError('invalid_response', `${operation} returned a non-object response`, { operation, status: response.status });
449
+ }
450
+ return parsed;
451
+ }
452
+ catch (error) {
453
+ if (control.signal.aborted) {
454
+ throw normalizedAbortReason(control.signal.reason);
455
+ }
456
+ if (error instanceof IlinkProtocolError)
457
+ throw error;
458
+ throw new IlinkProtocolError('transport', `${operation} request failed`, { operation, cause: error });
459
+ }
460
+ finally {
461
+ control.cleanup();
462
+ }
463
+ }
464
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Minimal iLink JSON protocol types mirrored from
3
+ * @tencent-weixin/openclaw-weixin 2.4.6. Byte fields are Base64 strings.
4
+ * See THIRD_PARTY_NOTICES for the upstream MIT attribution.
5
+ */
6
+ export const IlinkMessageType = {
7
+ NONE: 0,
8
+ USER: 1,
9
+ BOT: 2,
10
+ };
11
+ export const IlinkMessageState = {
12
+ NEW: 0,
13
+ GENERATING: 1,
14
+ FINISH: 2,
15
+ };
16
+ export const IlinkMessageItemType = {
17
+ NONE: 0,
18
+ TEXT: 1,
19
+ IMAGE: 2,
20
+ VOICE: 3,
21
+ FILE: 4,
22
+ VIDEO: 5,
23
+ TOOL_CALL_START: 11,
24
+ TOOL_CALL_RESULT: 12,
25
+ };
26
+ export const ILINK_QR_STATUSES = [
27
+ 'wait',
28
+ 'scaned',
29
+ 'confirmed',
30
+ 'expired',
31
+ 'scaned_but_redirect',
32
+ 'need_verifycode',
33
+ 'verify_code_blocked',
34
+ 'binded_redirect',
35
+ ];
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { PNG } from 'pngjs';
5
+ import { create } from 'qrcode';
6
+ const MAX_QR_CONTENT_BYTES = 2_048;
7
+ const MAX_QR_PNG_BYTES = 512 * 1_024;
8
+ const CARD_WIDTH = 720;
9
+ const CARD_HEIGHT = 1_024;
10
+ const QR_REGION_X = 120;
11
+ const QR_REGION_Y = 220;
12
+ const QR_REGION_SIZE = 480;
13
+ const QUIET_ZONE_MODULES = 4;
14
+ const QR_DARK = Object.freeze([0x11, 0x18, 0x14, 0xff]);
15
+ const PNG_SIGNATURE = Buffer.from([
16
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
17
+ ]);
18
+ function templatePath() {
19
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
20
+ const sourcePath = path.resolve(moduleDirectory, '../../assets/ilink-login-card.png');
21
+ return fs.existsSync(sourcePath)
22
+ ? sourcePath
23
+ : path.resolve(moduleDirectory, '../../../assets/ilink-login-card.png');
24
+ }
25
+ function readTemplate() {
26
+ const template = PNG.sync.read(fs.readFileSync(templatePath()));
27
+ if (template.width !== CARD_WIDTH || template.height !== CARD_HEIGHT) {
28
+ throw new Error('iLink login card template has invalid dimensions');
29
+ }
30
+ return Object.freeze({
31
+ width: template.width,
32
+ height: template.height,
33
+ data: Buffer.from(template.data),
34
+ });
35
+ }
36
+ const CARD_TEMPLATE = readTemplate();
37
+ export class IlinkQrRenderError extends Error {
38
+ code;
39
+ constructor(code, message) {
40
+ super(message);
41
+ this.name = 'IlinkQrRenderError';
42
+ this.code = code;
43
+ }
44
+ }
45
+ function validateContent(content) {
46
+ if (typeof content !== 'string' ||
47
+ content.trim().length === 0 ||
48
+ Buffer.byteLength(content, 'utf8') > MAX_QR_CONTENT_BYTES) {
49
+ throw new IlinkQrRenderError('invalid_qr_content', 'Invalid iLink QR content');
50
+ }
51
+ }
52
+ function paintModule(image, x, y, scale) {
53
+ for (let row = 0; row < scale; row += 1) {
54
+ for (let column = 0; column < scale; column += 1) {
55
+ const offset = ((y + row) * image.width + x + column) * 4;
56
+ image.data[offset] = QR_DARK[0];
57
+ image.data[offset + 1] = QR_DARK[1];
58
+ image.data[offset + 2] = QR_DARK[2];
59
+ image.data[offset + 3] = QR_DARK[3];
60
+ }
61
+ }
62
+ }
63
+ function renderCard(content) {
64
+ const qr = create(content, { errorCorrectionLevel: 'M' });
65
+ const matrixSize = qr.modules.size;
66
+ const totalModules = matrixSize + QUIET_ZONE_MODULES * 2;
67
+ const scale = Math.floor(QR_REGION_SIZE / totalModules);
68
+ if (scale < 1)
69
+ throw new Error('iLink QR matrix exceeds the card region');
70
+ const renderedSize = totalModules * scale;
71
+ const matrixX = QR_REGION_X + Math.floor((QR_REGION_SIZE - renderedSize) / 2)
72
+ + QUIET_ZONE_MODULES * scale;
73
+ const matrixY = QR_REGION_Y + Math.floor((QR_REGION_SIZE - renderedSize) / 2)
74
+ + QUIET_ZONE_MODULES * scale;
75
+ const image = new PNG({ width: CARD_TEMPLATE.width, height: CARD_TEMPLATE.height });
76
+ CARD_TEMPLATE.data.copy(image.data);
77
+ for (let row = 0; row < matrixSize; row += 1) {
78
+ for (let column = 0; column < matrixSize; column += 1) {
79
+ if (qr.modules.get(row, column)) {
80
+ paintModule(image, matrixX + column * scale, matrixY + row * scale, scale);
81
+ }
82
+ }
83
+ }
84
+ return PNG.sync.write(image, {
85
+ colorType: 6,
86
+ inputColorType: 6,
87
+ bitDepth: 8,
88
+ inputHasAlpha: true,
89
+ deflateLevel: 9,
90
+ deflateStrategy: 3,
91
+ });
92
+ }
93
+ export async function renderIlinkQrPng(content) {
94
+ validateContent(content);
95
+ let png;
96
+ try {
97
+ png = renderCard(content);
98
+ }
99
+ catch {
100
+ throw new IlinkQrRenderError('qr_render_failed', 'Unable to render iLink QR image');
101
+ }
102
+ if (png.length < PNG_SIGNATURE.length || !png.subarray(0, 8).equals(PNG_SIGNATURE)) {
103
+ throw new IlinkQrRenderError('invalid_qr_png', 'Rendered iLink QR image is invalid');
104
+ }
105
+ if (png.length > MAX_QR_PNG_BYTES) {
106
+ throw new IlinkQrRenderError('qr_png_too_large', 'Rendered iLink QR image is too large');
107
+ }
108
+ return png;
109
+ }