@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,267 @@
1
+ import { createCipheriv, createHash, randomBytes, } from 'node:crypto';
2
+ import { detectImageFormat } from '../lib/image-format.js';
3
+ import { IlinkMessageItemType } from './protocol/types.js';
4
+ /**
5
+ * This in-memory upload flow was independently rewritten after reviewing the
6
+ * MIT-licensed @tencent-weixin/openclaw-weixin 2.4.6 CDN implementation. It
7
+ * deliberately omits that package's local-file and remote-URL helpers.
8
+ * See THIRD_PARTY_NOTICES for attribution.
9
+ */
10
+ export const MAX_ILINK_IMAGE_BYTES = 2 * 1024 * 1024;
11
+ export const DEFAULT_ILINK_MEDIA_TIMEOUT_MS = 15_000;
12
+ const MAX_MEDIA_TIMEOUT_MS = 60_000;
13
+ const MAX_PEER_ID_BYTES = 1_024;
14
+ const MAX_OPAQUE_PARAM_BYTES = 256 * 1024;
15
+ const MAX_UPLOAD_URL_BYTES = 512 * 1024;
16
+ const ILINK_CDN_HOST = 'novac2c.cdn.weixin.qq.com';
17
+ const ILINK_CDN_UPLOAD_URL = `https://${ILINK_CDN_HOST}/c2c/upload`;
18
+ const VISIBLE_ASCII = /^[\x21-\x7e]+$/u;
19
+ const IlinkUploadMediaType = {
20
+ IMAGE: 1,
21
+ };
22
+ export class IlinkMediaError extends Error {
23
+ code;
24
+ status;
25
+ constructor(code, message, details = {}) {
26
+ super(message, details.cause === undefined ? undefined : { cause: details.cause });
27
+ this.name = 'IlinkMediaError';
28
+ this.code = code;
29
+ this.status = details.status;
30
+ }
31
+ }
32
+ function mediaError(code, message, details) {
33
+ return new IlinkMediaError(code, message, details);
34
+ }
35
+ function normalizeTimeout(value) {
36
+ if (!Number.isSafeInteger(value) ||
37
+ value < 1 ||
38
+ value > MAX_MEDIA_TIMEOUT_MS) {
39
+ throw mediaError('invalid_timeout', `iLink media timeout must be between 1 and ${MAX_MEDIA_TIMEOUT_MS} milliseconds`);
40
+ }
41
+ return value;
42
+ }
43
+ function normalizePeerId(value) {
44
+ if (typeof value !== 'string' ||
45
+ !value ||
46
+ value !== value.trim() ||
47
+ Buffer.byteLength(value, 'utf8') > MAX_PEER_ID_BYTES ||
48
+ /[\u0000-\u001f\u007f]/u.test(value)) {
49
+ throw mediaError('invalid_peer', 'Invalid iLink image recipient');
50
+ }
51
+ return value;
52
+ }
53
+ function copyAndValidateImage(value) {
54
+ if (!Buffer.isBuffer(value) || value.length === 0 || !detectImageFormat(value)) {
55
+ throw mediaError('invalid_image', 'Invalid iLink image bytes');
56
+ }
57
+ if (value.length > MAX_ILINK_IMAGE_BYTES) {
58
+ throw mediaError('image_too_large', `iLink image exceeds the ${MAX_ILINK_IMAGE_BYTES}-byte limit`);
59
+ }
60
+ return Buffer.from(value);
61
+ }
62
+ function validateAesKey(key) {
63
+ if (!Buffer.isBuffer(key) || key.length !== 16) {
64
+ throw mediaError('invalid_image', 'iLink media AES key must contain 16 bytes');
65
+ }
66
+ }
67
+ /** AES-128-ECB encryption with the PKCS#7 padding enabled by Node by default. */
68
+ export function encryptIlinkMedia(plaintext, key) {
69
+ if (!Buffer.isBuffer(plaintext)) {
70
+ throw mediaError('invalid_image', 'Invalid iLink media bytes');
71
+ }
72
+ validateAesKey(key);
73
+ const cipher = createCipheriv('aes-128-ecb', key, null);
74
+ return Buffer.concat([cipher.update(plaintext), cipher.final()]);
75
+ }
76
+ export function ilinkAesEcbPaddedSize(plaintextSize) {
77
+ if (!Number.isSafeInteger(plaintextSize) || plaintextSize < 0) {
78
+ throw mediaError('invalid_image', 'Invalid iLink media size');
79
+ }
80
+ return Math.ceil((plaintextSize + 1) / 16) * 16;
81
+ }
82
+ function opaqueParameter(value, label) {
83
+ if (typeof value !== 'string' ||
84
+ !value ||
85
+ value !== value.trim() ||
86
+ Buffer.byteLength(value, 'utf8') > MAX_OPAQUE_PARAM_BYTES ||
87
+ !VISIBLE_ASCII.test(value)) {
88
+ throw mediaError('invalid_upload_response', `Invalid iLink ${label}`);
89
+ }
90
+ return value;
91
+ }
92
+ function validateCdnUploadUrl(value) {
93
+ if (!value ||
94
+ value !== value.trim() ||
95
+ Buffer.byteLength(value, 'utf8') > MAX_UPLOAD_URL_BYTES) {
96
+ throw mediaError('unsafe_upload_url', 'Unsafe iLink CDN upload URL');
97
+ }
98
+ let url;
99
+ try {
100
+ url = new URL(value);
101
+ }
102
+ catch {
103
+ throw mediaError('unsafe_upload_url', 'Unsafe iLink CDN upload URL');
104
+ }
105
+ if (url.protocol !== 'https:' ||
106
+ url.hostname !== ILINK_CDN_HOST ||
107
+ url.username ||
108
+ url.password ||
109
+ url.port ||
110
+ url.pathname !== '/c2c/upload' ||
111
+ !url.search ||
112
+ url.hash) {
113
+ throw mediaError('unsafe_upload_url', 'Unsafe iLink CDN upload URL');
114
+ }
115
+ return url;
116
+ }
117
+ function resolveUploadUrl(value, filekey) {
118
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
119
+ throw mediaError('invalid_upload_response', 'Invalid iLink upload response');
120
+ }
121
+ const response = value;
122
+ const fullUrl = response.upload_full_url;
123
+ if (fullUrl !== undefined && typeof fullUrl !== 'string') {
124
+ throw mediaError('invalid_upload_response', 'Invalid iLink upload_full_url');
125
+ }
126
+ if (typeof fullUrl === 'string' && fullUrl !== '') {
127
+ return validateCdnUploadUrl(fullUrl);
128
+ }
129
+ const uploadParam = opaqueParameter(response.upload_param, 'upload_param');
130
+ const fallback = new URL(ILINK_CDN_UPLOAD_URL);
131
+ fallback.searchParams.set('encrypted_query_param', uploadParam);
132
+ fallback.searchParams.set('filekey', filekey);
133
+ return fallback;
134
+ }
135
+ function createRequestControl(externalSignal, timeoutMs) {
136
+ const controller = new AbortController();
137
+ const onExternalAbort = () => {
138
+ controller.abort(mediaError('aborted', 'iLink image upload was aborted'));
139
+ };
140
+ if (externalSignal?.aborted) {
141
+ onExternalAbort();
142
+ }
143
+ else {
144
+ externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
145
+ }
146
+ const timer = setTimeout(() => {
147
+ controller.abort(mediaError('timeout', 'iLink image upload timed out'));
148
+ }, timeoutMs);
149
+ timer.unref();
150
+ return {
151
+ signal: controller.signal,
152
+ cleanup: () => {
153
+ clearTimeout(timer);
154
+ externalSignal?.removeEventListener('abort', onExternalAbort);
155
+ },
156
+ };
157
+ }
158
+ function abortReason(signal) {
159
+ return signal.reason instanceof IlinkMediaError
160
+ ? signal.reason
161
+ : mediaError('aborted', 'iLink image upload was aborted');
162
+ }
163
+ async function abortable(promise, signal) {
164
+ if (signal.aborted)
165
+ throw abortReason(signal);
166
+ return new Promise((resolve, reject) => {
167
+ const onAbort = () => reject(abortReason(signal));
168
+ signal.addEventListener('abort', onAbort, { once: true });
169
+ void promise.then((value) => {
170
+ signal.removeEventListener('abort', onAbort);
171
+ resolve(value);
172
+ }, (error) => {
173
+ signal.removeEventListener('abort', onAbort);
174
+ reject(error);
175
+ });
176
+ });
177
+ }
178
+ function imageMessageItem({ encryptedParameter, aesKey, ciphertextSize, }) {
179
+ return Object.freeze({
180
+ type: IlinkMessageItemType.IMAGE,
181
+ image_item: Object.freeze({
182
+ media: Object.freeze({
183
+ encrypt_query_param: encryptedParameter,
184
+ aes_key: Buffer.from(aesKey.toString('hex'), 'ascii').toString('base64'),
185
+ encrypt_type: 1,
186
+ }),
187
+ mid_size: ciphertextSize,
188
+ }),
189
+ });
190
+ }
191
+ /**
192
+ * Encrypt and upload image bytes, then produce the IMAGE MessageItem accepted
193
+ * by sendmessage. Only caller-provided memory is accepted as image input.
194
+ */
195
+ export async function uploadIlinkImageBuffer({ bytes, peerId, client, fetchImpl = globalThis.fetch, signal: externalSignal, timeoutMs: rawTimeoutMs = DEFAULT_ILINK_MEDIA_TIMEOUT_MS, }) {
196
+ const plaintext = copyAndValidateImage(bytes);
197
+ const recipient = normalizePeerId(peerId);
198
+ const timeoutMs = normalizeTimeout(rawTimeoutMs);
199
+ if (typeof client?.getUploadUrl !== 'function') {
200
+ throw mediaError('get_upload_url_failed', 'iLink upload client is unavailable');
201
+ }
202
+ if (typeof fetchImpl !== 'function') {
203
+ throw mediaError('upload_failed', 'iLink CDN transport is unavailable');
204
+ }
205
+ const aesKey = randomBytes(16);
206
+ const filekey = randomBytes(16).toString('hex');
207
+ const ciphertext = encryptIlinkMedia(plaintext, aesKey);
208
+ const request = Object.freeze({
209
+ filekey,
210
+ media_type: IlinkUploadMediaType.IMAGE,
211
+ to_user_id: recipient,
212
+ rawsize: plaintext.length,
213
+ rawfilemd5: createHash('md5').update(plaintext).digest('hex'),
214
+ filesize: ilinkAesEcbPaddedSize(plaintext.length),
215
+ no_need_thumb: true,
216
+ aeskey: aesKey.toString('hex'),
217
+ });
218
+ const control = createRequestControl(externalSignal, timeoutMs);
219
+ try {
220
+ let uploadDetails;
221
+ try {
222
+ uploadDetails = await abortable(client.getUploadUrl(request, { signal: control.signal, timeoutMs }), control.signal);
223
+ }
224
+ catch (error) {
225
+ if (control.signal.aborted)
226
+ throw abortReason(control.signal);
227
+ throw mediaError('get_upload_url_failed', 'Could not obtain an iLink CDN upload URL', { cause: error });
228
+ }
229
+ const uploadUrl = resolveUploadUrl(uploadDetails, filekey);
230
+ let response;
231
+ try {
232
+ response = await abortable(fetchImpl(uploadUrl, {
233
+ method: 'POST',
234
+ headers: { 'Content-Type': 'application/octet-stream' },
235
+ body: new Uint8Array(ciphertext),
236
+ redirect: 'error',
237
+ signal: control.signal,
238
+ }), control.signal);
239
+ }
240
+ catch (error) {
241
+ if (control.signal.aborted)
242
+ throw abortReason(control.signal);
243
+ throw mediaError('upload_failed', 'iLink CDN upload failed', { cause: error });
244
+ }
245
+ if (response.redirected) {
246
+ throw mediaError('unsafe_upload_url', 'iLink CDN redirects are forbidden');
247
+ }
248
+ if (response.url) {
249
+ const responseUrl = validateCdnUploadUrl(response.url);
250
+ if (responseUrl.href !== uploadUrl.href) {
251
+ throw mediaError('unsafe_upload_url', 'iLink CDN response URL changed');
252
+ }
253
+ }
254
+ if (response.status !== 200) {
255
+ throw mediaError('upload_rejected', `iLink CDN rejected the upload with HTTP ${response.status}`, { status: response.status });
256
+ }
257
+ const encryptedParameter = opaqueParameter(response.headers.get('x-encrypted-param'), 'x-encrypted-param');
258
+ return imageMessageItem({
259
+ encryptedParameter,
260
+ aesKey,
261
+ ciphertextSize: ciphertext.length,
262
+ });
263
+ }
264
+ finally {
265
+ control.cleanup();
266
+ }
267
+ }
@@ -0,0 +1,247 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { MESSAGE_ORIGINS } from '../domain/message.js';
3
+ import { truncateUtf8 } from '../lib/text.js';
4
+ import { IlinkMessageItemType, IlinkMessageState, IlinkMessageType, } from './protocol/types.js';
5
+ import { extractIlinkInboundImageLocator } from './inbound-image.js';
6
+ import { ILINK_ACCOUNT_KEY_PATTERN, ILINK_CHANNEL, ILINK_MAX_PROVIDER_ID_BYTES, } from './store-types.js';
7
+ const MAX_ID_CHARACTERS = 1_024;
8
+ const MAX_CURSOR_CHARACTERS = 256 * 1_024;
9
+ const MAX_CONTEXT_TOKEN_CHARACTERS = 256 * 1_024;
10
+ const MAX_ITEMS = 50;
11
+ const MAX_TEXT_BYTES = 32 * 1_024;
12
+ const MAX_SUMMARY_BYTES = 48 * 1_024;
13
+ const MAX_NATIVE_IMAGES = 4;
14
+ export class IlinkMessageNormalizationError extends Error {
15
+ code;
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.name = 'IlinkMessageNormalizationError';
19
+ this.code = code;
20
+ }
21
+ }
22
+ function isRecord(value) {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ }
25
+ function requiredBoundedString(value, label, maximum, code) {
26
+ if (typeof value !== 'string' ||
27
+ !value ||
28
+ value.length > maximum ||
29
+ value.includes('\0')) {
30
+ throw new IlinkMessageNormalizationError(code, `${label} is invalid`);
31
+ }
32
+ return value;
33
+ }
34
+ function normalizePair(pair) {
35
+ const accountKey = requiredBoundedString(pair?.accountKey, 'iLink accountKey', MAX_ID_CHARACTERS, 'invalid_pair');
36
+ if (!ILINK_ACCOUNT_KEY_PATTERN.test(accountKey)) {
37
+ throw new IlinkMessageNormalizationError('invalid_pair', 'iLink accountKey is invalid');
38
+ }
39
+ const providerId = (value, label) => {
40
+ if (typeof value !== 'string' ||
41
+ !value ||
42
+ value !== value.trim() ||
43
+ Buffer.byteLength(value, 'utf8') > ILINK_MAX_PROVIDER_ID_BYTES ||
44
+ /[\u0000-\u001f\u007f]/u.test(value)) {
45
+ throw new IlinkMessageNormalizationError('invalid_pair', `${label} is invalid`);
46
+ }
47
+ return value;
48
+ };
49
+ return Object.freeze({
50
+ accountKey: accountKey,
51
+ botId: providerId(pair?.botId, 'iLink botId'),
52
+ ownerUserId: providerId(pair?.ownerUserId, 'iLink ownerUserId'),
53
+ });
54
+ }
55
+ function normalizeSync(sync) {
56
+ const cursor = sync?.cursor;
57
+ if (typeof cursor !== 'string' ||
58
+ cursor.length > MAX_CURSOR_CHARACTERS ||
59
+ cursor.includes('\0')) {
60
+ throw new IlinkMessageNormalizationError('invalid_sync', 'iLink cursor is invalid');
61
+ }
62
+ if (!Number.isSafeInteger(sync?.index) || sync.index < 0) {
63
+ throw new IlinkMessageNormalizationError('invalid_sync', 'iLink cursor index is invalid');
64
+ }
65
+ return Object.freeze({ cursor, index: sync.index });
66
+ }
67
+ function sha256(value) {
68
+ return createHash('sha256').update(value).digest('hex');
69
+ }
70
+ function stableString(value) {
71
+ return typeof value === 'string' && value &&
72
+ value.length <= MAX_ID_CHARACTERS && !value.includes('\0')
73
+ ? value
74
+ : '';
75
+ }
76
+ function providerMessageId(message) {
77
+ if (Number.isSafeInteger(message.message_id) &&
78
+ Number(message.message_id) >= 0) {
79
+ return `message:${message.message_id}`;
80
+ }
81
+ const clientId = stableString(message.client_id);
82
+ if (clientId)
83
+ return `client:${sha256(clientId)}`;
84
+ const itemIds = (message.item_list ?? [])
85
+ .map((item) => stableString(isRecord(item) ? item.msg_id : undefined))
86
+ .filter(Boolean);
87
+ if (itemIds.length > 0) {
88
+ return `items:${sha256(JSON.stringify(itemIds))}`;
89
+ }
90
+ if (Number.isSafeInteger(message.seq) && Number(message.seq) >= 0) {
91
+ return `seq:${message.seq}`;
92
+ }
93
+ return undefined;
94
+ }
95
+ function safeFilename(value) {
96
+ return truncateUtf8(typeof value === 'string'
97
+ ? value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim()
98
+ : '', 256, '…');
99
+ }
100
+ function normalizeItem(value) {
101
+ if (!isRecord(value)) {
102
+ return Object.freeze({
103
+ isText: false,
104
+ text: '',
105
+ rendered: '[iLink item: unknown type; content not parsed]',
106
+ });
107
+ }
108
+ const type = Number.isSafeInteger(value.type) && Number(value.type) >= 0
109
+ ? Number(value.type)
110
+ : undefined;
111
+ if (type === IlinkMessageItemType.TEXT) {
112
+ const textItem = isRecord(value.text_item) ? value.text_item : {};
113
+ const rawText = typeof textItem.text === 'string' ? textItem.text : '';
114
+ const text = truncateUtf8(rawText, MAX_TEXT_BYTES, '…');
115
+ return Object.freeze({
116
+ type,
117
+ isText: true,
118
+ text,
119
+ rendered: text.trim() ? text : '[iLink text: empty]',
120
+ });
121
+ }
122
+ let rendered;
123
+ switch (type) {
124
+ case IlinkMessageItemType.IMAGE:
125
+ rendered = '[iLink image: not downloaded or viewed]';
126
+ break;
127
+ case IlinkMessageItemType.VOICE:
128
+ rendered = '[iLink voice: not downloaded, played, or transcribed]';
129
+ break;
130
+ case IlinkMessageItemType.FILE: {
131
+ const file = isRecord(value.file_item) ? value.file_item : {};
132
+ const filename = safeFilename(file.file_name);
133
+ rendered = filename
134
+ ? `[iLink file: ${filename}; not downloaded or opened]`
135
+ : '[iLink file: not downloaded or opened]';
136
+ break;
137
+ }
138
+ case IlinkMessageItemType.VIDEO:
139
+ rendered = '[iLink video: not downloaded, watched, or transcribed]';
140
+ break;
141
+ default:
142
+ rendered = type === undefined
143
+ ? '[iLink item: unknown type; content not parsed]'
144
+ : `[iLink non-text item (type ${type}): content not parsed]`;
145
+ }
146
+ return Object.freeze({
147
+ ...(type === undefined ? {} : { type }),
148
+ isText: false,
149
+ text: '',
150
+ rendered,
151
+ });
152
+ }
153
+ function validOptionalSafeInteger(value) {
154
+ return value === undefined ||
155
+ (Number.isSafeInteger(value) && Number(value) >= 0);
156
+ }
157
+ /**
158
+ * Returns null for messages outside the active one-to-one pair or for malformed
159
+ * provider envelopes. Provider reply and media secrets remain isolated in facts
160
+ * so the host can seal them before persistence.
161
+ */
162
+ export function normalizeIlinkInboundMessage(message, activePair, syncPosition) {
163
+ const pair = normalizePair(activePair);
164
+ const sync = normalizeSync(syncPosition);
165
+ if (!isRecord(message))
166
+ return null;
167
+ const inbound = message;
168
+ const contextToken = inbound.context_token;
169
+ const createTime = inbound.create_time_ms;
170
+ const seq = inbound.seq;
171
+ if (inbound.from_user_id !== pair.ownerUserId ||
172
+ inbound.to_user_id !== pair.botId ||
173
+ inbound.message_type !== IlinkMessageType.USER ||
174
+ inbound.message_state !== IlinkMessageState.FINISH ||
175
+ typeof contextToken !== 'string' ||
176
+ !contextToken ||
177
+ contextToken.length > MAX_CONTEXT_TOKEN_CHARACTERS ||
178
+ contextToken.includes('\0') ||
179
+ typeof createTime !== 'number' ||
180
+ !Number.isSafeInteger(createTime) ||
181
+ createTime < 0 ||
182
+ !validOptionalSafeInteger(seq) ||
183
+ (inbound.item_list !== undefined && !Array.isArray(inbound.item_list)) ||
184
+ (inbound.item_list?.length ?? 0) > MAX_ITEMS) {
185
+ return null;
186
+ }
187
+ const items = (inbound.item_list ?? []).map(normalizeItem);
188
+ const textItems = items.filter((item) => item.isText);
189
+ const nonTextCount = items.length - textItems.length;
190
+ const text = truncateUtf8(textItems.map((item) => item.text).join('\n'), MAX_TEXT_BYTES, '…');
191
+ const summary = truncateUtf8(items.length > 0
192
+ ? items.map((item) => item.rendered).join('\n')
193
+ : '[iLink message: no readable content]', MAX_SUMMARY_BYTES, '…');
194
+ const kind = textItems.length > 0
195
+ ? nonTextCount > 0 ? 'mixed' : 'text'
196
+ : nonTextCount > 0 ? 'non_text' : 'empty';
197
+ const stableProviderMessageId = providerMessageId(inbound);
198
+ if (!stableProviderMessageId)
199
+ return null;
200
+ const images = (inbound.item_list ?? []).flatMap((value, position) => {
201
+ if (!isRecord(value) || value.type !== IlinkMessageItemType.IMAGE)
202
+ return [];
203
+ try {
204
+ const locator = extractIlinkInboundImageLocator(value.image_item);
205
+ const aesKey = locator.aesKey.toString('base64url');
206
+ locator.aesKey.fill(0);
207
+ return [{ position, downloadUrl: locator.downloadUrl, aesKey }];
208
+ }
209
+ catch {
210
+ return [];
211
+ }
212
+ }).slice(-MAX_NATIVE_IMAGES);
213
+ const imageFacts = Object.freeze(images.map((image) => Object.freeze(image)));
214
+ const itemTypes = Object.freeze(items.flatMap((item) => item.type === undefined ? [] : [item.type]));
215
+ const normalizedMessage = Object.freeze({
216
+ providerMessageId: stableProviderMessageId,
217
+ origin: MESSAGE_ORIGINS.CUSTOMER,
218
+ type: kind,
219
+ rawType: `ilink_${kind}`,
220
+ sentAt: createTime,
221
+ sync,
222
+ conversation: Object.freeze({
223
+ channel: ILINK_CHANNEL,
224
+ accountKey: pair.accountKey,
225
+ peerId: pair.ownerUserId,
226
+ }),
227
+ text,
228
+ summary,
229
+ attributes: Object.freeze({
230
+ itemTypes,
231
+ }),
232
+ attachments: Object.freeze(imageFacts.map((image) => Object.freeze({
233
+ kind: 'image',
234
+ mediaId: `ilink:${image.position}`,
235
+ filename: `ilink-image-${image.position}`,
236
+ status: 'unresolved',
237
+ }))),
238
+ });
239
+ return Object.freeze({
240
+ message: normalizedMessage,
241
+ facts: Object.freeze({
242
+ contextToken,
243
+ ...(seq === undefined ? {} : { providerSeq: seq }),
244
+ images: imageFacts,
245
+ }),
246
+ });
247
+ }