@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,281 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { truncateUtf8 } from '../lib/text.js';
3
+ import { COMMON_MESSAGE_TYPES, MESSAGE_ORIGINS, } from './message.js';
4
+ export const MESSAGE_TYPES = {
5
+ TEXT: COMMON_MESSAGE_TYPES.TEXT,
6
+ IMAGE: 'image',
7
+ VOICE: 'voice',
8
+ VIDEO: 'video',
9
+ FILE: 'file',
10
+ LOCATION: 'location',
11
+ LINK: 'link',
12
+ BUSINESS_CARD: 'business_card',
13
+ MINIPROGRAM: 'miniprogram',
14
+ MSGMENU: 'msgmenu',
15
+ CHANNELS_SHOP_PRODUCT: 'channels_shop_product',
16
+ CHANNELS_SHOP_ORDER: 'channels_shop_order',
17
+ MERGED_MESSAGE: 'merged_msg',
18
+ CHANNELS: 'channels',
19
+ NOTE: 'note',
20
+ EVENT: COMMON_MESSAGE_TYPES.EVENT,
21
+ UNKNOWN: COMMON_MESSAGE_TYPES.UNKNOWN,
22
+ };
23
+ function canonicalValue(value) {
24
+ if (Array.isArray(value))
25
+ return value.map(canonicalValue);
26
+ if (!value || typeof value !== 'object')
27
+ return value;
28
+ const source = value;
29
+ return Object.fromEntries(Object.keys(source).sort().flatMap((key) => source[key] === undefined ? [] : [[key, canonicalValue(source[key])]]));
30
+ }
31
+ function stableEventProviderId(accountKey, cursor, index, message) {
32
+ // Preserve the v21 fallback identity so an old no-msgid event replay remains
33
+ // a duplicate after the Provider ID moves out of the persistence layer.
34
+ const legacyPayload = { id: '', ...message };
35
+ const digest = createHash('sha256').update(`${accountKey}\0${cursor}\0${index}\0${JSON.stringify(canonicalValue(legacyPayload))}`).digest('hex');
36
+ return `event_${digest.slice(0, 40)}`;
37
+ }
38
+ const KNOWN_TYPES = new Set(Object.values(MESSAGE_TYPES));
39
+ const MAX_MERGED_ITEMS = 50;
40
+ const MAX_MERGED_DEPTH = 3;
41
+ const MAX_SUMMARY_BYTES = 16 * 1024;
42
+ function asObject(value) {
43
+ return value && typeof value === 'object' && !Array.isArray(value)
44
+ ? value
45
+ : {};
46
+ }
47
+ function clean(value, maxBytes = 1024) {
48
+ return truncateUtf8(String(value ?? '')
49
+ .replace(/\s+/gu, ' ')
50
+ .trim(), maxBytes, '…');
51
+ }
52
+ function numberLabel(label, value) {
53
+ const parsed = Number(value);
54
+ return Number.isFinite(parsed) ? `${label}${parsed}` : '';
55
+ }
56
+ function labeled(label, values) {
57
+ const detail = values.filter(Boolean).join('; ');
58
+ return detail ? `[WeChat ${label}] ${detail}` : `[WeChat ${label}]`;
59
+ }
60
+ function menuOptions(menu) {
61
+ const list = menu?.list;
62
+ if (!Array.isArray(list))
63
+ return '';
64
+ return list
65
+ .slice(0, 20)
66
+ .map((item) => {
67
+ const row = asObject(item);
68
+ const type = clean(row.type || 'unknown', 64);
69
+ const body = asObject(row[type]);
70
+ return clean(body.content || `[${type}]`, 256);
71
+ })
72
+ .filter(Boolean)
73
+ .join(', ');
74
+ }
75
+ function parseMergedItems(items, depth) {
76
+ if (!Array.isArray(items))
77
+ return [];
78
+ return items.slice(0, MAX_MERGED_ITEMS).map((value) => {
79
+ const item = asObject(value);
80
+ let payload;
81
+ try {
82
+ payload = JSON.parse(String(item.msg_content || '{}'));
83
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
84
+ payload = null;
85
+ }
86
+ else if (!payload.msgtype && item.msgtype) {
87
+ payload = { ...payload, msgtype: item.msgtype };
88
+ }
89
+ }
90
+ catch {
91
+ payload = null;
92
+ }
93
+ return Object.freeze({
94
+ sendTime: Number(item.send_time || 0),
95
+ type: String(item.msgtype || payload?.msgtype || 'unknown'),
96
+ senderName: clean(item.sender_name || 'Unknown sender', 256),
97
+ summary: summarizePayload(payload, depth),
98
+ });
99
+ });
100
+ }
101
+ function mergedSummary(mergedValue, depth) {
102
+ const merged = asObject(mergedValue);
103
+ if (depth >= MAX_MERGED_DEPTH) {
104
+ return '[WeChat chat history: nesting limit reached]';
105
+ }
106
+ const title = clean(merged?.title, 256);
107
+ const items = parseMergedItems(merged?.item, depth + 1);
108
+ const lines = [
109
+ `[WeChat chat history${title ? `: ${title}` : ''}]`,
110
+ ...items.map((item, index) => `${index + 1}. ${item.senderName}: ${item.summary}`),
111
+ ];
112
+ return truncateUtf8(lines.join('\n'), MAX_SUMMARY_BYTES, '…');
113
+ }
114
+ function summarizePayload(payload, depth = 0) {
115
+ if (!payload || typeof payload !== 'object')
116
+ return '[WeChat message: unreadable payload]';
117
+ const type = String(payload.msgtype || 'unknown');
118
+ switch (type) {
119
+ case MESSAGE_TYPES.TEXT: {
120
+ const content = clean(payload.text?.content, MAX_SUMMARY_BYTES);
121
+ const menuId = clean(payload.text?.menu_id, 128);
122
+ return menuId
123
+ ? `The participant selected a menu item: ${content || '[empty text]'} (menu_id: ${menuId})`
124
+ : content || '[WeChat text: empty]';
125
+ }
126
+ case MESSAGE_TYPES.IMAGE:
127
+ return '[WeChat image: attached as native image input]';
128
+ case MESSAGE_TYPES.VOICE:
129
+ return '[WeChat voice: not downloaded or transcribed]';
130
+ case MESSAGE_TYPES.VIDEO:
131
+ return '[WeChat video: not downloaded, watched, or transcribed]';
132
+ case MESSAGE_TYPES.FILE:
133
+ return labeled('file', [
134
+ clean(payload.file?.filename, 256),
135
+ payload.file?.file_size
136
+ ? `size ${clean(payload.file.file_size, 64)}`
137
+ : '',
138
+ 'content not downloaded or opened',
139
+ ]);
140
+ case MESSAGE_TYPES.LOCATION:
141
+ return labeled('location', [
142
+ clean(payload.location?.name, 256),
143
+ clean(payload.location?.address, 512),
144
+ numberLabel('latitude ', payload.location?.latitude),
145
+ numberLabel('longitude ', payload.location?.longitude),
146
+ ]);
147
+ case MESSAGE_TYPES.LINK:
148
+ return labeled('link', [
149
+ clean(payload.link?.title, 256),
150
+ clean(payload.link?.desc, 512),
151
+ clean(payload.link?.url, 2048),
152
+ ]);
153
+ case MESSAGE_TYPES.BUSINESS_CARD:
154
+ return '[WeChat business card: contact details not parsed]';
155
+ case MESSAGE_TYPES.MINIPROGRAM:
156
+ return labeled('mini program', [
157
+ clean(payload.miniprogram?.title, 256),
158
+ clean(payload.miniprogram?.appid, 128),
159
+ clean(payload.miniprogram?.pagepath, 1024),
160
+ ]);
161
+ case MESSAGE_TYPES.MSGMENU:
162
+ return labeled('menu', [
163
+ clean(payload.msgmenu?.head_content, 512),
164
+ menuOptions(payload.msgmenu),
165
+ clean(payload.msgmenu?.tail_content, 512),
166
+ ]);
167
+ case MESSAGE_TYPES.CHANNELS_SHOP_PRODUCT:
168
+ return labeled('Channels product', [
169
+ clean(payload.channels_shop_product?.title, 256),
170
+ payload.channels_shop_product?.sales_price
171
+ ? `price (cents) ${clean(payload.channels_shop_product.sales_price, 64)}`
172
+ : '',
173
+ clean(payload.channels_shop_product?.shop_nickname, 256),
174
+ payload.channels_shop_product?.product_id
175
+ ? `product ID ${clean(payload.channels_shop_product.product_id, 128)}`
176
+ : '',
177
+ ]);
178
+ case MESSAGE_TYPES.CHANNELS_SHOP_ORDER:
179
+ return labeled('Channels order', [
180
+ payload.channels_shop_order?.order_id
181
+ ? `order ID ${clean(payload.channels_shop_order.order_id, 128)}`
182
+ : '',
183
+ clean(payload.channels_shop_order?.product_titles, 512),
184
+ clean(payload.channels_shop_order?.price_wording, 128),
185
+ clean(payload.channels_shop_order?.state, 128),
186
+ clean(payload.channels_shop_order?.shop_nickname, 256),
187
+ ]);
188
+ case MESSAGE_TYPES.MERGED_MESSAGE:
189
+ return mergedSummary(payload.merged_msg, depth);
190
+ case MESSAGE_TYPES.CHANNELS:
191
+ return labeled('Channels content', [
192
+ clean(payload.channels?.nickname, 256),
193
+ clean(payload.channels?.title, 512),
194
+ payload.channels?.sub_type
195
+ ? `type ${clean(payload.channels.sub_type, 64)}`
196
+ : '',
197
+ ]);
198
+ case MESSAGE_TYPES.NOTE:
199
+ return '[WeChat note: API returned no readable body]';
200
+ default:
201
+ return `[WeChat ${clean(type, 128) || 'unknown'} message: content not parsed]`;
202
+ }
203
+ }
204
+ function messageAttributes(type, raw) {
205
+ switch (type) {
206
+ case MESSAGE_TYPES.TEXT:
207
+ return { menuId: String(raw.text?.menu_id || '') };
208
+ case MESSAGE_TYPES.LOCATION:
209
+ return {
210
+ latitude: Number(raw.location?.latitude),
211
+ longitude: Number(raw.location?.longitude),
212
+ name: String(raw.location?.name || ''),
213
+ address: String(raw.location?.address || ''),
214
+ };
215
+ case MESSAGE_TYPES.LINK:
216
+ return {
217
+ title: String(raw.link?.title || ''),
218
+ description: String(raw.link?.desc || ''),
219
+ url: String(raw.link?.url || ''),
220
+ };
221
+ case MESSAGE_TYPES.MINIPROGRAM:
222
+ return {
223
+ title: String(raw.miniprogram?.title || ''),
224
+ appId: String(raw.miniprogram?.appid || ''),
225
+ pagePath: String(raw.miniprogram?.pagepath || ''),
226
+ };
227
+ case MESSAGE_TYPES.MERGED_MESSAGE:
228
+ return {
229
+ title: String(raw.merged_msg?.title || ''),
230
+ items: parseMergedItems(raw.merged_msg?.item, 1),
231
+ };
232
+ case MESSAGE_TYPES.EVENT:
233
+ return structuredClone(raw.event || {});
234
+ default:
235
+ return {};
236
+ }
237
+ }
238
+ export function normalizeWecomMessage(rawMessage, fallbackOpenKfId = '', { cursor = '', index = 0 } = {}) {
239
+ const raw = asObject(rawMessage);
240
+ const rawType = String(raw.msgtype || 'unknown');
241
+ const type = KNOWN_TYPES.has(rawType)
242
+ ? rawType
243
+ : MESSAGE_TYPES.UNKNOWN;
244
+ const event = asObject(raw.event);
245
+ const origin = Number(raw.origin);
246
+ const mediaId = type === MESSAGE_TYPES.IMAGE ? String(raw.image?.media_id || '') : '';
247
+ const text = type === MESSAGE_TYPES.TEXT ? String(raw.text?.content || '') : '';
248
+ const sync = Object.freeze({
249
+ cursor: String(cursor || ''),
250
+ index: Number.isInteger(index) && index >= 0 ? index : 0,
251
+ });
252
+ const conversation = Object.freeze({
253
+ channel: 'wechat_kf',
254
+ accountKey: String(raw.open_kfid || event.open_kfid || fallbackOpenKfId),
255
+ peerId: String(raw.external_userid || event.external_userid || ''),
256
+ });
257
+ const normalized = {
258
+ origin: origin === 3
259
+ ? MESSAGE_ORIGINS.CUSTOMER
260
+ : origin === 4
261
+ ? MESSAGE_ORIGINS.SYSTEM
262
+ : MESSAGE_ORIGINS.UNKNOWN,
263
+ type,
264
+ rawType,
265
+ sentAt: Number(raw.send_time || 0),
266
+ sync,
267
+ conversation,
268
+ text,
269
+ summary: type === MESSAGE_TYPES.EVENT
270
+ ? `[WeChat system event: ${clean(event.event_type || 'unknown', 128)}]`
271
+ : summarizePayload({ ...raw, msgtype: type }),
272
+ attributes: Object.freeze(messageAttributes(type, raw)),
273
+ attachments: Object.freeze(mediaId
274
+ ? [Object.freeze({ kind: 'image', mediaId, status: 'unresolved' })]
275
+ : []),
276
+ };
277
+ return Object.freeze({
278
+ providerMessageId: String(raw.msgid || '') || stableEventProviderId(conversation.accountKey, sync.cursor, sync.index, normalized),
279
+ ...normalized,
280
+ });
281
+ }
@@ -0,0 +1,306 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { IlinkClient, IlinkProtocolError, } from './protocol/client.js';
3
+ import { IlinkMessageItemType, IlinkMessageState, IlinkMessageType, } from './protocol/types.js';
4
+ import { IlinkSecretBox } from './secret-box.js';
5
+ import { IlinkMediaError, uploadIlinkImageBuffer, } from './media.js';
6
+ import { IlinkSqliteStore, IlinkSqliteStoreError, } from './sqlite-store.js';
7
+ function receipt(attempt, error) {
8
+ return {
9
+ status: attempt.status === 'failed'
10
+ ? 'failed'
11
+ : attempt.status === 'uncertain' ? 'uncertain' : 'accepted',
12
+ attemptId: attempt.attemptId,
13
+ sendIndex: attempt.sendIndex,
14
+ type: attempt.type === 'image' ? 'image' : 'text',
15
+ providerMessageId: attempt.providerMessageId,
16
+ ...(error ? { error } : {}),
17
+ };
18
+ }
19
+ function preflightError(error, forcedKind) {
20
+ const code = error instanceof IlinkSqliteStoreError ? error.code : '';
21
+ const kind = forcedKind || (code === 'reply_window_expired'
22
+ ? 'reply_window_expired'
23
+ : code === 'reply_quota_exhausted'
24
+ ? 'reply_quota_exhausted'
25
+ : 'ilink_session_invalid');
26
+ return { kind, message: 'iLink send precondition failed' };
27
+ }
28
+ function preflightFailure(error, type = 'text', forcedKind) {
29
+ return {
30
+ status: 'failed',
31
+ attemptId: '',
32
+ sendIndex: -1,
33
+ type,
34
+ providerMessageId: '',
35
+ error: preflightError(error, forcedKind),
36
+ };
37
+ }
38
+ function definitive(error) {
39
+ return error instanceof IlinkProtocolError && (error.kind === 'business' ||
40
+ (error.kind === 'http' && Number(error.status || 0) >= 400 &&
41
+ Number(error.status || 0) < 500));
42
+ }
43
+ function deliveryError(error, uncertain) {
44
+ const protocol = error instanceof IlinkProtocolError ? error : undefined;
45
+ return {
46
+ kind: uncertain ? 'uncertain_result' : 'ilink_delivery_failed',
47
+ message: uncertain
48
+ ? 'The iLink delivery outcome is uncertain'
49
+ : 'iLink rejected the message',
50
+ ...(protocol?.ret !== undefined ? { ret: protocol.ret } : {}),
51
+ ...(protocol?.status !== undefined ? { code: protocol.status } : {}),
52
+ };
53
+ }
54
+ export class IlinkSendExecutor {
55
+ #store;
56
+ #ilink;
57
+ #secrets;
58
+ #createClient;
59
+ #media;
60
+ #uploadImage;
61
+ #queues = new Map();
62
+ constructor({ store, ilinkStore, secretBox, createClient = ({ token, baseUrl }) => new IlinkClient({ token, baseUrl }), mediaGateway, uploadImage = uploadIlinkImageBuffer, }) {
63
+ this.#store = store;
64
+ this.#ilink = ilinkStore;
65
+ this.#secrets = secretBox;
66
+ this.#createClient = createClient;
67
+ this.#media = mediaGateway;
68
+ this.#uploadImage = uploadImage;
69
+ }
70
+ #serialize(accountKey, operation) {
71
+ const previous = this.#queues.get(accountKey) || Promise.resolve();
72
+ const task = previous.catch(() => undefined).then(operation);
73
+ this.#queues.set(accountKey, task);
74
+ void task.finally(() => {
75
+ if (this.#queues.get(accountKey) === task)
76
+ this.#queues.delete(accountKey);
77
+ }).catch(() => undefined);
78
+ return task;
79
+ }
80
+ #releasePending(attemptId, error, type, forcedKind) {
81
+ this.#ilink.releasePendingAttempt(attemptId, error instanceof IlinkSqliteStoreError &&
82
+ error.code === 'reply_window_expired'
83
+ ? 'reply_window_expired'
84
+ : undefined);
85
+ const failed = this.#store.getAttempt(attemptId);
86
+ return failed
87
+ ? receipt(failed, preflightError(error, forcedKind))
88
+ : preflightFailure(error, type, forcedKind);
89
+ }
90
+ async execute(tool, input) {
91
+ if (tool !== 'send_text' && tool !== 'send_image') {
92
+ return preflightFailure(new Error('unsupported tool'));
93
+ }
94
+ let accountKey;
95
+ try {
96
+ const session = this.#store.getAgentSession(input.session);
97
+ if (session.channel !== 'weixin_ilink') {
98
+ return preflightFailure(new Error('wrong channel'));
99
+ }
100
+ accountKey = session.accountKey;
101
+ }
102
+ catch (error) {
103
+ return preflightFailure(error);
104
+ }
105
+ return this.#serialize(accountKey, () => tool === 'send_image' ? this.#executeImage(input) : this.#executeText(input));
106
+ }
107
+ async #executeText(input) {
108
+ let pendingAttemptId = '';
109
+ try {
110
+ if (typeof input.content !== 'string' || !input.content) {
111
+ return preflightFailure(new Error('missing text'));
112
+ }
113
+ const session = this.#store.getAgentSession(input.session);
114
+ if (session.channel !== 'weixin_ilink' || !session.replyWindowId) {
115
+ return preflightFailure(new Error('wrong channel'));
116
+ }
117
+ const clientId = `il_${randomBytes(16).toString('base64url')}`;
118
+ const prepared = this.#ilink.prepareReplyAttempt({
119
+ sessionToken: input.session,
120
+ sentType: 'text',
121
+ payload: { content: input.content, clientId },
122
+ metadata: { tool: 'send_text' },
123
+ });
124
+ if (prepared.kind === 'rejected') {
125
+ return receipt(prepared.attempt, preflightError(undefined, prepared.code));
126
+ }
127
+ pendingAttemptId = prepared.attempt.attemptId;
128
+ const delivery = this.#deliveryContext(session.replyWindowId);
129
+ const attempt = this.#ilink.startReplyAttempt({
130
+ sessionToken: input.session,
131
+ attemptId: pendingAttemptId,
132
+ });
133
+ return this.#transmit(attempt, delivery, {
134
+ type: IlinkMessageItemType.TEXT,
135
+ text_item: { text: input.content },
136
+ }, clientId);
137
+ }
138
+ catch (error) {
139
+ return pendingAttemptId
140
+ ? this.#releasePending(pendingAttemptId, error, 'text')
141
+ : preflightFailure(error);
142
+ }
143
+ }
144
+ async #executeImage(input) {
145
+ let pendingAttemptId = '';
146
+ try {
147
+ const session = this.#store.getAgentSession(input.session);
148
+ if (session.channel !== 'weixin_ilink' || !session.replyWindowId ||
149
+ typeof input.mediaRef !== 'string') {
150
+ return preflightFailure(new Error('invalid image session'), 'image');
151
+ }
152
+ let artifact;
153
+ if (input.mediaRef.startsWith('artifact:')) {
154
+ try {
155
+ artifact = this.#store.getAgentArtifact(input.session, input.mediaRef);
156
+ }
157
+ catch (error) {
158
+ return preflightFailure(error, 'image', 'invalid_media_reference');
159
+ }
160
+ }
161
+ const clientId = `il_${randomBytes(16).toString('base64url')}`;
162
+ const prepared = this.#ilink.prepareReplyAttempt({
163
+ sessionToken: input.session,
164
+ sentType: 'image',
165
+ payload: { mediaRef: input.mediaRef, clientId },
166
+ metadata: artifact
167
+ ? { ...(artifact.metadata || {}), tool: 'generated_image' }
168
+ : { tool: 'send_image' },
169
+ });
170
+ if (prepared.kind === 'rejected') {
171
+ return receipt(prepared.attempt, preflightError(undefined, prepared.code));
172
+ }
173
+ pendingAttemptId = prepared.attempt.attemptId;
174
+ const delivery = this.#deliveryContext(session.replyWindowId);
175
+ let bytes;
176
+ try {
177
+ if (artifact) {
178
+ bytes = artifact.bytes;
179
+ }
180
+ else {
181
+ const source = session.mediaCatalog.find((item) => item.ref === input.mediaRef);
182
+ if (!source || !this.#media) {
183
+ return this.#releasePending(pendingAttemptId, new Error('image reference unavailable'), 'image', 'invalid_media_reference');
184
+ }
185
+ bytes = (await this.#media.resolveReference({
186
+ messageKey: source.messageKey,
187
+ mediaId: source.mediaId,
188
+ })).bytes;
189
+ }
190
+ }
191
+ catch (error) {
192
+ return this.#releasePending(pendingAttemptId, error, 'image', input.mediaRef.startsWith('artifact:')
193
+ ? 'invalid_media_reference'
194
+ : 'media_prepare_failed');
195
+ }
196
+ let imageItem;
197
+ try {
198
+ this.#ilink.validatePendingReplyAttempt({
199
+ sessionToken: input.session,
200
+ attemptId: pendingAttemptId,
201
+ });
202
+ imageItem = await this.#uploadImage({
203
+ bytes,
204
+ peerId: delivery.peerId,
205
+ client: delivery.client,
206
+ });
207
+ this.#store.getAgentSession(input.session);
208
+ const attempt = this.#ilink.startReplyAttempt({
209
+ sessionToken: input.session,
210
+ attemptId: pendingAttemptId,
211
+ });
212
+ return this.#transmit(attempt, delivery, imageItem, clientId);
213
+ }
214
+ catch (error) {
215
+ return this.#releasePending(pendingAttemptId, error, 'image', error instanceof IlinkMediaError ? 'media_prepare_failed' : undefined);
216
+ }
217
+ }
218
+ catch (error) {
219
+ return pendingAttemptId
220
+ ? this.#releasePending(pendingAttemptId, error, 'image')
221
+ : preflightFailure(error, 'image');
222
+ }
223
+ }
224
+ #deliveryContext(replyWindowId) {
225
+ const window = this.#ilink.getReplyWindowSecret(replyWindowId);
226
+ const accountWithSecret = window
227
+ ? this.#ilink.getAccountWithSecret(window.accountKey)
228
+ : undefined;
229
+ if (!accountWithSecret || !window ||
230
+ window.peerId !== accountWithSecret.account.ownerPeerId) {
231
+ throw new Error('Missing bound iLink delivery state');
232
+ }
233
+ const botToken = this.#secrets.open(accountWithSecret.secret.sealedBotToken, {
234
+ secretKind: 'bot_token',
235
+ accountId: accountWithSecret.account.accountKey,
236
+ peerId: accountWithSecret.account.ownerPeerId,
237
+ generation: accountWithSecret.account.generation,
238
+ });
239
+ const contextToken = this.#secrets.open(window.sealedContextToken, {
240
+ secretKind: 'context_token',
241
+ accountId: window.accountKey,
242
+ peerId: window.peerId,
243
+ generation: window.secretGeneration,
244
+ });
245
+ return {
246
+ client: this.#createClient({
247
+ token: botToken,
248
+ baseUrl: accountWithSecret.account.baseUrl,
249
+ }),
250
+ contextToken,
251
+ peerId: window.peerId,
252
+ };
253
+ }
254
+ async #transmit(attempt, delivery, item, clientId) {
255
+ try {
256
+ await delivery.client.sendMessage({
257
+ msg: {
258
+ from_user_id: '',
259
+ to_user_id: delivery.peerId,
260
+ client_id: clientId,
261
+ message_type: IlinkMessageType.BOT,
262
+ message_state: IlinkMessageState.FINISH,
263
+ context_token: delivery.contextToken,
264
+ item_list: [item],
265
+ },
266
+ });
267
+ }
268
+ catch (error) {
269
+ if (definitive(error)) {
270
+ const failed = this.#store.failSend(attempt.attemptId, error);
271
+ return receipt(failed, deliveryError(error, false));
272
+ }
273
+ const uncertain = this.#store.markSendUncertain(attempt.attemptId, error);
274
+ return receipt(uncertain, deliveryError(error, true));
275
+ }
276
+ const accepted = this.#store.completeSend(attempt.attemptId, {
277
+ providerMessageId: clientId,
278
+ });
279
+ return receipt(accepted);
280
+ }
281
+ async notifyQueued(messageKey, content = 'Your conversation is queued. Please wait.') {
282
+ const window = this.#ilink.getReplyWindowSecretBySource(messageKey);
283
+ if (!window)
284
+ throw new Error('iLink queue notice has no reply window');
285
+ await this.#serialize(window.accountKey, async () => {
286
+ const delivery = this.#deliveryContext(window.replyWindowId);
287
+ const clientId = `il_${randomBytes(16).toString('base64url')}`;
288
+ const attempt = this.#ilink.reserveStartedSystemAttempt({
289
+ messageKey,
290
+ sentType: 'text',
291
+ source: 'queue_notice',
292
+ payload: { content, clientId },
293
+ metadata: { tool: 'queue_notice' },
294
+ });
295
+ const result = await this.#transmit(attempt, delivery, {
296
+ type: IlinkMessageItemType.TEXT,
297
+ text_item: { text: content },
298
+ }, clientId);
299
+ if (result.status === 'failed')
300
+ throw new Error('iLink queue notice failed');
301
+ });
302
+ }
303
+ async waitForIdle() {
304
+ await Promise.allSettled([...this.#queues.values()]);
305
+ }
306
+ }