@agentshouse/kit 0.0.1 → 0.1.0-alpha.1

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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1696 -2
  3. package/bin/connect-linux.sh +127 -0
  4. package/bin/kit.js +1023 -0
  5. package/package.json +33 -3
  6. package/src/acp.js +99 -0
  7. package/src/adapter-mcp.js +44 -0
  8. package/src/adapter.js +252 -0
  9. package/src/agent-logins.js +65 -0
  10. package/src/agents.js +449 -0
  11. package/src/apply.js +29 -0
  12. package/src/attach.js +52 -0
  13. package/src/binding.js +90 -0
  14. package/src/blocks.js +156 -0
  15. package/src/bridge.js +449 -0
  16. package/src/bytes.js +89 -0
  17. package/src/channel.js +37 -0
  18. package/src/command.js +109 -0
  19. package/src/configuration.js +465 -0
  20. package/src/connector-login.js +91 -0
  21. package/src/connector.js +107 -0
  22. package/src/continuation.js +61 -0
  23. package/src/contract.js +363 -0
  24. package/src/conversation-originals.js +143 -0
  25. package/src/custom/authorization.js +63 -0
  26. package/src/declaration.js +35 -0
  27. package/src/delivery.js +191 -0
  28. package/src/directory.js +102 -0
  29. package/src/draft.js +89 -0
  30. package/src/google/calendar.js +177 -0
  31. package/src/google/declared.js +146 -0
  32. package/src/google/drive.js +435 -0
  33. package/src/google/gateway.js +166 -0
  34. package/src/google/gmail.js +249 -0
  35. package/src/google/id.js +1 -0
  36. package/src/google/oauth.js +273 -0
  37. package/src/google/source.js +25 -0
  38. package/src/hook.js +376 -0
  39. package/src/host.js +121 -0
  40. package/src/house-main.js +79 -0
  41. package/src/house.js +1085 -0
  42. package/src/inbound.js +175 -0
  43. package/src/instance/google.js +1 -0
  44. package/src/launcher-main.js +19 -0
  45. package/src/launcher.js +276 -0
  46. package/src/local-files.js +113 -0
  47. package/src/login.js +274 -0
  48. package/src/mcp.js +71 -0
  49. package/src/migrator.js +248 -0
  50. package/src/mount.js +582 -0
  51. package/src/operation.js +20 -0
  52. package/src/original.js +631 -0
  53. package/src/peer.js +124 -0
  54. package/src/plan.js +57 -0
  55. package/src/question.js +316 -0
  56. package/src/refusal.js +79 -0
  57. package/src/repository.js +521 -0
  58. package/src/session-bin/house +2 -0
  59. package/src/settlement.js +57 -0
  60. package/src/setup.js +185 -0
  61. package/src/source-originals.js +116 -0
  62. package/src/source.js +286 -0
  63. package/src/starter-set/grilling/LICENSE +21 -0
  64. package/src/starter-set/grilling/SKILL.md +30 -0
  65. package/src/starter-set/grilling/agents/openai.yaml +3 -0
  66. package/src/starter-set.js +17 -0
  67. package/src/state.js +157 -0
  68. package/src/sync-library.js +293 -0
  69. package/src/sync-state.js +116 -0
  70. package/src/sync.js +477 -0
  71. package/src/telegram.js +1045 -0
  72. package/src/terminal.js +57 -0
  73. package/src/tmux.js +119 -0
  74. package/src/worker.js +756 -0
  75. package/src/workspace.js +500 -0
@@ -0,0 +1,1045 @@
1
+ import { chmod, mkdir, readFile, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { createInterface } from "node:readline";
4
+ import { Writable } from "node:stream";
5
+ import {
6
+ CONVERSATION_FILE_TRANSFER_TIMEOUT_MS,
7
+ KIT_CONTRACT_VERSION,
8
+ ORIGINAL_UUID,
9
+ PERSONAL_BOT_DISCONNECT_RELATIVE_PATH,
10
+ PERSONAL_BOT_OFFSET_RELATIVE_PATH,
11
+ PERSONAL_BOT_TOKEN_RELATIVE_PATH,
12
+ TELEGRAM_BOT_API_TIMEOUT_MS,
13
+ TELEGRAM_CONTROL_KEYBOARD_COMMANDS,
14
+ TELEGRAM_DELIVERY_BATCH_MAX,
15
+ TELEGRAM_DELIVERY_KINDS,
16
+ TELEGRAM_DOWNLOAD_CEILING_BYTES,
17
+ TELEGRAM_GATEWAY_POLL_MS,
18
+ TELEGRAM_IMAGE_UPLOAD_CEILING_BYTES,
19
+ TELEGRAM_INGRESS_BATCH_MAX,
20
+ TELEGRAM_MEDIA_KINDS,
21
+ TELEGRAM_UNSUPPORTED_MEDIA_FIELDS,
22
+ TELEGRAM_UPDATE_OUTCOMES,
23
+ telegramApiUrl,
24
+ telegramFileUrl,
25
+ } from "./contract.js";
26
+ import { heldControl, houseRequest, maintainControl, openSession } from "./house.js";
27
+ import { KitRefusal } from "./refusal.js";
28
+ import { writeText } from "./state.js";
29
+
30
+ const TOKEN_PROMPT = "Paste the BotFather token for this Environment: ";
31
+ const AMBIGUOUS_DELIVERY = Object.freeze({ telegram_message_id: null, telegram_topic_id: null });
32
+ const DELIVERY_KINDS = new Set(TELEGRAM_DELIVERY_KINDS);
33
+
34
+ function plain(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+
38
+ function identifier(value) {
39
+ return typeof value === "number" || typeof value === "string";
40
+ }
41
+
42
+ function asId(value) {
43
+ return String(value);
44
+ }
45
+
46
+ function cancelled(error) {
47
+ return error instanceof KitRefusal && error.code === "kit_command_cancelled";
48
+ }
49
+
50
+ function telegramFile(value) {
51
+ if (!plain(value)) return null;
52
+ if (typeof value.file_id !== "string" || value.file_id.length === 0) return null;
53
+ if (typeof value.file_unique_id !== "string" || value.file_unique_id.length === 0) return null;
54
+ if (value.file_name !== undefined && typeof value.file_name !== "string") return null;
55
+ if (value.mime_type !== undefined && typeof value.mime_type !== "string") return null;
56
+ if (value.file_size !== undefined && (!Number.isSafeInteger(value.file_size) || value.file_size < 0)) {
57
+ return null;
58
+ }
59
+ if (value.width !== undefined && typeof value.width !== "number") return null;
60
+ if (value.height !== undefined && typeof value.height !== "number") return null;
61
+ return value;
62
+ }
63
+
64
+ function telegramPhoto(value) {
65
+ const file = telegramFile(value);
66
+ if (file === null) return null;
67
+ if (!Number.isSafeInteger(file.width) || file.width < 1 || file.width > 1_000_000) return null;
68
+ if (!Number.isSafeInteger(file.height) || file.height < 1 || file.height > 1_000_000) return null;
69
+ return file;
70
+ }
71
+
72
+ function telegramUser(value) {
73
+ if (!plain(value) || !identifier(value.id)) return null;
74
+ if (value.is_bot !== undefined && typeof value.is_bot !== "boolean") return null;
75
+ if (value.username !== undefined && typeof value.username !== "string") return null;
76
+ if (value.has_topics_enabled !== undefined && typeof value.has_topics_enabled !== "boolean") {
77
+ return null;
78
+ }
79
+ return value;
80
+ }
81
+
82
+ function telegramMessage(value) {
83
+ if (!plain(value) || !identifier(value.message_id)) return null;
84
+ if (!plain(value.chat) || !identifier(value.chat.id) || typeof value.chat.type !== "string") return null;
85
+ if (value.from !== undefined && telegramUser(value.from) === null) return null;
86
+ if (value.text !== undefined && typeof value.text !== "string") return null;
87
+ if (value.caption !== undefined && typeof value.caption !== "string") return null;
88
+ if (value.message_thread_id !== undefined && !identifier(value.message_thread_id)) return null;
89
+ for (const kind of TELEGRAM_MEDIA_KINDS) {
90
+ if (kind === "photo" || value[kind] === undefined) continue;
91
+ if (telegramFile(value[kind]) === null) return null;
92
+ }
93
+ if (value.photo !== undefined) {
94
+ if (!Array.isArray(value.photo) || value.photo.some((one) => telegramPhoto(one) === null)) return null;
95
+ }
96
+ return value;
97
+ }
98
+
99
+ function telegramCallback(value) {
100
+ if (!plain(value) || !identifier(value.id) || telegramUser(value.from) === null) return null;
101
+ if (value.data !== undefined && typeof value.data !== "string") return null;
102
+ if (value.message !== undefined && telegramMessage(value.message) === null) return null;
103
+ return value;
104
+ }
105
+
106
+ export function parseTelegramBotIdentity(value) {
107
+ if (!plain(value) || !identifier(value.id) || value.is_bot !== true) return null;
108
+ if (typeof value.username !== "string" || value.username.length === 0) return null;
109
+ return {
110
+ telegramBotUserId: asId(value.id),
111
+ botUsername: value.username,
112
+ hasTopicsEnabled: value.has_topics_enabled === true,
113
+ };
114
+ }
115
+
116
+ function attachmentOf(kind, value) {
117
+ const file = kind === "photo" ? telegramPhoto(value) : telegramFile(value);
118
+ if (file === null) return null;
119
+ return {
120
+ file_id: file.file_id,
121
+ file_unique_id: file.file_unique_id,
122
+ media_kind: kind,
123
+ original_name: file.file_name ?? null,
124
+ media_type: file.mime_type ?? null,
125
+ byte_size: file.file_size ?? null,
126
+ ...(kind === "photo" ? { dimensions: { width: file.width, height: file.height } } : {}),
127
+ };
128
+ }
129
+
130
+ function largestPhoto(photos) {
131
+ if (!Array.isArray(photos) || photos.length === 0) return null;
132
+ const chosen = photos.reduce((best, candidate) =>
133
+ (candidate.file_size ?? 0) > (best.file_size ?? 0) ? candidate : best,
134
+ );
135
+ return attachmentOf("photo", { ...chosen, mime_type: chosen.mime_type ?? "image/jpeg" });
136
+ }
137
+
138
+ function mediaOf(message) {
139
+ if (message === undefined) return { caption: null, attachments: [], unsupported: false };
140
+ const attachments = [];
141
+ const photo = largestPhoto(message.photo);
142
+ if (photo !== null) attachments.push(photo);
143
+ for (const kind of TELEGRAM_MEDIA_KINDS) {
144
+ if (kind === "photo") continue;
145
+ const attachment = attachmentOf(kind, message[kind]);
146
+ if (attachment !== null) attachments.push(attachment);
147
+ }
148
+ return {
149
+ caption: message.caption ?? null,
150
+ attachments,
151
+ unsupported: TELEGRAM_UNSUPPORTED_MEDIA_FIELDS.some((field) => message[field] !== undefined),
152
+ };
153
+ }
154
+
155
+ function updateKind(input) {
156
+ if (input.chatType !== "private" || input.fromIsBot) return "foreign";
157
+ if (input.callback) return "callback";
158
+ if (input.edited) return "edited_message";
159
+ if (input.text !== null && TELEGRAM_CONTROL_KEYBOARD_COMMANDS.includes(input.text)) return "control";
160
+ return "ordinary_message";
161
+ }
162
+
163
+ export function normalizeTelegramUpdate(telegramBotUserId, value) {
164
+ if (!plain(value) || !identifier(value.update_id)) return null;
165
+ const callback = value.callback_query === undefined ? undefined : telegramCallback(value.callback_query);
166
+ const edited = value.edited_message === undefined ? undefined : telegramMessage(value.edited_message);
167
+ const posted = value.message === undefined ? undefined : telegramMessage(value.message);
168
+ if (callback === null || edited === null || posted === null) return null;
169
+ const message = callback?.message ?? edited ?? posted;
170
+ if (message === undefined && callback === undefined) return null;
171
+ const from = callback?.from ?? message?.from;
172
+ if (from === undefined) return null;
173
+ const chat = message?.chat;
174
+ const text = callback === undefined ? (message?.text ?? null) : null;
175
+ const media = callback === undefined ? mediaOf(message) : mediaOf(undefined);
176
+ const fromIsBot = from.is_bot === true;
177
+ return {
178
+ telegram_bot_user_id: telegramBotUserId,
179
+ update_id: asId(value.update_id),
180
+ kind: updateKind({
181
+ chatType: chat?.type ?? "private",
182
+ fromIsBot,
183
+ text,
184
+ callback: callback !== undefined,
185
+ edited: edited !== undefined,
186
+ }),
187
+ chat_id: chat === undefined ? asId(from.id) : asId(chat.id),
188
+ topic_id: message?.message_thread_id === undefined ? null : asId(message.message_thread_id),
189
+ from_telegram_user_id: asId(from.id),
190
+ from_is_bot: fromIsBot,
191
+ message_id: message === undefined ? null : asId(message.message_id),
192
+ text,
193
+ caption: media.caption,
194
+ attachments: media.attachments,
195
+ unsupported_media: media.unsupported,
196
+ callback_correlation: callback?.data ?? null,
197
+ };
198
+ }
199
+
200
+ export function telegramRetryAfter(answered) {
201
+ if (!plain(answered) || answered.ok !== false || !plain(answered.parameters)) return null;
202
+ const retryAfter = answered.parameters.retry_after;
203
+ if (!Number.isSafeInteger(retryAfter) || retryAfter <= 0) return null;
204
+ return retryAfter;
205
+ }
206
+
207
+ function botApiAccepted(answered) {
208
+ return plain(answered) && answered.ok === true;
209
+ }
210
+
211
+ function botApiRefused(answered) {
212
+ return plain(answered) && answered.ok === false;
213
+ }
214
+
215
+ function inlineKeyboard(delivery) {
216
+ return {
217
+ inline_keyboard: delivery.options.map((option) => [
218
+ { text: option.label, callback_data: option.correlation },
219
+ ]),
220
+ };
221
+ }
222
+
223
+ function sendBody(delivery) {
224
+ const body = { chat_id: Number(delivery.chat_id), text: delivery.text ?? "" };
225
+ if (delivery.topic_id !== null) body.message_thread_id = Number(delivery.topic_id);
226
+ if (delivery.reply_to_message_id !== null) {
227
+ body.reply_to_message_id = Number(delivery.reply_to_message_id);
228
+ }
229
+ if (delivery.kind === "agent_choice") body.reply_markup = inlineKeyboard(delivery);
230
+ if (delivery.kind === "interaction_card" && delivery.options.length > 0) {
231
+ body.reply_markup = inlineKeyboard(delivery);
232
+ }
233
+ if (delivery.kind === "control_keyboard") {
234
+ body.reply_markup = {
235
+ keyboard: [TELEGRAM_CONTROL_KEYBOARD_COMMANDS.map((command) => ({ text: command }))],
236
+ is_persistent: true,
237
+ resize_keyboard: true,
238
+ };
239
+ }
240
+ return body;
241
+ }
242
+
243
+ export function draftCall(draft) {
244
+ return {
245
+ method: "sendRichMessageDraft",
246
+ body: {
247
+ chat_id: Number(draft.chat_id),
248
+ message_thread_id: Number(draft.topic_id),
249
+ draft_id: Number(draft.draft_id),
250
+ rich_message: draft.rich_message,
251
+ },
252
+ };
253
+ }
254
+
255
+ export function deliveryCall(delivery) {
256
+ if (delivery.kind === "conversation_topic") {
257
+ return {
258
+ method: "createForumTopic",
259
+ body: { chat_id: Number(delivery.chat_id), name: delivery.text ?? "" },
260
+ };
261
+ }
262
+ if (delivery.rich_message !== undefined && delivery.rich_message !== null) {
263
+ const body = { chat_id: Number(delivery.chat_id), rich_message: delivery.rich_message };
264
+ if (delivery.topic_id !== null) body.message_thread_id = Number(delivery.topic_id);
265
+ if (delivery.edit_telegram_message_id !== null) {
266
+ body.message_id = Number(delivery.edit_telegram_message_id);
267
+ return { method: "editMessageText", body };
268
+ }
269
+ return { method: "sendRichMessage", body };
270
+ }
271
+ if (delivery.edit_telegram_message_id !== null) {
272
+ const body = {
273
+ chat_id: Number(delivery.chat_id),
274
+ message_id: Number(delivery.edit_telegram_message_id),
275
+ text: delivery.text ?? "",
276
+ };
277
+ if (delivery.kind === "interaction_card" && delivery.options.length > 0) {
278
+ body.reply_markup = inlineKeyboard(delivery);
279
+ }
280
+ return { method: "editMessageText", body };
281
+ }
282
+ return { method: "sendMessage", body: sendBody(delivery) };
283
+ }
284
+
285
+ function acceptedDelivery(delivery, result) {
286
+ if (delivery.kind === "conversation_topic") {
287
+ const threadId = plain(result) ? result.message_thread_id : undefined;
288
+ if (!identifier(threadId)) return null;
289
+ return { telegram_message_id: null, telegram_topic_id: asId(threadId) };
290
+ }
291
+ const messageId = plain(result) ? result.message_id : undefined;
292
+ if (!identifier(messageId)) return null;
293
+ return { telegram_message_id: asId(messageId), telegram_topic_id: null };
294
+ }
295
+
296
+ export function deliveryOutcome(delivery, answered) {
297
+ if (botApiAccepted(answered)) {
298
+ const delivered = acceptedDelivery(delivery, answered.result);
299
+ if (delivered !== null) return { outcome: "delivered", report: delivered };
300
+ }
301
+ if (botApiRefused(answered)) return { outcome: "failed", report: AMBIGUOUS_DELIVERY };
302
+ return { outcome: "delivery_unknown", report: AMBIGUOUS_DELIVERY };
303
+ }
304
+
305
+ export function uploadFields(upload) {
306
+ const fields = { chat_id: upload.chat_id };
307
+ if (upload.topic_id !== null) fields.message_thread_id = upload.topic_id;
308
+ if (upload.caption !== null) fields.caption = upload.caption;
309
+ return fields;
310
+ }
311
+
312
+ export function uploadOutcome(answered) {
313
+ if (botApiAccepted(answered)) {
314
+ const messageId = plain(answered.result) ? answered.result.message_id : undefined;
315
+ if (identifier(messageId)) {
316
+ return {
317
+ outcome: "delivered",
318
+ report: { telegram_message_id: asId(messageId), reason: null },
319
+ };
320
+ }
321
+ }
322
+ if (botApiRefused(answered)) {
323
+ return { outcome: "failed", report: { telegram_message_id: null, reason: "send_refused" } };
324
+ }
325
+ return {
326
+ outcome: "delivery_unknown",
327
+ report: { telegram_message_id: null, reason: "provider_response_ambiguous" },
328
+ };
329
+ }
330
+
331
+ function refuseAnswer() {
332
+ throw new KitRefusal("kit_telegram_response_invalid");
333
+ }
334
+
335
+ function queuedDelivery(value) {
336
+ if (!plain(value) || !ORIGINAL_UUID.test(value.id)) refuseAnswer();
337
+ if (!DELIVERY_KINDS.has(value.kind) || typeof value.chat_id !== "string") refuseAnswer();
338
+ if (value.topic_id !== null && typeof value.topic_id !== "string") refuseAnswer();
339
+ if (value.text !== null && typeof value.text !== "string") refuseAnswer();
340
+ if (value.reply_to_message_id !== null && typeof value.reply_to_message_id !== "string") refuseAnswer();
341
+ if (value.edit_telegram_message_id !== null && typeof value.edit_telegram_message_id !== "string") {
342
+ refuseAnswer();
343
+ }
344
+ if (value.rich_message !== undefined && value.rich_message !== null && !plain(value.rich_message)) {
345
+ refuseAnswer();
346
+ }
347
+ if (!Array.isArray(value.options)) refuseAnswer();
348
+ for (const option of value.options) {
349
+ if (!plain(option) || typeof option.correlation !== "string" || typeof option.label !== "string") {
350
+ refuseAnswer();
351
+ }
352
+ }
353
+ return value;
354
+ }
355
+
356
+ function queuedDraft(value) {
357
+ if (!plain(value) || !plain(value.rich_message)) refuseAnswer();
358
+ for (const named of ["conversation_id", "turn_id", "chat_id", "topic_id", "draft_id"]) {
359
+ if (typeof value[named] !== "string" || value[named].length === 0) refuseAnswer();
360
+ }
361
+ return value;
362
+ }
363
+
364
+ function queuedUpload(value) {
365
+ if (!plain(value) || !ORIGINAL_UUID.test(value.id)) refuseAnswer();
366
+ if (typeof value.chat_id !== "string" || typeof value.file_name !== "string") refuseAnswer();
367
+ if (value.topic_id !== null && typeof value.topic_id !== "string") refuseAnswer();
368
+ if (value.caption !== null && typeof value.caption !== "string") refuseAnswer();
369
+ return value;
370
+ }
371
+
372
+ function queueOf(answer) {
373
+ if (!Array.isArray(answer?.queue) || answer.queue.length > TELEGRAM_DELIVERY_BATCH_MAX) refuseAnswer();
374
+ return answer.queue.map((item) => {
375
+ if (!plain(item)) refuseAnswer();
376
+ if (item.type === "message") return { type: "message", delivery: queuedDelivery(item.delivery) };
377
+ if (item.type === "draft") return { type: "draft", draft: queuedDraft(item.draft) };
378
+ if (item.type !== "file") refuseAnswer();
379
+ return { type: "file", upload: queuedUpload(item.upload) };
380
+ });
381
+ }
382
+
383
+ function fetchesOf(answer) {
384
+ if (!Array.isArray(answer?.fetches) || answer.fetches.length > TELEGRAM_DELIVERY_BATCH_MAX) {
385
+ refuseAnswer();
386
+ }
387
+ for (const pending of answer.fetches) {
388
+ if (!plain(pending) || !ORIGINAL_UUID.test(pending.id)) refuseAnswer();
389
+ if (typeof pending.file_id !== "string" || pending.file_id.length === 0) refuseAnswer();
390
+ if (pending.byte_size !== null && (!Number.isSafeInteger(pending.byte_size) || pending.byte_size < 0)) {
391
+ refuseAnswer();
392
+ }
393
+ }
394
+ return answer.fetches;
395
+ }
396
+
397
+ function submissionOf(answer) {
398
+ if (!plain(answer) || !TELEGRAM_UPDATE_OUTCOMES.includes(answer.outcome)) refuseAnswer();
399
+ return answer.outcome;
400
+ }
401
+
402
+ function abandonedOf(answer) {
403
+ if (!plain(answer) || !Number.isSafeInteger(answer.abandoned) || answer.abandoned < 0) refuseAnswer();
404
+ return answer.abandoned;
405
+ }
406
+
407
+ function updatesOf(result) {
408
+ if (!Array.isArray(result)) throw new KitRefusal("telegram_get_updates_invalid");
409
+ return result;
410
+ }
411
+
412
+ function telegramFileLocation(result) {
413
+ if (!plain(result) || typeof result.file_id !== "string" || result.file_id.length === 0) {
414
+ throw new KitRefusal("telegram_get_file_invalid");
415
+ }
416
+ if (typeof result.file_path !== "string" || result.file_path.length === 0) {
417
+ throw new KitRefusal("telegram_get_file_invalid");
418
+ }
419
+ return result;
420
+ }
421
+
422
+ function tokenPath(directory) {
423
+ return join(directory, PERSONAL_BOT_TOKEN_RELATIVE_PATH);
424
+ }
425
+
426
+ function offsetPath(directory) {
427
+ return join(directory, PERSONAL_BOT_OFFSET_RELATIVE_PATH);
428
+ }
429
+
430
+ function disconnectPath(directory) {
431
+ return join(directory, PERSONAL_BOT_DISCONNECT_RELATIVE_PATH);
432
+ }
433
+
434
+ async function writeOwned(path, contents, signal) {
435
+ const parent = join(path, "..");
436
+ await mkdir(parent, { recursive: true, mode: 0o700 });
437
+ await chmod(parent, 0o700);
438
+ await writeText(path, contents, signal);
439
+ }
440
+
441
+ async function readSecret(directory) {
442
+ let stored;
443
+ try {
444
+ stored = (await readFile(tokenPath(directory), "utf8")).trim();
445
+ } catch {
446
+ throw new KitRefusal("telegram_bot_token_required");
447
+ }
448
+ if (stored.length === 0) throw new KitRefusal("telegram_bot_token_required");
449
+ return stored;
450
+ }
451
+
452
+ async function disconnecting(directory) {
453
+ return readFile(disconnectPath(directory)).then(
454
+ () => true,
455
+ () => false,
456
+ );
457
+ }
458
+
459
+ async function readOffset(directory) {
460
+ let stored;
461
+ try {
462
+ stored = await readFile(offsetPath(directory), "utf8");
463
+ } catch (error) {
464
+ if (error.code === "ENOENT") return 0;
465
+ throw new KitRefusal("telegram_offset_unreadable");
466
+ }
467
+ let parsed;
468
+ try {
469
+ parsed = JSON.parse(stored);
470
+ } catch {
471
+ throw new KitRefusal("telegram_offset_unreadable");
472
+ }
473
+ if (!plain(parsed) || !Number.isSafeInteger(parsed.offset) || parsed.offset < 0) {
474
+ throw new KitRefusal("telegram_offset_unreadable");
475
+ }
476
+ return parsed.offset;
477
+ }
478
+
479
+ async function writeOffset(directory, offset, signal) {
480
+ await writeOwned(offsetPath(directory), `${JSON.stringify({ offset })}\n`, signal);
481
+ }
482
+
483
+ function boundedSignal(gateway, timeoutMs) {
484
+ const signals = [AbortSignal.timeout(timeoutMs)];
485
+ if (gateway.signal !== undefined) signals.push(gateway.signal);
486
+ return AbortSignal.any(signals);
487
+ }
488
+
489
+ function transportRefusal(gateway, code, detail = null) {
490
+ if (gateway.signal?.aborted === true) return new KitRefusal("kit_command_cancelled");
491
+ return new KitRefusal(code, detail);
492
+ }
493
+
494
+ async function telegramPost(gateway, url, body) {
495
+ const fetcher = gateway.dependencies.fetch ?? fetch;
496
+ let response;
497
+ try {
498
+ response = await fetcher(url, {
499
+ method: "POST",
500
+ redirect: "error",
501
+ signal: boundedSignal(gateway, TELEGRAM_BOT_API_TIMEOUT_MS),
502
+ headers: { "content-type": "application/json" },
503
+ body: JSON.stringify(body),
504
+ });
505
+ } catch {
506
+ throw transportRefusal(gateway, "telegram_bot_api_invalid");
507
+ }
508
+ return response.json().catch(() => null);
509
+ }
510
+
511
+ async function telegramUpload(gateway, url, fields, file) {
512
+ const fetcher = gateway.dependencies.fetch ?? fetch;
513
+ const form = new FormData();
514
+ for (const [name, value] of Object.entries(fields)) form.append(name, value);
515
+ form.append(file.field, new Blob([file.bytes]), file.name);
516
+ let response;
517
+ try {
518
+ response = await fetcher(url, {
519
+ method: "POST",
520
+ redirect: "error",
521
+ signal: boundedSignal(gateway, CONVERSATION_FILE_TRANSFER_TIMEOUT_MS),
522
+ body: form,
523
+ });
524
+ } catch {
525
+ throw transportRefusal(gateway, "telegram_bot_api_invalid");
526
+ }
527
+ return response.json().catch(() => null);
528
+ }
529
+
530
+ async function telegramCall(gateway, token, method, body = {}) {
531
+ const answered = await telegramPost(gateway, telegramApiUrl(token, method), body);
532
+ const retryAfter = telegramRetryAfter(answered);
533
+ if (retryAfter !== null) {
534
+ gateway.gate.hold(retryAfter);
535
+ throw new KitRefusal("telegram_bot_rate_limited");
536
+ }
537
+ if (!botApiAccepted(answered)) throw new KitRefusal("telegram_bot_api_invalid");
538
+ return answered.result;
539
+ }
540
+
541
+ async function downloadTelegramFile(gateway, url) {
542
+ const fetcher = gateway.dependencies.fetch ?? fetch;
543
+ let response;
544
+ try {
545
+ response = await fetcher(url, {
546
+ method: "GET",
547
+ redirect: "error",
548
+ signal: boundedSignal(gateway, CONVERSATION_FILE_TRANSFER_TIMEOUT_MS),
549
+ });
550
+ } catch {
551
+ throw transportRefusal(gateway, "telegram_get_file_invalid");
552
+ }
553
+ if (!response.ok || response.body === null) {
554
+ await response.body?.cancel();
555
+ throw new KitRefusal("telegram_get_file_invalid");
556
+ }
557
+ const bytes = await readCapped(response, TELEGRAM_DOWNLOAD_CEILING_BYTES);
558
+ if (bytes.length > TELEGRAM_DOWNLOAD_CEILING_BYTES) {
559
+ throw new KitRefusal("telegram_inbound_too_large");
560
+ }
561
+ return bytes;
562
+ }
563
+
564
+ async function readCapped(response, ceiling) {
565
+ const reader = response.body.getReader();
566
+ const chunks = [];
567
+ let size = 0;
568
+ try {
569
+ for (;;) {
570
+ const chunk = await reader.read();
571
+ if (chunk.done) break;
572
+ size += chunk.value.byteLength;
573
+ chunks.push(chunk.value);
574
+ if (size > ceiling) break;
575
+ }
576
+ } finally {
577
+ await reader.cancel().catch(() => {});
578
+ reader.releaseLock();
579
+ }
580
+ return Buffer.concat(chunks, size);
581
+ }
582
+
583
+ function houseCall(gateway, path, body = {}) {
584
+ return heldControl(gateway.session, gateway.signal).then((authorization) =>
585
+ houseRequest(
586
+ gateway.session.house,
587
+ authorization,
588
+ `/kit/telegram/${path}`,
589
+ body,
590
+ gateway.dependencies,
591
+ gateway.signal,
592
+ ),
593
+ );
594
+ }
595
+
596
+ async function pushInboundBytes(gateway, id, bytes) {
597
+ const signal = boundedSignal(gateway, CONVERSATION_FILE_TRANSFER_TIMEOUT_MS);
598
+ const authorization = await heldControl(gateway.session, signal);
599
+ const fetcher = gateway.dependencies.fetch ?? fetch;
600
+ let response;
601
+ try {
602
+ response = await fetcher(`${gateway.session.house}/kit/telegram/inbound/${id}/bytes`, {
603
+ method: "POST",
604
+ redirect: "error",
605
+ signal,
606
+ headers: {
607
+ authorization,
608
+ "content-type": "application/octet-stream",
609
+ "x-house-kit-version": KIT_CONTRACT_VERSION,
610
+ },
611
+ body: bytes,
612
+ });
613
+ } catch {
614
+ throw transportRefusal(gateway, "kit_inbound_unavailable", id);
615
+ }
616
+ const answer = await response.json().catch(() => null);
617
+ if (!response.ok || answer?.ok !== true) throw new KitRefusal("kit_inbound_unavailable", id);
618
+ }
619
+
620
+ async function pullOutboundBytes(gateway, upload) {
621
+ const signal = boundedSignal(gateway, CONVERSATION_FILE_TRANSFER_TIMEOUT_MS);
622
+ const authorization = await heldControl(gateway.session, signal);
623
+ const fetcher = gateway.dependencies.fetch ?? fetch;
624
+ let response;
625
+ try {
626
+ response = await fetcher(`${gateway.session.house}/kit/telegram/outbound/${upload.id}/stream`, {
627
+ method: "POST",
628
+ redirect: "error",
629
+ signal,
630
+ headers: {
631
+ authorization,
632
+ "content-type": "application/json",
633
+ "x-house-kit-version": KIT_CONTRACT_VERSION,
634
+ },
635
+ body: "{}",
636
+ });
637
+ } catch {
638
+ throw transportRefusal(gateway, "kit_outbound_unavailable", upload.id);
639
+ }
640
+ if (!response.ok || response.body === null) {
641
+ await response.body?.cancel();
642
+ throw new KitRefusal("kit_outbound_unavailable", upload.id);
643
+ }
644
+ return readCapped(response, TELEGRAM_IMAGE_UPLOAD_CEILING_BYTES);
645
+ }
646
+
647
+ function rateGate(now) {
648
+ let until = 0;
649
+ return {
650
+ open: () => now() < until,
651
+ hold: (retryAfter) => {
652
+ until = Math.max(until, now() + retryAfter * 1000);
653
+ },
654
+ };
655
+ }
656
+
657
+ function pause(milliseconds, signal) {
658
+ return new Promise((resolve, reject) => {
659
+ if (signal?.aborted === true) {
660
+ reject(new KitRefusal("kit_command_cancelled"));
661
+ return;
662
+ }
663
+ const timer = setTimeout(finish, milliseconds);
664
+ function finish() {
665
+ signal?.removeEventListener("abort", cancel);
666
+ resolve();
667
+ }
668
+ function cancel() {
669
+ clearTimeout(timer);
670
+ signal.removeEventListener("abort", cancel);
671
+ reject(new KitRefusal("kit_command_cancelled"));
672
+ }
673
+ signal?.addEventListener("abort", cancel, { once: true });
674
+ });
675
+ }
676
+
677
+ async function gatewayToken(gateway) {
678
+ if (await disconnecting(gateway.directory)) throw new KitRefusal("telegram_bot_disconnecting");
679
+ return readSecret(gateway.directory);
680
+ }
681
+
682
+ async function submitUpdates(gateway) {
683
+ if (gateway.gate.open()) return;
684
+ const token = await gatewayToken(gateway);
685
+ const identity = parseTelegramBotIdentity(await telegramCall(gateway, token, "getMe"));
686
+ if (identity === null) throw new KitRefusal("telegram_bot_identity_invalid");
687
+ const offset = await readOffset(gateway.directory);
688
+ const updates = updatesOf(
689
+ await telegramCall(gateway, token, "getUpdates", {
690
+ offset,
691
+ limit: TELEGRAM_INGRESS_BATCH_MAX,
692
+ timeout: 0,
693
+ allowed_updates: ["message", "callback_query"],
694
+ }),
695
+ );
696
+ for (const update of updates) {
697
+ const event = normalizeTelegramUpdate(identity.telegramBotUserId, update);
698
+ if (event === null) throw new KitRefusal("telegram_update_invalid");
699
+ const outcome = submissionOf(await houseCall(gateway, "updates", event));
700
+ await writeOffset(gateway.directory, Number(event.update_id) + 1, gateway.signal);
701
+ gateway.summary.submitted.push({ update: event.update_id, kind: event.kind, outcome });
702
+ }
703
+ }
704
+
705
+ async function relayInboundFiles(gateway) {
706
+ if (gateway.gate.open()) return;
707
+ const token = await gatewayToken(gateway);
708
+ const pending = fetchesOf(
709
+ await houseCall(gateway, "inbound/fetches/acquire", { max: TELEGRAM_DELIVERY_BATCH_MAX }),
710
+ );
711
+ for (const file of pending) {
712
+ if (gateway.gate.open()) return;
713
+ try {
714
+ if (file.byte_size !== null && file.byte_size > TELEGRAM_DOWNLOAD_CEILING_BYTES) {
715
+ throw new KitRefusal("telegram_inbound_too_large");
716
+ }
717
+ const located = telegramFileLocation(
718
+ await telegramCall(gateway, token, "getFile", { file_id: file.file_id }),
719
+ );
720
+ const bytes = await downloadTelegramFile(gateway, telegramFileUrl(token, located.file_path));
721
+ await pushInboundBytes(gateway, file.id, bytes);
722
+ gateway.summary.relayed.push({ attachment: file.id, bytes: bytes.length });
723
+ } catch (error) {
724
+ if (cancelled(error) || !(error instanceof KitRefusal)) throw error;
725
+ gateway.summary.failures.push({ activity: "inbound", item: file.id, code: error.code });
726
+ }
727
+ }
728
+ }
729
+
730
+ async function reportDelivery(gateway, delivery, settled) {
731
+ await houseCall(gateway, `deliveries/${delivery.id}/outcome`, {
732
+ outcome: settled.outcome,
733
+ telegram_message_id: settled.report.telegram_message_id,
734
+ telegram_topic_id: settled.report.telegram_topic_id,
735
+ });
736
+ gateway.summary.deliveries.push({ delivery: delivery.id, outcome: settled.outcome });
737
+ }
738
+
739
+ async function reportUpload(gateway, upload, settled) {
740
+ await houseCall(gateway, `outbound/${upload.id}/outcome`, {
741
+ outcome: settled.outcome,
742
+ telegram_message_id: settled.report.telegram_message_id,
743
+ reason: settled.report.reason,
744
+ });
745
+ gateway.summary.uploads.push({
746
+ upload: upload.id,
747
+ outcome: settled.outcome,
748
+ reason: settled.report.reason,
749
+ });
750
+ }
751
+
752
+ async function sendQueuedMessage(gateway, token, delivery) {
753
+ await houseCall(gateway, `deliveries/${delivery.id}/attempt`);
754
+ const call = deliveryCall(delivery);
755
+ let answered;
756
+ try {
757
+ answered = await telegramPost(gateway, telegramApiUrl(token, call.method), call.body);
758
+ } catch (error) {
759
+ if (cancelled(error)) throw error;
760
+ await reportDelivery(gateway, delivery, {
761
+ outcome: "delivery_unknown",
762
+ report: AMBIGUOUS_DELIVERY,
763
+ });
764
+ return false;
765
+ }
766
+ await reportDelivery(gateway, delivery, deliveryOutcome(delivery, answered));
767
+ const retryAfter = telegramRetryAfter(answered);
768
+ if (retryAfter === null) return false;
769
+ gateway.gate.hold(retryAfter);
770
+ return true;
771
+ }
772
+
773
+ async function sendQueuedUpload(gateway, token, upload) {
774
+ let bytes;
775
+ try {
776
+ bytes = await pullOutboundBytes(gateway, upload);
777
+ } catch (error) {
778
+ if (cancelled(error)) throw error;
779
+ await reportUpload(gateway, upload, {
780
+ outcome: "failed",
781
+ report: { telegram_message_id: null, reason: "transfer_failed" },
782
+ });
783
+ return false;
784
+ }
785
+ if (bytes.length > TELEGRAM_IMAGE_UPLOAD_CEILING_BYTES) {
786
+ await reportUpload(gateway, upload, {
787
+ outcome: "failed",
788
+ report: { telegram_message_id: null, reason: "file_too_large" },
789
+ });
790
+ return false;
791
+ }
792
+ let answered;
793
+ try {
794
+ answered = await telegramUpload(
795
+ gateway,
796
+ telegramApiUrl(token, "sendPhoto"),
797
+ uploadFields(upload),
798
+ { field: "photo", name: upload.file_name, bytes },
799
+ );
800
+ } catch (error) {
801
+ if (cancelled(error)) throw error;
802
+ await reportUpload(gateway, upload, {
803
+ outcome: "delivery_unknown",
804
+ report: { telegram_message_id: null, reason: "provider_response_ambiguous" },
805
+ });
806
+ return false;
807
+ }
808
+ await reportUpload(gateway, upload, uploadOutcome(answered));
809
+ const retryAfter = telegramRetryAfter(answered);
810
+ if (retryAfter === null) return false;
811
+ gateway.gate.hold(retryAfter);
812
+ return true;
813
+ }
814
+
815
+ export async function streamDrafts(drafts, gate, post) {
816
+ for (const draft of drafts) {
817
+ if (gate.open()) return;
818
+ const call = draftCall(draft);
819
+ let answered;
820
+ try {
821
+ answered = await post(call);
822
+ } catch (error) {
823
+ if (cancelled(error)) throw error;
824
+ return;
825
+ }
826
+ const retryAfter = telegramRetryAfter(answered);
827
+ if (retryAfter !== null) {
828
+ gate.hold(retryAfter);
829
+ return;
830
+ }
831
+ }
832
+ }
833
+
834
+ async function streamQueuedDrafts(gateway, token, drafts) {
835
+ if (gateway.gate.open()) return;
836
+ await streamDrafts(drafts, gateway.gate, (call) =>
837
+ telegramPost(gateway, telegramApiUrl(token, call.method), call.body),
838
+ );
839
+ }
840
+
841
+ async function drainOutboundQueue(gateway) {
842
+ if (gateway.gate.open()) return;
843
+ const token = await gatewayToken(gateway);
844
+ const queue = queueOf(
845
+ await houseCall(gateway, "queue/acquire", { max: TELEGRAM_DELIVERY_BATCH_MAX }),
846
+ );
847
+ const drafts = [];
848
+ for (const item of queue) {
849
+ if (item.type === "draft") {
850
+ drafts.push(item.draft);
851
+ continue;
852
+ }
853
+ const gated =
854
+ item.type === "file"
855
+ ? await sendQueuedUpload(gateway, token, item.upload)
856
+ : await sendQueuedMessage(gateway, token, item.delivery);
857
+ if (gated) return;
858
+ }
859
+ await streamQueuedDrafts(gateway, token, drafts);
860
+ }
861
+
862
+ async function onePass(gateway, activity, work) {
863
+ try {
864
+ await work(gateway);
865
+ } catch (error) {
866
+ if (cancelled(error) || !(error instanceof KitRefusal)) throw error;
867
+ gateway.summary.failures.push({ activity, item: null, code: error.code });
868
+ }
869
+ }
870
+
871
+ async function paceActivity(gateway, stopping, activity, work) {
872
+ try {
873
+ for (let index = 0; index < gateway.passes; index += 1) {
874
+ if (gateway.signal?.aborted === true) throw new KitRefusal("kit_command_cancelled");
875
+ await onePass(gateway, activity, work);
876
+ if (index + 1 < gateway.passes) await gateway.wait(TELEGRAM_GATEWAY_POLL_MS, gateway.signal);
877
+ }
878
+ } catch (error) {
879
+ stopping.abort();
880
+ throw error;
881
+ }
882
+ }
883
+
884
+ async function serveActivities(gateway, stopping) {
885
+ const settled = await Promise.allSettled([
886
+ paceActivity(gateway, stopping, "ingress", submitUpdates),
887
+ paceActivity(gateway, stopping, "inbound", relayInboundFiles),
888
+ paceActivity(gateway, stopping, "outbound", drainOutboundQueue),
889
+ ]);
890
+ const failures = settled.filter((one) => one.status === "rejected").map((one) => one.reason);
891
+ const reported = failures.find((error) => !cancelled(error)) ?? failures[0];
892
+ if (reported !== undefined) throw reported;
893
+ }
894
+
895
+ function emptySummary() {
896
+ return {
897
+ abandoned: 0,
898
+ submitted: [],
899
+ relayed: [],
900
+ deliveries: [],
901
+ uploads: [],
902
+ failures: [],
903
+ };
904
+ }
905
+
906
+ async function askForToken(dependencies) {
907
+ const input = dependencies.input ?? process.stdin;
908
+ const output = dependencies.output ?? process.stderr;
909
+ if (input.isTTY !== true) {
910
+ const reader = createInterface({ input, terminal: false });
911
+ try {
912
+ output.write(TOKEN_PROMPT);
913
+ for await (const line of reader) return line;
914
+ return "";
915
+ } finally {
916
+ reader.close();
917
+ }
918
+ }
919
+ const hidden = new Writable({
920
+ write(_chunk, _encoding, written) {
921
+ written();
922
+ },
923
+ });
924
+ const reader = createInterface({ input, output: hidden, terminal: true });
925
+ output.write(TOKEN_PROMPT);
926
+ try {
927
+ return await new Promise((entered) => reader.question("", entered));
928
+ } finally {
929
+ reader.close();
930
+ output.write("\n");
931
+ }
932
+ }
933
+
934
+ async function proveOwner(gateway, token, telegramBotUserId) {
935
+ const updates = updatesOf(
936
+ await telegramCall(gateway, token, "getUpdates", {
937
+ offset: 0,
938
+ limit: TELEGRAM_INGRESS_BATCH_MAX,
939
+ timeout: 0,
940
+ allowed_updates: ["message"],
941
+ }),
942
+ );
943
+ for (const update of updates) {
944
+ const event = normalizeTelegramUpdate(telegramBotUserId, update);
945
+ if (
946
+ event !== null &&
947
+ event.kind === "ordinary_message" &&
948
+ !event.from_is_bot &&
949
+ event.topic_id === null
950
+ ) {
951
+ await writeOffset(gateway.directory, Number(event.update_id) + 1, gateway.signal);
952
+ return event.from_telegram_user_id;
953
+ }
954
+ }
955
+ throw new KitRefusal("telegram_owner_not_proven");
956
+ }
957
+
958
+ function gatewayContext(session, options, dependencies, signal, summary = emptySummary()) {
959
+ return {
960
+ session,
961
+ directory: options.directory,
962
+ dependencies,
963
+ signal,
964
+ gate: rateGate(() => session.now().getTime()),
965
+ wait: dependencies.wait ?? pause,
966
+ passes: options.passes ?? Number.POSITIVE_INFINITY,
967
+ summary,
968
+ };
969
+ }
970
+
971
+ export async function connectPersonalBot(options, dependencies = {}) {
972
+ const session = await openSession(options, dependencies);
973
+ const gateway = gatewayContext(session, options, dependencies, options.signal);
974
+ const ask = dependencies.prompt ?? (() => askForToken(dependencies));
975
+ const token = (await ask()).trim();
976
+ if (token.length === 0) throw new KitRefusal("telegram_bot_token_required");
977
+ const identity = parseTelegramBotIdentity(await telegramCall(gateway, token, "getMe"));
978
+ if (identity === null) throw new KitRefusal("telegram_bot_identity_invalid");
979
+ if (!identity.hasTopicsEnabled) throw new KitRefusal("telegram_threaded_mode_required");
980
+ await houseCall(gateway, "personal-bot/preflight", {
981
+ telegram_bot_user_id: identity.telegramBotUserId,
982
+ bot_username: identity.botUsername,
983
+ has_topics_enabled: true,
984
+ });
985
+ await telegramCall(gateway, token, "deleteWebhook", { drop_pending_updates: false });
986
+ const setup = {
987
+ telegram_bot_user_id: identity.telegramBotUserId,
988
+ bot_username: identity.botUsername,
989
+ owner_telegram_user_id: await proveOwner(gateway, token, identity.telegramBotUserId),
990
+ has_topics_enabled: true,
991
+ };
992
+ await writeOwned(tokenPath(options.directory), `${token}\n`, options.signal);
993
+ try {
994
+ await houseCall(gateway, "personal-bot", setup);
995
+ } catch (error) {
996
+ await rm(tokenPath(options.directory), { force: true });
997
+ throw error;
998
+ }
999
+ return setup;
1000
+ }
1001
+
1002
+ export async function disconnectPersonalBot(options, dependencies = {}) {
1003
+ const session = await openSession(options, dependencies);
1004
+ const gateway = gatewayContext(session, options, dependencies, options.signal);
1005
+ const directory = options.directory;
1006
+ const marked = await disconnecting(directory);
1007
+ let token = null;
1008
+ try {
1009
+ token = await readSecret(directory);
1010
+ } catch (error) {
1011
+ if (!marked) throw error;
1012
+ }
1013
+ if (!marked) await writeOwned(disconnectPath(directory), "{}\n", options.signal);
1014
+ if (token !== null) {
1015
+ await telegramCall(gateway, token, "deleteWebhook", { drop_pending_updates: false });
1016
+ await Promise.all([
1017
+ rm(tokenPath(directory), { force: true }),
1018
+ rm(offsetPath(directory), { force: true }),
1019
+ ]);
1020
+ }
1021
+ await houseCall(gateway, "personal-bot/disconnect");
1022
+ await rm(disconnectPath(directory), { force: true });
1023
+ return { revoked: token !== null, resumed: marked };
1024
+ }
1025
+
1026
+ export async function serveTelegramGateway(options, dependencies = {}) {
1027
+ const session = await openSession(options, dependencies);
1028
+ const summary = emptySummary();
1029
+ try {
1030
+ await maintainControl(
1031
+ session,
1032
+ async (signal) => {
1033
+ const stopping = new AbortController();
1034
+ const shared = AbortSignal.any([signal, stopping.signal]);
1035
+ const gateway = gatewayContext(session, options, dependencies, shared, summary);
1036
+ summary.abandoned = abandonedOf(await houseCall(gateway, "deliveries/abandoned"));
1037
+ await serveActivities(gateway, stopping);
1038
+ },
1039
+ options.signal,
1040
+ );
1041
+ } catch (error) {
1042
+ if (!cancelled(error)) throw error;
1043
+ }
1044
+ return summary;
1045
+ }