@amykit/plugin-telegram 0.0.0-stage → 0.7.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.
@@ -0,0 +1,1190 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { addressed } from "@amykit/core";
5
+ import { BotApiRefusal, TooLarge } from "./BotApi.js";
6
+ /** The most a bot may download, by the Bot API's own limit. */
7
+ export const DEFAULT_MAX_FILE_BYTES = 20 * 1024 * 1024;
8
+ /** Telegram refuses a longer message, so a longer one is sent in parts. */
9
+ const MAX_MESSAGE_CHARS = 4096;
10
+ /** Telegram's limit on a topic's name. */
11
+ const MAX_TOPIC_NAME_CHARS = 128;
12
+ /**
13
+ * Telegram restarts its update ids at random after a week without updates, so
14
+ * an offset older than this is not sent: it could confirm, unread, the new
15
+ * updates numbered below it.
16
+ */
17
+ const OFFSET_LIFETIME_MS = 6 * 24 * 60 * 60 * 1000;
18
+ /** An opening, and what was kept for it, left this long is abandoned. */
19
+ const ABANDONED_MS = 24 * 60 * 60 * 1000;
20
+ /**
21
+ * One topic per piece of work, in the operator's private chat with the bot.
22
+ *
23
+ * Telegram hands a bot its updates once, to whoever asks first, so the
24
+ * process reading them files each message, durably, under the profile that
25
+ * owns its topic before the offset that confirms it moves.
26
+ *
27
+ * Nothing here depends on that reader being alone. Every reply is a file of
28
+ * its own, created and never rewritten; every topic and owner is a file
29
+ * created once; the offset only moves forward. A second reader, or one that
30
+ * was paused past its hold, can only add what is already there, never lose
31
+ * it. The hold on the bot saves work (a 409 from Telegram, a topic opened
32
+ * twice) and nothing else.
33
+ */
34
+ export class TelegramConversation {
35
+ api;
36
+ options;
37
+ asks = true;
38
+ edit = true;
39
+ threads;
40
+ /** The bot: its directory is named for it, so two bots never share a conversation. */
41
+ source;
42
+ opening = new Map();
43
+ /** Who this profile's directory is: a directory removed and made again is somebody else. */
44
+ identity;
45
+ /** The operator's id as Telegram writes it, whatever spelling the config used. */
46
+ operator;
47
+ constructor(api, options) {
48
+ this.api = api;
49
+ this.options = options;
50
+ if (!options.readOnly) {
51
+ privateDirectory(options.directory);
52
+ privateDirectory(this.shared());
53
+ }
54
+ this.operator = normalisedId(options.operator);
55
+ this.identity = options.readOnly ? readIdentity(options.directory) ?? "" : identityOf(options.directory);
56
+ if (this.topics())
57
+ this.threads = true;
58
+ this.source = `telegram:${path.basename(this.shared())}`;
59
+ }
60
+ open(workId, title) {
61
+ const known = this.topicOfWork(workId);
62
+ if (known !== undefined) {
63
+ // An opener that stopped between recording the topic and adopting its early replies left them waiting.
64
+ this.adopt(`${this.chatId()}-${known}`);
65
+ return Promise.resolve({ id: known });
66
+ }
67
+ if (!this.topics())
68
+ return this.openInTheChat(workId);
69
+ // Two looks at the same work in one process must not open two topics.
70
+ const pending = this.opening.get(workId);
71
+ if (pending)
72
+ return pending;
73
+ const opened = this.held(async () => {
74
+ // Another process may have opened it while this one waited.
75
+ const already = this.topicOfWork(workId);
76
+ if (already !== undefined)
77
+ return { id: String(already) };
78
+ // Said before asking, so a reply in a topic Telegram creates now is kept until its owner is recorded.
79
+ const opening = path.join(this.shared(), "opening", String(this.chatId()), `${randomUUID()}.json`);
80
+ createOnce(opening, { workId }, this.shared());
81
+ let topic;
82
+ try {
83
+ topic = await this.api.call("createForumTopic", {
84
+ chat_id: this.chatId(),
85
+ name: topicName(title, workId),
86
+ });
87
+ }
88
+ catch (error) {
89
+ closeOpening(opening);
90
+ throw error;
91
+ }
92
+ // Owner first: a reply that arrives before the work knows its topic is still filed here.
93
+ // The directory is kept relative to the bot's, so a state root moved or mounted elsewhere still finds it.
94
+ // A profile removed while Telegram was answering is not made again, and owns nothing.
95
+ if (readIdentity(this.options.directory) !== this.identity) {
96
+ throw new Error(`${this.options.directory} was removed while its topic was being opened`);
97
+ }
98
+ const key = this.record(workId, String(topic.message_thread_id));
99
+ this.adopt(key);
100
+ closeOpening(opening);
101
+ return { id: this.topicOfWork(workId) };
102
+ }).finally(() => this.opening.delete(workId));
103
+ this.opening.set(workId, opened);
104
+ return opened;
105
+ }
106
+ /**
107
+ * A work in a chat without topics: nothing to ask Telegram for, only its
108
+ * thread to name and record, so a reply addressed to it is filed under it.
109
+ */
110
+ openInTheChat(workId) {
111
+ if (readIdentity(this.options.directory) !== this.identity) {
112
+ return Promise.reject(new Error(`${this.options.directory} was removed while its work was being opened`));
113
+ }
114
+ // The profile is in it too: two phases of one project open the same work id, each a thread of its own.
115
+ this.record(workId, `w${fileNameOf(`${this.identity}\0${workId}`).slice(0, 16)}`);
116
+ return Promise.resolve({ id: this.topicOfWork(workId) });
117
+ }
118
+ /**
119
+ * Records who owns a work's thread, the thread by work id, and the work by
120
+ * its id for the whole bot, so a message addressed to it from outside every
121
+ * thread finds it. Owner first: a reply that arrives before the work knows
122
+ * its thread is still filed here. The directory is kept relative to the
123
+ * bot's, so a state root moved or mounted elsewhere still finds it.
124
+ */
125
+ record(workId, thread) {
126
+ const owner = { directory: path.relative(this.shared(), this.options.directory), identity: this.identity };
127
+ const key = `${this.chatId()}-${thread}`;
128
+ createOnce(path.join(this.shared(), "owners", `${key}.json`), owner, this.shared());
129
+ // Created once: if another process recorded a thread for this work first, that one is the work's.
130
+ createOnce(path.join(this.threadsDirectory(), `${fileNameOf(workId)}.json`), { topic: this.topics() ? Number(thread) : thread, workId }, this.options.directory);
131
+ // Replaced, not created once: a project's phases open one work id each, and
132
+ // the phase that opened it last is the one an addressed message reaches.
133
+ durableDirectory(path.join(this.shared(), "works"), this.shared());
134
+ writeJson(path.join(this.shared(), "works", `${this.chatId()}-${fileNameOf(workId)}.json`), { thread: this.topicOfWork(workId), workId });
135
+ return `${this.chatId()}-${this.topicOfWork(workId)}`;
136
+ }
137
+ async post(thread, message) {
138
+ if (message.files?.length)
139
+ throw new Error("@amykit/plugin-telegram does not upload files; post their paths as text");
140
+ const topic = this.threadOf(thread);
141
+ // Works share the chat without topics, so each message says whose it is.
142
+ const text = this.topics() ? message.text : `${this.workOfThread(topic) ?? topic}: ${message.text}`;
143
+ return message.ask ? this.postAsk(topic, text, message.ask) : this.sendParts(topic, text);
144
+ }
145
+ async postAsk(topic, text, ask) {
146
+ const usable = validAsk(ask);
147
+ const body = [text === ask.prompt ? "" : text, ask.prompt, usable ? "" : numberedAnswers(ask)].filter(Boolean).join("\n");
148
+ if (!usable)
149
+ return this.sendParts(topic, body);
150
+ // Under the bot's shared lock, as a poll reads callbacks: a keyboard is
151
+ // never visible to another process before its ask is recorded, and a
152
+ // second post of the same ask finds the first one and sends nothing.
153
+ return this.held(async () => {
154
+ const recorded = readJson(this.askFile(topic, ask.id));
155
+ if (recorded)
156
+ return String(recorded.messageId);
157
+ return this.sendParts(topic, body, ask);
158
+ });
159
+ }
160
+ async sendParts(topic, text, ask) {
161
+ let sent = "";
162
+ const rendered = parts(text);
163
+ for (const [index, part] of rendered.entries()) {
164
+ const result = await this.api.call("sendMessage", {
165
+ chat_id: this.chatId(), ...this.inThread(topic), text: part,
166
+ ...(ask && index === rendered.length - 1 ? { reply_markup: keyboardFor(ask, askKey(ask.id), []) } : {}),
167
+ });
168
+ sent = String(result.message_id);
169
+ createOnce(path.join(this.options.directory, "posted", topic, `${result.message_id}.json`), { at: new Date().toISOString() }, this.options.directory);
170
+ // Filed under its work for the whole bot, so a reply quoting it reaches that work.
171
+ if (!this.topics())
172
+ createOnce(path.join(this.shared(), "filed", `${this.chatId()}-${result.message_id}.json`), { thread: topic }, this.shared());
173
+ if (ask && index === rendered.length - 1) {
174
+ createOnce(this.askFile(topic, ask.id), { ask, messageId: result.message_id, at: new Date().toISOString(), selected: [], callbacks: [] }, this.options.directory);
175
+ }
176
+ }
177
+ return sent;
178
+ }
179
+ async replies(thread, since) {
180
+ const after = instantToMicros(since);
181
+ if (Number.isNaN(after))
182
+ throw new Error(`replies since ${since}: not an instant`);
183
+ const topic = this.threadOf(thread);
184
+ await this.held(async () => {
185
+ this.adopt(`${this.chatId()}-${topic}`);
186
+ await this.fileUpdates();
187
+ });
188
+ // Telegram dates to the second; ids in one chat only grow. A reply numbered below the last message amy
189
+ // posted by `since` was written before it, whatever second they share.
190
+ const before = this.postedBy(topic, after);
191
+ const replies = [];
192
+ for (const filed of filedUnder(this.inbox(this.options.directory, this.identity, topic))) {
193
+ if (instantToMicros(filed.at) <= after || filed.messageId < before)
194
+ continue;
195
+ const { paths, skipped } = await this.downloadAll(topic, filed.files);
196
+ replies.push({ at: filed.at, author: filed.author, text: [filed.text, ...skipped].filter(Boolean).join("\n"), files: paths, ...(filed.kind ? { kind: filed.kind } : {}) });
197
+ }
198
+ for (const asked of this.askedUnder(topic)) {
199
+ const accepted = asked.accepted;
200
+ if (!accepted || instantToMicros(accepted.at) <= after)
201
+ continue;
202
+ replies.push({ at: accepted.at, author: this.operator, text: asked.ask.prompt, files: [], kind: "decision", answer: { ask: asked.ask.id, chosen: accepted.chosen } });
203
+ }
204
+ return replies.sort((a, b) => a.at.localeCompare(b.at));
205
+ }
206
+ /**
207
+ * Whether this bot can hold the conversation, asked of Telegram itself:
208
+ * the token, the bot's topic mode, a private chat the operator started,
209
+ * and nobody else reading its updates.
210
+ */
211
+ async checks() {
212
+ let me;
213
+ try {
214
+ me = await this.api.call("getMe");
215
+ }
216
+ catch (error) {
217
+ return [{ label: "telegram bot", ok: false, detail: messageOf(error) }];
218
+ }
219
+ const name = `@${me.username ?? me.id}`;
220
+ const checks = [
221
+ !this.topics()
222
+ ? { label: "telegram bot", ok: true, detail: `${name}, with every work in the private chat, addressed by a quote or its id` }
223
+ : me.has_topics_enabled === true
224
+ ? { label: "telegram bot", ok: true, detail: `${name}, with topics in private chats` }
225
+ : { label: "telegram bot", ok: false, detail: `${name} has topic mode off; turn on topics for it in @BotFather, so each piece of work gets its own topic, or set topics: false` },
226
+ ];
227
+ try {
228
+ const chat = await this.api.call("getChat", { chat_id: this.chatId() });
229
+ checks.push(chat.type === "private"
230
+ ? { label: "telegram chat", ok: true, detail: `private chat with ${this.operator}` }
231
+ : { label: "telegram chat", ok: false, detail: `${this.operator} is a ${chat.type ?? "chat"}, not a person; set operator to your own user id` });
232
+ }
233
+ catch (error) {
234
+ checks.push({ label: "telegram chat", ok: false, detail: `${this.operator} has no chat with ${name}; open ${name} in Telegram and send /start (${messageOf(error)})` });
235
+ }
236
+ try {
237
+ // Not confirming anything: no offset, so nothing filed is skipped.
238
+ await this.api.call("getUpdates", { limit: 1, timeout: 0, allowed_updates: ["message", "callback_query"] });
239
+ checks.push({ label: "telegram updates", ok: true, detail: "read by amy alone" });
240
+ }
241
+ catch (error) {
242
+ const conflict = error instanceof BotApiRefusal && error.code === 409;
243
+ checks.push({
244
+ label: "telegram updates",
245
+ ok: false,
246
+ detail: conflict
247
+ ? `${name}'s updates are read by something else (409 Conflict: ${error.description}); Telegram gives them to one reader, so amy needs a bot of its own`
248
+ : messageOf(error),
249
+ });
250
+ }
251
+ return checks;
252
+ }
253
+ /**
254
+ * The messages in the private chat that reached no work, oldest first,
255
+ * until they are settled. Telegram is read for them first, so the chat is
256
+ * read all the time and not only when a work is looked at (S20).
257
+ */
258
+ async chat() {
259
+ await this.held(() => this.fileUpdates());
260
+ const directory = path.join(this.shared(), "chat", String(this.chatId()));
261
+ return jsonFilesIn(directory)
262
+ .flatMap((name) => {
263
+ const filed = readJson(path.join(directory, name), true);
264
+ // Only the operator's: a message filed for another reader of the same bot is not this one's to act on.
265
+ return filed && filed.author === this.operator ? [filed] : [];
266
+ })
267
+ .sort((a, b) => a.at.localeCompare(b.at));
268
+ }
269
+ async settle(id) {
270
+ const message = /^telegram:(-?\d+):(\d+)$/.exec(id);
271
+ if (!message)
272
+ throw new Error(`${id} is not a message this chat filed`);
273
+ fs.rmSync(path.join(this.shared(), "chat", message[1], `${message[2]}.json`), { force: true });
274
+ }
275
+ /** Says something in the private chat, outside every topic, in as many messages as it takes. */
276
+ async say(place, text) {
277
+ let sent = "";
278
+ for (const part of parts(text)) {
279
+ const result = await this.api.call("sendMessage", { chat_id: Number(place), text: part });
280
+ sent = String(result.message_id);
281
+ // A reply quoting what amy said in the chat continues the conversation; it is not a work's.
282
+ createOnce(path.join(this.shared(), "said", `${place}-${sent}.json`), {}, this.shared());
283
+ }
284
+ return sent;
285
+ }
286
+ /** Replaces what a message amy said reads, as much of it as one message holds. */
287
+ async revise(place, id, text) {
288
+ try {
289
+ await this.api.call("editMessageText", { chat_id: Number(place), message_id: Number(id), text: parts(text)[0] });
290
+ }
291
+ catch (error) {
292
+ // The same text again, or a message gone: there is nothing to redraw.
293
+ if (!(error instanceof BotApiRefusal && /message is not modified|message to edit not found/i.test(error.description)))
294
+ throw error;
295
+ }
296
+ }
297
+ chatId() {
298
+ return Number(this.operator);
299
+ }
300
+ topics() {
301
+ return this.options.topics !== false;
302
+ }
303
+ /**
304
+ * Where each work's thread is remembered, one place per mode: a topic is no
305
+ * thread in a chat without topics, nor the other way round, so turning
306
+ * topics on or off opens each work afresh rather than reusing an id the
307
+ * new mode cannot post to.
308
+ */
309
+ threadsDirectory() {
310
+ return path.join(this.options.directory, this.topics() ? "topics" : "addressed");
311
+ }
312
+ /** Where a work's message goes: its topic, or the chat itself when works share it. */
313
+ inThread(thread) {
314
+ return this.topics() ? { message_thread_id: Number(thread) } : {};
315
+ }
316
+ threadOf(thread) {
317
+ if (this.topics() ? !/^\d+$/.test(thread.id) : !/^w[0-9a-f]{16}$/.test(thread.id)) {
318
+ throw new Error(`${thread.id} is not a telegram ${this.topics() ? "topic" : "work thread"}`);
319
+ }
320
+ return thread.id;
321
+ }
322
+ workOfThread(thread) {
323
+ for (const name of jsonFilesIn(this.threadsDirectory())) {
324
+ const known = readJson(path.join(this.threadsDirectory(), name), true);
325
+ if (known && String(known.topic) === thread)
326
+ return known.workId;
327
+ }
328
+ return undefined;
329
+ }
330
+ shared() {
331
+ return this.options.shared ?? this.options.directory;
332
+ }
333
+ inbox(directory, identity, topic) {
334
+ return path.join(directory, `inbox-${identity}`, topic);
335
+ }
336
+ /**
337
+ * Reads every update Telegram holds and files each of the operator's
338
+ * messages under the profile owning its topic. A topic no profile owns any
339
+ * more, its profile removed or made again, is skipped. The offset moves
340
+ * only once what it confirms is durably on disk.
341
+ */
342
+ async fileUpdates() {
343
+ this.pruneAbandoned();
344
+ for (;;) {
345
+ const offset = this.offset();
346
+ const updates = await this.api.call("getUpdates", {
347
+ ...(offset === undefined ? {} : { offset }),
348
+ timeout: 0,
349
+ limit: 100,
350
+ allowed_updates: ["message", "callback_query"],
351
+ });
352
+ if (updates.length === 0)
353
+ return;
354
+ for (const update of updates) {
355
+ const message = update.message;
356
+ if (message && isAnAnswer(message))
357
+ this.file(message, String(message.message_thread_id));
358
+ else if (message && isOutsideEveryTopic(message))
359
+ this.fileOutside(message);
360
+ if (update.callback_query)
361
+ await this.fileCallback(update.callback_query);
362
+ }
363
+ this.confirm(Math.max(...updates.map((update) => update.update_id)) + 1);
364
+ }
365
+ }
366
+ /** Handles a button only after resolving its real topic owner from the callback message. */
367
+ async fileCallback(callback) {
368
+ const context = this.callbackContext(callback);
369
+ // The callback's private chat owns the profile; a shared bot must not let
370
+ // the first polling profile discard another operator's press.
371
+ const ownsChat = context?.message.chat.id === callback.from.id;
372
+ const text = context && ownsChat ? await this.applyCallback(context, callback.data, callback.id) : "this question is no longer open";
373
+ try {
374
+ await this.api.call("answerCallbackQuery", { callback_query_id: callback.id, ...(text ? { text } : {}) });
375
+ }
376
+ catch (error) {
377
+ // Telegram expires callback acknowledgements quickly. The answer above
378
+ // is already durable, so retrying an expired acknowledgement must not
379
+ // pin the shared update offset forever.
380
+ if (!(error instanceof BotApiRefusal && error.method === "answerCallbackQuery" && error.code === 400))
381
+ throw error;
382
+ }
383
+ }
384
+ callbackContext(callback) {
385
+ const message = callback.message;
386
+ if (!message)
387
+ return undefined;
388
+ const topic = this.topics()
389
+ ? message.message_thread_id === undefined ? undefined : String(message.message_thread_id)
390
+ : readJson(path.join(this.shared(), "filed", `${message.chat.id}-${message.message_id}.json`))?.thread;
391
+ if (topic === undefined)
392
+ return undefined;
393
+ const owner = readJson(path.join(this.shared(), "owners", `${message.chat.id}-${topic}.json`));
394
+ const place = owner ? this.inboxOf(owner, topic) : undefined;
395
+ return place ? { message, topic, directory: place.directory } : undefined;
396
+ }
397
+ async applyCallback(context, data, callbackId) {
398
+ const parsed = data ? parseCallback(data) : undefined;
399
+ const state = parsed ? this.askByKey(context.directory, context.topic, parsed.key) : undefined;
400
+ if (!parsed || !state || state.messageId !== context.message.message_id)
401
+ return "this question is no longer open";
402
+ // Telegram delivers a callback again until it is confirmed. Its choice
403
+ // is already saved; what may not be is the keyboard, if showing it
404
+ // failed the first time, so draw it again from what was saved.
405
+ if (state.callbacks?.includes(callbackId))
406
+ return this.redrawKeyboard(context.message, state, parsed.key);
407
+ if (state.accepted)
408
+ return this.acknowledgeAccepted(context, state, callbackId);
409
+ if (parsed.send)
410
+ return this.sendChoices(context, state, callbackId);
411
+ if (parsed.index === undefined || parsed.index < 0 || parsed.index >= state.ask.answers.length)
412
+ return "this question is no longer open";
413
+ return this.choose(context, state, parsed.index, parsed.key, callbackId);
414
+ }
415
+ async sendChoices(context, state, callbackId) {
416
+ if (state.ask.form !== "choices")
417
+ return "this question is no longer open";
418
+ state.accepted = { at: new Date().toISOString(), chosen: state.selected.map((index) => state.ask.answers[index]).filter(Boolean) };
419
+ state.callbacks = [...(state.callbacks ?? []), callbackId];
420
+ this.writeAsk(context.directory, context.topic, state);
421
+ await this.closeKeyboard(context.message);
422
+ return undefined;
423
+ }
424
+ async choose(context, state, index, key, callbackId) {
425
+ if (state.ask.form !== "choices") {
426
+ state.accepted = { at: new Date().toISOString(), chosen: [state.ask.answers[index]] };
427
+ state.callbacks = [...(state.callbacks ?? []), callbackId];
428
+ this.writeAsk(context.directory, context.topic, state);
429
+ await this.closeKeyboard(context.message);
430
+ return undefined;
431
+ }
432
+ state.selected = state.selected.includes(index) ? state.selected.filter((selected) => selected !== index) : [...state.selected, index].sort((left, right) => left - right);
433
+ state.callbacks = [...(state.callbacks ?? []), callbackId];
434
+ this.writeAsk(context.directory, context.topic, state);
435
+ await this.api.call("editMessageReplyMarkup", { chat_id: context.message.chat.id, message_id: context.message.message_id, reply_markup: keyboardFor(state.ask, key, state.selected) });
436
+ return undefined;
437
+ }
438
+ async acknowledgeAccepted(context, state, callbackId) {
439
+ state.callbacks = [...(state.callbacks ?? []), callbackId];
440
+ if (!state.acknowledged) {
441
+ await this.api.call("sendMessage", { chat_id: context.message.chat.id, ...this.inThread(context.topic), text: `Amy already took ${state.ask.id}: ${state.accepted.chosen.join(", ")}.` });
442
+ state.acknowledged = true;
443
+ }
444
+ this.writeAsk(context.directory, context.topic, state);
445
+ return "this question is no longer open";
446
+ }
447
+ async redrawKeyboard(message, state, key) {
448
+ const markup = state.accepted ? { inline_keyboard: [] } : keyboardFor(state.ask, key, state.selected);
449
+ try {
450
+ await this.api.call("editMessageReplyMarkup", { chat_id: message.chat.id, message_id: message.message_id, reply_markup: markup });
451
+ }
452
+ catch (error) {
453
+ // Already showing what was saved (the first edit landed after all), or
454
+ // the message is gone: either way there is nothing left to draw, and
455
+ // the saved answer must not hold the batch back.
456
+ if (!(error instanceof BotApiRefusal && /message is not modified|message to edit not found/i.test(error.description)))
457
+ throw error;
458
+ }
459
+ return undefined;
460
+ }
461
+ async closeKeyboard(message) {
462
+ await this.api.call("editMessageReplyMarkup", { chat_id: message.chat.id, message_id: message.message_id, reply_markup: { inline_keyboard: [] } });
463
+ }
464
+ askFile(topic, id) {
465
+ return path.join(this.options.directory, "asks", topic, `${askKey(id)}.json`);
466
+ }
467
+ askByKey(directory, topic, key) {
468
+ return readJson(path.join(directory, "asks", topic, `${key}.json`));
469
+ }
470
+ writeAsk(directory, topic, state) {
471
+ writeJson(path.join(directory, "asks", topic, `${askKey(state.ask.id)}.json`), state);
472
+ }
473
+ askedUnder(topic) {
474
+ const directory = path.join(this.options.directory, "asks", topic);
475
+ try {
476
+ return fs.readdirSync(directory).filter((name) => name.endsWith(".json")).flatMap((name) => {
477
+ const state = readJson(path.join(directory, name));
478
+ return state ? [state] : [];
479
+ });
480
+ }
481
+ catch (error) {
482
+ if (error.code === "ENOENT")
483
+ return [];
484
+ throw error;
485
+ }
486
+ }
487
+ /**
488
+ * Files one message in its owner's inbox for that owner's identity, as a
489
+ * file of its own. The inbox is named for the identity, so a write racing a
490
+ * profile's removal lands where no later profile at that path reads.
491
+ */
492
+ file(received, topic, said) {
493
+ // Addressed from the chat, it is filed as what it said to the work, the address taken off.
494
+ const message = said === undefined ? received : { ...received, text: said, caption: undefined };
495
+ // Keyed by chat: one bot's topics in two operators' chats are two owners.
496
+ const key = `${message.chat.id}-${topic}`;
497
+ const ownerFile = path.join(this.shared(), "owners", `${key}.json`);
498
+ let owner = readJson(ownerFile);
499
+ if (!owner) {
500
+ // A topic being opened right now has no owner yet: kept until it has, never confirmed away.
501
+ if (this.openingIn(message.chat.id)) {
502
+ createOnce(path.join(this.shared(), "unowned", key, `${message.message_id}.json`), message, this.shared());
503
+ return;
504
+ }
505
+ // No opening now, but one may have finished since the owner was looked up: its owner says so.
506
+ owner = readJson(ownerFile);
507
+ // Anything else in a chat nobody is opening a topic in is nobody's, and is not kept.
508
+ if (!owner)
509
+ return;
510
+ }
511
+ const place = this.inboxOf(owner, topic);
512
+ if (!place || filedAlready(place.inbox, message.message_id))
513
+ return;
514
+ const { directory, inbox } = place;
515
+ const { kind, text } = markedBy(message.text ?? message.caption ?? "");
516
+ const filed = { messageId: message.message_id, at: instantOf(message), author: String(message.from.id), text, files: filesOf(message), ...(kind ? { kind } : {}) };
517
+ // Created once: a late reader filing the same message finds it there. Only when the file that is
518
+ // there does not parse, somebody having damaged it, is the reply filed beside it; nothing is deleted.
519
+ const target = path.join(inbox, `${message.message_id}.json`);
520
+ if (!createOnce(target, filed, directory) && readJson(target, true) === undefined) {
521
+ createOnce(path.join(inbox, `${message.message_id}-again.json`), filed, directory);
522
+ }
523
+ }
524
+ /**
525
+ * Where a topic's replies are filed for its owner, if that owner's directory
526
+ * still carries the identity that opened the topic; made if needed.
527
+ */
528
+ inboxOf(owner, topic) {
529
+ const directory = path.resolve(this.shared(), owner.directory);
530
+ if (readIdentity(directory) !== owner.identity)
531
+ return undefined;
532
+ const inbox = this.inbox(directory, owner.identity, topic);
533
+ return ensureInbox(directory, inbox) ? { directory, inbox } : undefined;
534
+ }
535
+ /** The id of the last message amy posted in a topic by an instant, or 0 when none is known. */
536
+ postedBy(topic, micros) {
537
+ const directory = path.join(this.options.directory, "posted", topic);
538
+ let names;
539
+ try {
540
+ names = fs.readdirSync(directory).filter((name) => name.endsWith(".json"));
541
+ }
542
+ catch (error) {
543
+ if (error.code === "ENOENT")
544
+ return 0;
545
+ throw error;
546
+ }
547
+ return names.reduce((last, name) => {
548
+ const posted = readJson(path.join(directory, name), true);
549
+ const id = Number.parseInt(name, 10);
550
+ return posted && instantToMicros(posted.at) <= micros && id > last ? id : last;
551
+ }, 0);
552
+ }
553
+ /** Files what arrived in a topic before its owner was recorded, now that it is. */
554
+ adopt(key) {
555
+ const waiting = path.join(this.shared(), "unowned", key);
556
+ let names;
557
+ try {
558
+ names = fs.readdirSync(waiting).filter((name) => name.endsWith(".json"));
559
+ }
560
+ catch (error) {
561
+ if (error.code === "ENOENT")
562
+ return;
563
+ throw error;
564
+ }
565
+ // In the order the operator wrote them, which a directory listing does not promise.
566
+ for (const name of names.sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10))) {
567
+ const message = readJson(path.join(waiting, name), true);
568
+ if (message)
569
+ this.file(message, key.slice(key.indexOf("-", 1) + 1));
570
+ fs.rmSync(path.join(waiting, name), { force: true });
571
+ }
572
+ }
573
+ /** Whether a topic is being opened in this chat now, by any process: an opening older than a day was abandoned. */
574
+ openingIn(chat) {
575
+ const openings = path.join(this.shared(), "opening", String(chat));
576
+ let names;
577
+ try {
578
+ names = fs.readdirSync(openings);
579
+ }
580
+ catch (error) {
581
+ if (error.code === "ENOENT")
582
+ return false;
583
+ throw error;
584
+ }
585
+ return names.some((name) => {
586
+ const since = mtimeOf(path.join(openings, name));
587
+ return since !== undefined && Date.now() - since < ABANDONED_MS;
588
+ });
589
+ }
590
+ /** Replies kept for a topic whose opening was abandoned a day ago, and the openings themselves, go. */
591
+ pruneAbandoned() {
592
+ for (const area of ["unowned", "opening"]) {
593
+ const root = path.join(this.shared(), area);
594
+ let entries;
595
+ try {
596
+ entries = fs.readdirSync(root);
597
+ }
598
+ catch (error) {
599
+ if (error.code === "ENOENT")
600
+ continue;
601
+ throw error;
602
+ }
603
+ for (const entry of entries) {
604
+ const directory = path.join(root, entry);
605
+ const newest = Math.max(0, ...fs.readdirSync(directory).map((name) => mtimeOf(path.join(directory, name)) ?? 0));
606
+ const owned = area === "unowned" && fs.existsSync(path.join(this.shared(), "owners", `${entry}.json`));
607
+ if (!owned && Date.now() - newest > ABANDONED_MS)
608
+ fs.rmSync(directory, { recursive: true, force: true });
609
+ }
610
+ }
611
+ }
612
+ /**
613
+ * The offset to confirm with, unless it has outlived Telegram's update
614
+ * ids: then the earliest update Telegram still holds is asked for instead,
615
+ * and the ones already filed are filed once.
616
+ */
617
+ offset() {
618
+ const stored = readJson(path.join(this.shared(), "offset.json"));
619
+ if (stored?.offset === undefined)
620
+ return undefined;
621
+ if (stored.receivedAt === undefined || Date.now() - stored.receivedAt > OFFSET_LIFETIME_MS)
622
+ return undefined;
623
+ return stored.offset;
624
+ }
625
+ /** Moves the offset forward, never back, so a late reader cannot unconfirm what another confirmed. */
626
+ confirm(next) {
627
+ const file = path.join(this.shared(), "offset.json");
628
+ const stored = readJson(file);
629
+ const expired = stored?.receivedAt === undefined || Date.now() - stored.receivedAt > OFFSET_LIFETIME_MS;
630
+ if (!expired && (stored?.offset ?? 0) > next)
631
+ return;
632
+ writeJson(file, { offset: next, receivedAt: Date.now() });
633
+ }
634
+ /** The files as local paths, and a line naming each one too large to fetch. */
635
+ async downloadAll(topic, files) {
636
+ const limit = this.options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
637
+ const paths = [];
638
+ const skipped = [];
639
+ for (const one of files) {
640
+ const target = path.join(this.options.directory, "files", topic, safeName(one.unique), safeName(one.name));
641
+ const kept = (one.size ?? 0) <= limit && (fs.existsSync(target) || (await this.download(one, target, limit)));
642
+ if (kept)
643
+ paths.push(target);
644
+ else
645
+ skipped.push(`[${one.name} was not downloaded: it is larger than ${limit} bytes]`);
646
+ }
647
+ return { paths, skipped };
648
+ }
649
+ /**
650
+ * Fetches one file to its place, or answers false when it is too large: by
651
+ * what getFile says, by Telegram refusing it as too big when the message did
652
+ * not say its size, or by its bytes as they arrive. A local Bot API server
653
+ * answers with a path on its own disk, which is read where it lies.
654
+ */
655
+ async download(one, target, limit) {
656
+ let found;
657
+ try {
658
+ found = await this.api.call("getFile", { file_id: one.id });
659
+ }
660
+ catch (error) {
661
+ if (error instanceof BotApiRefusal && /file is too big/i.test(error.description))
662
+ return false;
663
+ throw error;
664
+ }
665
+ if ((found.file_size ?? 0) > limit)
666
+ return false;
667
+ if (!found.file_path)
668
+ throw new Error(`telegram gave no path to download ${one.name}; it is asked for again on the next look`);
669
+ let bytes;
670
+ try {
671
+ bytes = path.isAbsolute(found.file_path) ? localFile(found.file_path, limit) : await this.api.download(found.file_path, limit);
672
+ }
673
+ catch (error) {
674
+ if (error instanceof TooLarge)
675
+ return false;
676
+ throw error;
677
+ }
678
+ // Within the profile's own directory, which is not made again if it was removed meanwhile.
679
+ if (readIdentity(this.options.directory) !== this.identity)
680
+ throw new Error(`${this.options.directory} was removed while its file was being fetched`);
681
+ durableDirectory(path.dirname(target), this.options.directory);
682
+ // Staged under a short name of its own and renamed, so a path that exists is always a whole file.
683
+ const partial = path.join(path.dirname(target), `.${randomUUID()}.partial`);
684
+ fs.writeFileSync(partial, bytes, { flag: "wx", mode: 0o600 });
685
+ fs.renameSync(partial, target);
686
+ return true;
687
+ }
688
+ topicOfWork(workId) {
689
+ const topic = readJson(path.join(this.threadsDirectory(), `${fileNameOf(workId)}.json`))?.topic;
690
+ return topic === undefined ? undefined : String(topic);
691
+ }
692
+ /**
693
+ * Files a message written outside every topic: under the work it is
694
+ * addressed to — a quote of amy's message for it, or its id at the start —
695
+ * and in the chat otherwise (S42). The rule is the core's; this only says
696
+ * what amy filed where (S16).
697
+ */
698
+ fileOutside(message) {
699
+ const chat = message.chat.id;
700
+ const quoted = message.reply_to_message?.message_id;
701
+ // Quoting what amy said in the chat continues that conversation.
702
+ const quotes = quoted === undefined || fs.existsSync(path.join(this.shared(), "said", `${chat}-${quoted}.json`)) ? undefined : String(quoted);
703
+ const routed = addressed({ text: message.text ?? message.caption ?? "", ...(quotes === undefined ? {} : { quotes }) }, {
704
+ threadOfMessage: (id) => readJson(path.join(this.shared(), "filed", `${chat}-${id}.json`), true)?.thread,
705
+ threadOfWork: (workId) => readJson(path.join(this.shared(), "works", `${chat}-${fileNameOf(workId)}.json`), true)?.thread,
706
+ });
707
+ if (routed.to === "work") {
708
+ this.file(message, routed.thread, routed.text);
709
+ return;
710
+ }
711
+ if (!(message.text ?? "").trim())
712
+ return;
713
+ const filed = {
714
+ id: `telegram:${chat}:${message.message_id}`,
715
+ at: instantOf(message),
716
+ author: String(message.from.id),
717
+ text: routed.text,
718
+ place: String(chat),
719
+ ...(routed.unaddressed ? { unaddressed: true } : {}),
720
+ };
721
+ createOnce(path.join(this.shared(), "chat", String(chat), `${message.message_id}.json`), filed, this.shared());
722
+ }
723
+ /**
724
+ * Runs `work` holding the bot, so one process reads its updates or opens a
725
+ * topic at a time: a hold not renewed within its lease is taken over. Being
726
+ * passed over costs a 409 or a second topic, never a reply, since nothing
727
+ * filed is ever rewritten.
728
+ */
729
+ async held(work) {
730
+ const lock = path.join(this.shared(), "lock");
731
+ const lease = this.options.leaseMs ?? 60_000;
732
+ const deadline = Date.now() + (this.options.lockWaitMs ?? 120_000);
733
+ const me = randomUUID();
734
+ while (!claim(lock, me)) {
735
+ const since = mtimeOf(lock);
736
+ if (since !== undefined && Date.now() - since > lease) {
737
+ ignoringGone(() => fs.renameSync(lock, path.join(this.shared(), `.${randomUUID()}.stale`)));
738
+ removeStale(this.shared());
739
+ continue;
740
+ }
741
+ if (Date.now() > deadline)
742
+ throw new Error(`${this.shared()} is held by another process; waited for it and gave up`);
743
+ await new Promise((resolve) => setTimeout(resolve, 50));
744
+ }
745
+ const renew = setInterval(() => ignoringGone(() => ownedBy(lock, me) && fs.utimesSync(lock, new Date(), new Date())), Math.max(10, lease / 3));
746
+ try {
747
+ return await work();
748
+ }
749
+ finally {
750
+ clearInterval(renew);
751
+ if (ownedBy(lock, me))
752
+ fs.rmSync(lock, { recursive: true, force: true });
753
+ }
754
+ }
755
+ }
756
+ /** A compact, opaque ask id for callback_data; Telegram permits at most 64 bytes. */
757
+ function askKey(id) {
758
+ return createHash("sha256").update(id).digest("hex").slice(0, 24);
759
+ }
760
+ function callbackData(key, action) {
761
+ return `amy:${key}:${action}`;
762
+ }
763
+ function parseCallback(data) {
764
+ const match = /^amy:([0-9a-f]{24}):(send|\d+)$/.exec(data);
765
+ if (!match)
766
+ return undefined;
767
+ return match[2] === "send" ? { key: match[1], send: true } : { key: match[1], index: Number(match[2]), send: false };
768
+ }
769
+ function validAsk(ask) {
770
+ return ask.answers.length >= 2 && ask.answers.length <= 10 && ask.answers.every((answer) => answer.trim().length > 0 && answer.length <= 64) && new Set(ask.answers).size === ask.answers.length;
771
+ }
772
+ function numberedAnswers(ask) {
773
+ return ask.answers.map((answer, index) => `${index + 1}. ${answer}`).join("\n");
774
+ }
775
+ function keyboardFor(ask, key, selected) {
776
+ const answers = ask.answers.map((answer, index) => ({ text: ask.form === "choices" && selected.includes(index) ? `✓ ${answer}` : answer, callback_data: callbackData(key, String(index)) }));
777
+ return { inline_keyboard: ask.form === "choices" ? [...answers.map((answer) => [answer]), [{ text: "Send", callback_data: callbackData(key, "send") }]] : answers.map((answer) => [answer]) };
778
+ }
779
+ /**
780
+ * A message that answers: written by the person whose private chat it is (a
781
+ * bot's own messages excluded), in a topic, and saying something. A topic
782
+ * renamed, a sticker or a voice note carries no answer, and an empty reply
783
+ * would read as one. Whose topic it is the owners decide, by chat.
784
+ */
785
+ function isAnAnswer(message) {
786
+ if (message.from?.is_bot)
787
+ return false;
788
+ if (message.chat.type !== "private" || message.from?.id !== message.chat.id)
789
+ return false;
790
+ if (message.is_topic_message !== true || message.message_thread_id === undefined)
791
+ return false;
792
+ return Boolean(message.text?.trim() || message.caption?.trim() || message.document || message.photo?.length);
793
+ }
794
+ /**
795
+ * A message in the operator's private chat, outside every topic, from the
796
+ * operator: a work addressed from the chat, an ask, or an action. Anyone
797
+ * else answers nothing (S22).
798
+ */
799
+ function isOutsideEveryTopic(message) {
800
+ if (message.from?.is_bot)
801
+ return false;
802
+ if (message.chat.type !== "private" || message.from?.id !== message.chat.id)
803
+ return false;
804
+ return message.is_topic_message !== true && Boolean(message.text?.trim() || message.caption?.trim());
805
+ }
806
+ /** The JSON files in a directory, none when it is not there. */
807
+ function jsonFilesIn(directory) {
808
+ try {
809
+ return fs.readdirSync(directory).filter((name) => name.endsWith(".json"));
810
+ }
811
+ catch (error) {
812
+ if (error.code === "ENOENT")
813
+ return [];
814
+ throw error;
815
+ }
816
+ }
817
+ /** Takes the hold if nobody has it, writing who holds it inside. */
818
+ function claim(lock, me) {
819
+ try {
820
+ fs.mkdirSync(lock);
821
+ }
822
+ catch (error) {
823
+ if (error.code === "EEXIST")
824
+ return false;
825
+ throw error;
826
+ }
827
+ fs.writeFileSync(path.join(lock, "owner"), me, { mode: 0o600 });
828
+ return true;
829
+ }
830
+ function ownedBy(lock, me) {
831
+ try {
832
+ return fs.readFileSync(path.join(lock, "owner"), "utf-8") === me;
833
+ }
834
+ catch {
835
+ return false;
836
+ }
837
+ }
838
+ /** Removes the holds taken over and moved aside. */
839
+ function removeStale(directory) {
840
+ for (const name of fs.readdirSync(directory)) {
841
+ if (name.endsWith(".stale"))
842
+ fs.rmSync(path.join(directory, name), { recursive: true, force: true });
843
+ }
844
+ }
845
+ /** The files a message carries: its document, and its photo at the largest size on screen, which every size states. */
846
+ function filesOf(message) {
847
+ const largest = message.photo?.length ? [...message.photo].sort((a, b) => b.width * b.height - a.width * a.height)[0] : undefined;
848
+ return [
849
+ ...(message.document ? [{ id: message.document.file_id, unique: message.document.file_unique_id, name: message.document.file_name ?? message.document.file_unique_id, size: message.document.file_size }] : []),
850
+ ...(largest ? [{ id: largest.file_id, unique: largest.file_unique_id, name: `${largest.file_unique_id}.jpg`, size: largest.file_size }] : []),
851
+ ];
852
+ }
853
+ /**
854
+ * When a reply was written, to the microsecond, from what Telegram says: the
855
+ * end of its second, so none falls before a question asked earlier in that
856
+ * second, and then its message id, so two replies in one second keep the
857
+ * order they were written in. Derived from the message alone, so any reader,
858
+ * at any time, files it with the same instant.
859
+ */
860
+ function instantOf(message) {
861
+ const end = new Date(message.date * 1000 + 999).toISOString();
862
+ return end.replace(/Z$/, `${String(1 + (message.message_id % 999)).padStart(3, "0")}Z`);
863
+ }
864
+ /** An instant to the microsecond: the digits past the millisecond are read too. */
865
+ function instantToMicros(instant) {
866
+ const ms = Date.parse(instant);
867
+ if (Number.isNaN(ms))
868
+ return Number.NaN;
869
+ const extra = /\.\d{3}(\d{1,3})Z$/.exec(instant)?.[1] ?? "";
870
+ return ms * 1000 + Number(extra.padEnd(3, "0"));
871
+ }
872
+ /** An opening done with, and the directories it leaves empty; one still in use by another opening stays. */
873
+ function closeOpening(opening) {
874
+ fs.rmSync(opening, { force: true });
875
+ for (const directory of [path.dirname(opening), path.dirname(path.dirname(opening))]) {
876
+ try {
877
+ fs.rmdirSync(directory);
878
+ }
879
+ catch {
880
+ return;
881
+ }
882
+ }
883
+ }
884
+ /** Whether a message is filed under the topic already, readably. */
885
+ function filedAlready(inbox, messageId) {
886
+ return [`${messageId}.json`, `${messageId}-again.json`].some((name) => readJson(path.join(inbox, name), true) !== undefined);
887
+ }
888
+ /**
889
+ * What the operator marked a reply as, by a command at its start, taken off
890
+ * the text: /decide (or ✅) is the decision that moves the work, /discuss (or
891
+ * 💬) a turn in a discussion. Unmarked, it carries no kind, and the
892
+ * workflow's policy reads it.
893
+ */
894
+ function markedBy(text) {
895
+ const trimmed = text.trimStart();
896
+ const marks = [["/decide", "decision"], ["✅", "decision"], ["/discuss", "discussion"], ["💬", "discussion"]];
897
+ for (const [mark, kind] of marks) {
898
+ if (!trimmed.startsWith(mark))
899
+ continue;
900
+ let rest = trimmed.slice(mark.length);
901
+ // A command may be addressed to the bot by name: /decide@amy_work_bot.
902
+ if (mark.startsWith("/") && rest.startsWith("@"))
903
+ rest = rest.slice(rest.search(/\s|$/));
904
+ if (rest !== "" && !/^\s/.test(rest))
905
+ continue;
906
+ return { kind, text: rest.trim() };
907
+ }
908
+ return { text };
909
+ }
910
+ /** Every reply filed under a topic, in order. A file that does not parse is skipped, and filed again. */
911
+ function filedUnder(inbox) {
912
+ let names;
913
+ try {
914
+ names = fs.readdirSync(inbox).filter((name) => name.endsWith(".json"));
915
+ }
916
+ catch (error) {
917
+ if (error.code === "ENOENT")
918
+ return [];
919
+ throw error;
920
+ }
921
+ return names
922
+ .flatMap((name) => {
923
+ const filed = readJson(path.join(inbox, name), true);
924
+ return filed ? [filed] : [];
925
+ })
926
+ .sort((a, b) => a.at.localeCompare(b.at) || a.messageId - b.messageId);
927
+ }
928
+ /**
929
+ * The topic's inbox, made if needed without making anything above it: a
930
+ * profile removed between the reader's look and this is not made again. Each
931
+ * new directory is flushed into its parent before anything is filed in it.
932
+ */
933
+ function ensureInbox(directory, inbox) {
934
+ for (const step of [path.dirname(inbox), inbox]) {
935
+ try {
936
+ fs.mkdirSync(step, { mode: 0o700 });
937
+ syncDirectory(path.dirname(step));
938
+ }
939
+ catch (error) {
940
+ const code = error.code;
941
+ if (code === "EEXIST")
942
+ continue;
943
+ if (code === "ENOENT")
944
+ return false;
945
+ throw error;
946
+ }
947
+ }
948
+ return fs.existsSync(directory);
949
+ }
950
+ /** The identity a profile directory carries, published whole and durable the first time it is used. */
951
+ function identityOf(directory) {
952
+ const known = readIdentity(directory);
953
+ if (known)
954
+ return known;
955
+ const made = randomUUID();
956
+ const staged = path.join(directory, `.${randomUUID()}.partial`);
957
+ const handle = fs.openSync(staged, "wx", 0o600);
958
+ try {
959
+ fs.writeFileSync(handle, made);
960
+ fs.fsyncSync(handle);
961
+ }
962
+ finally {
963
+ fs.closeSync(handle);
964
+ }
965
+ try {
966
+ // A link is created whole or not at all, so nobody reads half an identity.
967
+ fs.linkSync(staged, path.join(directory, "identity"));
968
+ }
969
+ catch (error) {
970
+ if (error.code !== "EEXIST")
971
+ throw error;
972
+ }
973
+ finally {
974
+ fs.rmSync(staged, { force: true });
975
+ }
976
+ syncDirectory(directory);
977
+ return readIdentity(directory);
978
+ }
979
+ /** The directory's identity, or undefined when it has none because it is gone; any other failure is raised. */
980
+ function readIdentity(directory) {
981
+ try {
982
+ return fs.readFileSync(path.join(directory, "identity"), "utf-8");
983
+ }
984
+ catch (error) {
985
+ const code = error.code;
986
+ if (code === "ENOENT" || code === "ENOTDIR")
987
+ return undefined;
988
+ throw error;
989
+ }
990
+ }
991
+ /**
992
+ * A JSON file written once: whoever writes it first is the answer. Every
993
+ * directory made for it is flushed into its parent first, and none is made
994
+ * at or above `within`, which must already exist.
995
+ */
996
+ function createOnce(file, value, within) {
997
+ durableDirectory(path.dirname(file), within);
998
+ const staged = path.join(path.dirname(file), `.${randomUUID()}.partial`);
999
+ const handle = fs.openSync(staged, "wx", 0o600);
1000
+ try {
1001
+ fs.writeFileSync(handle, `${JSON.stringify(value)}\n`);
1002
+ fs.fsyncSync(handle);
1003
+ }
1004
+ finally {
1005
+ fs.closeSync(handle);
1006
+ }
1007
+ let published = true;
1008
+ try {
1009
+ fs.linkSync(staged, file);
1010
+ }
1011
+ catch (error) {
1012
+ if (error.code !== "EEXIST")
1013
+ throw error;
1014
+ published = false;
1015
+ }
1016
+ finally {
1017
+ fs.rmSync(staged, { force: true });
1018
+ }
1019
+ syncDirectory(path.dirname(file));
1020
+ return published;
1021
+ }
1022
+ /**
1023
+ * A file a local Bot API server keeps on a disk amy shares, read within the
1024
+ * limit. Its path is never said: a local server may put the token in it.
1025
+ */
1026
+ function localFile(file, limit) {
1027
+ let size;
1028
+ try {
1029
+ size = fs.statSync(file).size;
1030
+ }
1031
+ catch {
1032
+ throw new Error("the local Bot API server keeps this file where amy cannot read it; the server and amy must share that disk");
1033
+ }
1034
+ if (size > limit)
1035
+ throw new TooLarge(limit);
1036
+ try {
1037
+ return fs.readFileSync(file);
1038
+ }
1039
+ catch {
1040
+ throw new Error("the local Bot API server's file could not be read; the server and amy must share that disk");
1041
+ }
1042
+ }
1043
+ /** A Telegram id spelled as Telegram spells it: 004021 is 4021. */
1044
+ function normalisedId(id) {
1045
+ if (!/^\d+$/.test(id))
1046
+ throw new Error(`${id} is not a Telegram user id`);
1047
+ return String(Number(id));
1048
+ }
1049
+ /**
1050
+ * A work id as one file name on every system: a lowercase digest of the
1051
+ * whole id, so `build` and `Build` stay two on a case-insensitive disk, no id
1052
+ * is too long, and none is a name Windows reserves.
1053
+ */
1054
+ function fileNameOf(workId) {
1055
+ return createHash("sha256").update(workId).digest("hex");
1056
+ }
1057
+ /** The title, as much of it as a topic's name holds; the work id when there is none. */
1058
+ function topicName(title, workId) {
1059
+ const name = (title.trim() || workId).replace(/\s+/g, " ");
1060
+ return name.length > MAX_TOPIC_NAME_CHARS ? `${name.slice(0, whole(name, MAX_TOPIC_NAME_CHARS - 1))}…` : name;
1061
+ }
1062
+ /**
1063
+ * The text in messages Telegram accepts, split at a line where one falls in
1064
+ * reach, never inside a character, and never leaving an empty last part.
1065
+ */
1066
+ function parts(text) {
1067
+ const out = [];
1068
+ let rest = text;
1069
+ while (rest.length > MAX_MESSAGE_CHARS) {
1070
+ const cut = rest.lastIndexOf("\n", MAX_MESSAGE_CHARS);
1071
+ const at = cut > MAX_MESSAGE_CHARS / 2 ? cut : whole(rest, MAX_MESSAGE_CHARS);
1072
+ out.push(rest.slice(0, at));
1073
+ rest = rest.slice(at).replace(/^\n/, "");
1074
+ }
1075
+ if (rest !== "" || out.length === 0)
1076
+ out.push(rest);
1077
+ return out;
1078
+ }
1079
+ /** The cut at `at`, moved back one if it would split a surrogate pair. */
1080
+ function whole(text, at) {
1081
+ const code = text.charCodeAt(at - 1);
1082
+ return code >= 0xd800 && code <= 0xdbff ? at - 1 : at;
1083
+ }
1084
+ function mtimeOf(file) {
1085
+ try {
1086
+ return fs.statSync(file).mtimeMs;
1087
+ }
1088
+ catch {
1089
+ return undefined;
1090
+ }
1091
+ }
1092
+ function ignoringGone(act) {
1093
+ try {
1094
+ act();
1095
+ }
1096
+ catch (error) {
1097
+ if (error.code !== "ENOENT")
1098
+ throw error;
1099
+ }
1100
+ }
1101
+ /** A JSON file's value; with `lenient`, a file that does not parse reads as absent. */
1102
+ function readJson(file, lenient = false) {
1103
+ let text;
1104
+ try {
1105
+ text = fs.readFileSync(file, "utf-8");
1106
+ }
1107
+ catch (error) {
1108
+ if (error.code === "ENOENT")
1109
+ return undefined;
1110
+ throw error;
1111
+ }
1112
+ try {
1113
+ return JSON.parse(text);
1114
+ }
1115
+ catch (error) {
1116
+ if (lenient)
1117
+ return undefined;
1118
+ throw error;
1119
+ }
1120
+ }
1121
+ function writeJson(file, value) {
1122
+ writeText(file, `${JSON.stringify(value, null, 2)}\n`);
1123
+ }
1124
+ /**
1125
+ * Written aside, flushed to the disk, renamed, and the rename flushed too: a
1126
+ * reader never sees half of it, and a crash after this returns loses none of
1127
+ * it, which is what lets the offset that follows confirm it to Telegram.
1128
+ */
1129
+ function writeText(file, text) {
1130
+ const partial = path.join(path.dirname(file), `.${randomUUID()}.partial`);
1131
+ const handle = fs.openSync(partial, "wx", 0o600);
1132
+ try {
1133
+ // Every byte or an error: a short write is not published.
1134
+ fs.writeFileSync(handle, text);
1135
+ fs.fsyncSync(handle);
1136
+ }
1137
+ finally {
1138
+ fs.closeSync(handle);
1139
+ }
1140
+ fs.renameSync(partial, file);
1141
+ syncDirectory(path.dirname(file));
1142
+ }
1143
+ /** A directory's entries flushed; a platform that cannot open a directory for this keeps the rename as it is. */
1144
+ function syncDirectory(directory) {
1145
+ let handle;
1146
+ try {
1147
+ handle = fs.openSync(directory, "r");
1148
+ fs.fsyncSync(handle);
1149
+ }
1150
+ catch (error) {
1151
+ const code = error.code;
1152
+ if (code !== "EISDIR" && code !== "EPERM" && code !== "EINVAL" && code !== "EBADF")
1153
+ throw error;
1154
+ }
1155
+ finally {
1156
+ if (handle !== undefined)
1157
+ fs.closeSync(handle);
1158
+ }
1159
+ }
1160
+ /** A directory made, if it is not there, and flushed into its parent so it survives a power loss. */
1161
+ function durableDirectory(directory, within) {
1162
+ if (fs.existsSync(directory))
1163
+ return;
1164
+ const relative = path.relative(within, directory);
1165
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
1166
+ throw new Error(`${within} is gone, and nothing is made in its place`);
1167
+ }
1168
+ durableDirectory(path.dirname(directory), within);
1169
+ try {
1170
+ fs.mkdirSync(directory, { mode: 0o700 });
1171
+ }
1172
+ catch (error) {
1173
+ if (error.code !== "EEXIST")
1174
+ throw error;
1175
+ }
1176
+ syncDirectory(path.dirname(directory));
1177
+ }
1178
+ /** What threads a machine's work lives in, and the operator's files, are nobody else's to read. */
1179
+ function privateDirectory(directory) {
1180
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
1181
+ }
1182
+ /** A name from a message, made into one path segment. */
1183
+ function safeName(name) {
1184
+ const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_");
1185
+ return cleaned || "file";
1186
+ }
1187
+ function messageOf(error) {
1188
+ return error instanceof Error ? error.message : String(error);
1189
+ }
1190
+ //# sourceMappingURL=TelegramConversation.js.map