@llblab/pi-telegram 0.52.2 → 0.53.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 (204) hide show
  1. package/AGENTS.md +9 -7
  2. package/BACKLOG.md +1 -18
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +7 -11
  5. package/api/commands.ts +3 -0
  6. package/dist/api/commands.d.ts +1 -1
  7. package/dist/lib/activity-verbosity.d.ts +1 -5
  8. package/dist/lib/activity-verbosity.js +15 -42
  9. package/dist/lib/activity.d.ts +9 -0
  10. package/dist/lib/activity.js +39 -10
  11. package/dist/lib/agent-messages.js +7 -3
  12. package/dist/lib/bindings.d.ts +43 -5
  13. package/dist/lib/bindings.js +163 -20
  14. package/dist/lib/bus-api.d.ts +12 -0
  15. package/dist/lib/bus-api.js +182 -79
  16. package/dist/lib/bus-follower.d.ts +111 -31
  17. package/dist/lib/bus-follower.js +1324 -359
  18. package/dist/lib/bus-leader.d.ts +41 -91
  19. package/dist/lib/bus-leader.js +969 -637
  20. package/dist/lib/bus-transport.js +16 -9
  21. package/dist/lib/bus.d.ts +215 -18
  22. package/dist/lib/bus.js +1048 -327
  23. package/dist/lib/channel-posts.d.ts +5 -0
  24. package/dist/lib/channel-posts.js +434 -261
  25. package/dist/lib/command-templates.d.ts +26 -5
  26. package/dist/lib/command-templates.js +92 -176
  27. package/dist/lib/commands.d.ts +168 -82
  28. package/dist/lib/commands.js +1409 -291
  29. package/dist/lib/config.d.ts +1 -8
  30. package/dist/lib/config.js +34 -76
  31. package/dist/lib/delivery.d.ts +2 -2
  32. package/dist/lib/delivery.js +16 -16
  33. package/dist/lib/extension.js +639 -171
  34. package/dist/lib/generative-app-worker.mjs +19 -7
  35. package/dist/lib/generative-apps.d.ts +0 -5
  36. package/dist/lib/generative-apps.js +61 -49
  37. package/dist/lib/inbound.d.ts +5 -0
  38. package/dist/lib/inbound.js +35 -59
  39. package/dist/lib/journal.d.ts +33 -3
  40. package/dist/lib/journal.js +2063 -782
  41. package/dist/lib/lifecycle.d.ts +2 -6
  42. package/dist/lib/lifecycle.js +75 -58
  43. package/dist/lib/locks.d.ts +29 -3
  44. package/dist/lib/locks.js +289 -108
  45. package/dist/lib/logging.js +50 -14
  46. package/dist/lib/media.d.ts +22 -1
  47. package/dist/lib/media.js +46 -28
  48. package/dist/lib/menu-model.d.ts +19 -5
  49. package/dist/lib/menu-model.js +53 -11
  50. package/dist/lib/menu-queue.d.ts +5 -4
  51. package/dist/lib/menu-queue.js +45 -63
  52. package/dist/lib/menu-settings.d.ts +5 -4
  53. package/dist/lib/menu-settings.js +114 -78
  54. package/dist/lib/menu-status.d.ts +1 -1
  55. package/dist/lib/menu-status.js +8 -25
  56. package/dist/lib/menu-thinking.d.ts +6 -1
  57. package/dist/lib/menu-thinking.js +14 -21
  58. package/dist/lib/menu.d.ts +8 -61
  59. package/dist/lib/menu.js +92 -164
  60. package/dist/lib/model.d.ts +1 -1
  61. package/dist/lib/model.js +2 -16
  62. package/dist/lib/outbound-attachments.d.ts +6 -27
  63. package/dist/lib/outbound-attachments.js +45 -38
  64. package/dist/lib/outbound-buttons.js +22 -16
  65. package/dist/lib/outbound-markup.d.ts +0 -2
  66. package/dist/lib/outbound-markup.js +51 -64
  67. package/dist/lib/outbound-voice.js +6 -3
  68. package/dist/lib/outbound.d.ts +6 -6
  69. package/dist/lib/outbound.js +65 -51
  70. package/dist/lib/ownership.js +6 -2
  71. package/dist/lib/paths.d.ts +5 -0
  72. package/dist/lib/paths.js +37 -21
  73. package/dist/lib/pi.d.ts +2 -0
  74. package/dist/lib/pi.js +8 -1
  75. package/dist/lib/polling.d.ts +21 -37
  76. package/dist/lib/polling.js +59 -50
  77. package/dist/lib/preview.js +39 -19
  78. package/dist/lib/process-identity.d.ts +24 -0
  79. package/dist/lib/process-identity.js +133 -0
  80. package/dist/lib/prompts.js +2 -7
  81. package/dist/lib/queue.d.ts +14 -16
  82. package/dist/lib/queue.js +252 -127
  83. package/dist/lib/recovery.d.ts +2 -83
  84. package/dist/lib/recovery.js +20 -274
  85. package/dist/lib/replies.d.ts +16 -24
  86. package/dist/lib/replies.js +99 -116
  87. package/dist/lib/routing.d.ts +120 -7
  88. package/dist/lib/routing.js +4667 -1152
  89. package/dist/lib/runtime.d.ts +1 -11
  90. package/dist/lib/sections.d.ts +4 -29
  91. package/dist/lib/sections.js +7 -20
  92. package/dist/lib/skills.js +4 -3
  93. package/dist/lib/status.js +86 -57
  94. package/dist/lib/sync.d.ts +1 -3
  95. package/dist/lib/sync.js +67 -43
  96. package/dist/lib/target.d.ts +4 -0
  97. package/dist/lib/target.js +18 -0
  98. package/dist/lib/telegram-api.d.ts +18 -13
  99. package/dist/lib/telegram-api.js +148 -53
  100. package/dist/lib/text-groups.js +4 -39
  101. package/dist/lib/thread-cleanup-manager.d.ts +6 -27
  102. package/dist/lib/thread-cleanup-manager.js +369 -186
  103. package/dist/lib/thread-display.d.ts +1 -2
  104. package/dist/lib/thread-display.js +58 -38
  105. package/dist/lib/thread-naming.d.ts +28 -8
  106. package/dist/lib/thread-naming.js +175 -66
  107. package/dist/lib/thread-reconciler.d.ts +42 -16
  108. package/dist/lib/thread-reconciler.js +90 -50
  109. package/dist/lib/threads.d.ts +82 -20
  110. package/dist/lib/threads.js +2247 -855
  111. package/dist/lib/time-injection.js +1 -2
  112. package/dist/lib/turns.js +1 -19
  113. package/dist/lib/updates.d.ts +169 -80
  114. package/dist/lib/updates.js +2418 -641
  115. package/dist/lib/voice.d.ts +7 -22
  116. package/dist/lib/voice.js +7 -22
  117. package/dist/lib/wire.js +1 -1
  118. package/dist/lib/workspace-admission.d.ts +3 -1
  119. package/dist/lib/workspace-admission.js +425 -211
  120. package/dist/lib/workspace-identity.js +8 -13
  121. package/dist/lib/workspace-retirement.d.ts +1 -1
  122. package/dist/lib/workspace-retirement.js +328 -140
  123. package/dist/lib/workspace-slots.js +8 -4
  124. package/dist/package.json +1 -1
  125. package/docs/architecture.md +31 -29
  126. package/docs/inbound.md +1 -1
  127. package/docs/multi-instance-bus.md +15 -3
  128. package/docs/outbound.md +1 -1
  129. package/docs/public-api.md +29 -1
  130. package/docs/ui-style.md +25 -8
  131. package/lib/activity-verbosity.ts +59 -82
  132. package/lib/activity.ts +146 -102
  133. package/lib/agent-messages.ts +12 -5
  134. package/lib/bindings.ts +389 -82
  135. package/lib/bus-api.ts +263 -102
  136. package/lib/bus-follower.ts +2440 -601
  137. package/lib/bus-leader.ts +2209 -1290
  138. package/lib/bus-transport.ts +46 -12
  139. package/lib/bus.ts +1673 -444
  140. package/lib/channel-posts.ts +848 -327
  141. package/lib/command-templates.ts +152 -265
  142. package/lib/commands.ts +2398 -555
  143. package/lib/config.ts +177 -170
  144. package/lib/delivery.ts +41 -29
  145. package/lib/extension.ts +1206 -433
  146. package/lib/generative-app-worker.mjs +19 -7
  147. package/lib/generative-apps.ts +324 -211
  148. package/lib/inbound.ts +95 -97
  149. package/lib/journal.ts +5311 -1763
  150. package/lib/keyboard.ts +1 -3
  151. package/lib/lifecycle.ts +128 -78
  152. package/lib/locks.ts +677 -203
  153. package/lib/logging.ts +147 -58
  154. package/lib/media.ts +82 -43
  155. package/lib/menu-model.ts +85 -18
  156. package/lib/menu-queue.ts +88 -129
  157. package/lib/menu-settings.ts +260 -155
  158. package/lib/menu-status.ts +20 -60
  159. package/lib/menu-thinking.ts +41 -56
  160. package/lib/menu.ts +112 -285
  161. package/lib/model.ts +8 -22
  162. package/lib/outbound-attachments.ts +156 -125
  163. package/lib/outbound-buttons.ts +76 -49
  164. package/lib/outbound-markup.ts +96 -94
  165. package/lib/outbound-voice.ts +33 -27
  166. package/lib/outbound.ts +128 -125
  167. package/lib/ownership.ts +19 -10
  168. package/lib/paths.ts +148 -49
  169. package/lib/pi.ts +35 -15
  170. package/lib/polling.ts +144 -147
  171. package/lib/preview.ts +93 -40
  172. package/lib/process-identity.ts +164 -0
  173. package/lib/prompts.ts +4 -11
  174. package/lib/queue.ts +516 -301
  175. package/lib/recovery.ts +50 -454
  176. package/lib/rendering.ts +1 -3
  177. package/lib/replies.ts +248 -209
  178. package/lib/routing.ts +8509 -2186
  179. package/lib/runtime.ts +1 -12
  180. package/lib/sections.ts +24 -89
  181. package/lib/setup.ts +1 -3
  182. package/lib/skills.ts +9 -4
  183. package/lib/status.ts +215 -110
  184. package/lib/sync.ts +448 -361
  185. package/lib/target.ts +22 -3
  186. package/lib/telegram-api.ts +302 -127
  187. package/lib/text-groups.ts +5 -39
  188. package/lib/thread-cleanup-manager.ts +833 -331
  189. package/lib/thread-display.ts +213 -97
  190. package/lib/thread-naming.ts +232 -87
  191. package/lib/thread-reconciler.ts +155 -72
  192. package/lib/threads.ts +5318 -1845
  193. package/lib/time-injection.ts +1 -4
  194. package/lib/turns.ts +15 -28
  195. package/lib/updates.ts +5184 -1612
  196. package/lib/voice.ts +7 -35
  197. package/lib/wire.ts +1 -1
  198. package/lib/workspace-admission.ts +996 -416
  199. package/lib/workspace-identity.ts +14 -17
  200. package/lib/workspace-retirement.ts +878 -376
  201. package/lib/workspace-slots.ts +22 -13
  202. package/package.json +1 -1
  203. package/scripts/check-downgrade.mjs +0 -472
  204. package/scripts/measure-workspace.mjs +0 -108
@@ -4,20 +4,29 @@
4
4
  * Owns publication intent, outcome-unknown fencing, confirmed post identity, and bounded local listing
5
5
  */
6
6
 
7
- import { chmodSync, closeSync, constants, createReadStream, fstatSync, lstatSync, mkdirSync, openSync,
8
- readFileSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { createHash } from "node:crypto";
8
+ import { createReadStream } from "node:fs";
9
9
  import { lstat } from "node:fs/promises";
10
- import { basename, dirname } from "node:path";
11
- import { getTelegramJournalPublicationPaths } from "./paths.ts";
12
- import { createHash, randomUUID } from "node:crypto";
10
+ import { basename } from "node:path";
13
11
 
14
12
  import { Type } from "@sinclair/typebox";
15
13
 
16
- import { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.ts";
14
+ import { createTelegramUpdateJournalBotIdentity } from "./journal.ts";
15
+ import {
16
+ getTelegramJournalPublicationPaths,
17
+ resolveTelegramServiceJournalStorage,
18
+ } from "./paths.ts";
19
+
20
+ import {
21
+ publishTelegramPrivateFile,
22
+ readTelegramPrivateFile,
23
+ TelegramPrivateFileError,
24
+ withTelegramFileTransaction,
25
+ } from "./locks.ts";
17
26
  import type { ExtensionAPI } from "./pi.ts";
18
27
  import {
19
- isWireRecord as isRecord,
20
28
  hasOnlyWireKeys as hasOnlyKeys,
29
+ isWireRecord as isRecord,
21
30
  isNonNegativeWireInteger as isSafeTime,
22
31
  } from "./wire.ts";
23
32
 
@@ -37,7 +46,10 @@ export interface TelegramChannelPostMediaIntent {
37
46
  sha256: string;
38
47
  }
39
48
 
40
- const TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES: Record<TelegramChannelPostMediaKind, number> = {
49
+ const TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES: Record<
50
+ TelegramChannelPostMediaKind,
51
+ number
52
+ > = {
41
53
  photo: 10 * 1024 * 1024,
42
54
  video: 50 * 1024 * 1024,
43
55
  };
@@ -58,7 +70,12 @@ export function isTelegramChannelPostValidationError(
58
70
  return error instanceof TelegramChannelPostValidationError;
59
71
  }
60
72
 
61
- const TELEGRAM_CHANNEL_POST_PHOTO_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
73
+ const TELEGRAM_CHANNEL_POST_PHOTO_EXTENSIONS = new Set([
74
+ ".jpg",
75
+ ".jpeg",
76
+ ".png",
77
+ ".webp",
78
+ ]);
62
79
  const TELEGRAM_CHANNEL_POST_VIDEO_EXTENSIONS = new Set([".mp4"]);
63
80
 
64
81
  export function resolveTelegramChannelPostMediaKind(
@@ -79,7 +96,9 @@ export function assertTelegramChannelPostMediaSize(
79
96
  sizeBytes: number,
80
97
  ): void {
81
98
  if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) {
82
- throw new TelegramChannelPostValidationError("Channel media file is empty or unreadable.");
99
+ throw new TelegramChannelPostValidationError(
100
+ "Channel media file is empty or unreadable.",
101
+ );
83
102
  }
84
103
  const limit = TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES[kind];
85
104
  if (sizeBytes > limit) {
@@ -122,8 +141,12 @@ export async function inspectTelegramChannelPostMedia(
122
141
  );
123
142
  }
124
143
  assertTelegramChannelPostMediaSize(kind, stats.size);
125
- return { kind, fileName: basename(path), sizeBytes: stats.size,
126
- sha256: await hashTelegramChannelPostMedia(path) };
144
+ return {
145
+ kind,
146
+ fileName: basename(path),
147
+ sizeBytes: stats.size,
148
+ sha256: await hashTelegramChannelPostMedia(path),
149
+ };
127
150
  }
128
151
 
129
152
  export function getTelegramChannelPostCaptionLength(caption: string): number {
@@ -132,15 +155,19 @@ export function getTelegramChannelPostCaptionLength(caption: string): number {
132
155
  .replace(/<[^>]*>/gu, "")
133
156
  .replace(/&lt;/gu, "<")
134
157
  .replace(/&gt;/gu, ">")
135
- .replace(/&quot;/gu, "\"")
158
+ .replace(/&quot;/gu, '"')
136
159
  .replace(/&#39;/gu, "'")
137
160
  .replace(/&amp;/gu, "&");
138
161
  return visible.length;
139
162
  }
140
163
 
141
- export function assertTelegramChannelPostCaptionWithinLimit(caption: string): void {
142
- if (getTelegramChannelPostCaptionLength(caption) >
143
- TELEGRAM_CHANNEL_POST_CAPTION_MAX_LENGTH) {
164
+ export function assertTelegramChannelPostCaptionWithinLimit(
165
+ caption: string,
166
+ ): void {
167
+ if (
168
+ getTelegramChannelPostCaptionLength(caption) >
169
+ TELEGRAM_CHANNEL_POST_CAPTION_MAX_LENGTH
170
+ ) {
144
171
  throw new TelegramChannelPostValidationError(
145
172
  `Channel media caption exceeds the Telegram limit of ${TELEGRAM_CHANNEL_POST_CAPTION_MAX_LENGTH} characters.`,
146
173
  );
@@ -180,17 +207,28 @@ interface TelegramPublishedChannelPostIdentity {
180
207
  lastMutationId?: string;
181
208
  }
182
209
 
183
- export type TelegramChannelPostRecord = TelegramChannelPostRecordBase & (
184
- | { state: "prepared" }
185
- | { state: "outcome-unknown"; issuedAtMs: number }
186
- | ({ state: "published" } & TelegramPublishedChannelPostIdentity)
187
- | ({ state: "edit-outcome-unknown"; mutationId: string; attemptedMarkdown: string;
188
- mutationIssuedAtMs: number } & TelegramPublishedChannelPostIdentity)
189
- | ({ state: "delete-outcome-unknown"; mutationId: string;
190
- mutationIssuedAtMs: number } & TelegramPublishedChannelPostIdentity)
191
- | ({ state: "deleted"; mutationId: string; deletedAtMs: number } &
192
- TelegramPublishedChannelPostIdentity)
193
- );
210
+ export type TelegramChannelPostRecord = TelegramChannelPostRecordBase &
211
+ (
212
+ | { state: "prepared" }
213
+ | { state: "outcome-unknown"; issuedAtMs: number }
214
+ | ({ state: "published" } & TelegramPublishedChannelPostIdentity)
215
+ | ({
216
+ state: "edit-outcome-unknown";
217
+ mutationId: string;
218
+ attemptedMarkdown: string;
219
+ mutationIssuedAtMs: number;
220
+ } & TelegramPublishedChannelPostIdentity)
221
+ | ({
222
+ state: "delete-outcome-unknown";
223
+ mutationId: string;
224
+ mutationIssuedAtMs: number;
225
+ } & TelegramPublishedChannelPostIdentity)
226
+ | ({
227
+ state: "deleted";
228
+ mutationId: string;
229
+ deletedAtMs: number;
230
+ } & TelegramPublishedChannelPostIdentity)
231
+ );
194
232
 
195
233
  interface TelegramChannelPostJournalFile {
196
234
  version: typeof CHANNEL_POST_JOURNAL_VERSION;
@@ -210,23 +248,45 @@ export interface TelegramChannelPostJournalStoreOptions {
210
248
  }
211
249
 
212
250
  export interface TelegramChannelPostJournalStore {
213
- prepare(input: { operationId: string; channel: TelegramChannelPostAddress; markdown: string;
214
- media?: TelegramChannelPostMediaIntent }):
215
- { prepared: boolean; record: TelegramChannelPostRecord };
251
+ prepare(input: {
252
+ operationId: string;
253
+ channel: TelegramChannelPostAddress;
254
+ markdown: string;
255
+ media?: TelegramChannelPostMediaIntent;
256
+ }): { prepared: boolean; record: TelegramChannelPostRecord };
216
257
  get(operationId: string): TelegramChannelPostRecord | undefined;
217
- beginPublication(operationId: string): { began: boolean; record: TelegramChannelPostRecord };
218
- confirmPublished(input: { operationId: string; channelId: number; messageId: number;
219
- channelUsername?: `@${string}`; channelTitle?: string }):
220
- { confirmed: boolean; record: TelegramChannelPostRecord };
221
- beginEdit(input: { operationId: string; mutationId: string; markdown: string }):
222
- { began: boolean; record: TelegramChannelPostRecord };
223
- confirmEdited(input: { operationId: string; mutationId: string }):
224
- { confirmed: boolean; record: TelegramChannelPostRecord };
225
- beginDelete(input: { operationId: string; mutationId: string }):
226
- { began: boolean; record: TelegramChannelPostRecord };
227
- confirmDeleted(input: { operationId: string; mutationId: string }):
228
- { confirmed: boolean; record: TelegramChannelPostRecord };
229
- list(input?: { channel?: TelegramChannelPostAddress; limit?: number }): TelegramChannelPostRecord[];
258
+ beginPublication(operationId: string): {
259
+ began: boolean;
260
+ record: TelegramChannelPostRecord;
261
+ };
262
+ confirmPublished(input: {
263
+ operationId: string;
264
+ channelId: number;
265
+ messageId: number;
266
+ channelUsername?: `@${string}`;
267
+ channelTitle?: string;
268
+ }): { confirmed: boolean; record: TelegramChannelPostRecord };
269
+ beginEdit(input: {
270
+ operationId: string;
271
+ mutationId: string;
272
+ markdown: string;
273
+ }): { began: boolean; record: TelegramChannelPostRecord };
274
+ confirmEdited(input: { operationId: string; mutationId: string }): {
275
+ confirmed: boolean;
276
+ record: TelegramChannelPostRecord;
277
+ };
278
+ beginDelete(input: { operationId: string; mutationId: string }): {
279
+ began: boolean;
280
+ record: TelegramChannelPostRecord;
281
+ };
282
+ confirmDeleted(input: { operationId: string; mutationId: string }): {
283
+ confirmed: boolean;
284
+ record: TelegramChannelPostRecord;
285
+ };
286
+ list(input?: {
287
+ channel?: TelegramChannelPostAddress;
288
+ limit?: number;
289
+ }): TelegramChannelPostRecord[];
230
290
  }
231
291
 
232
292
  export async function publishTelegramChannelPost(input: {
@@ -236,35 +296,63 @@ export async function publishTelegramChannelPost(input: {
236
296
  markdown: string;
237
297
  media?: TelegramChannelPostMediaIntent;
238
298
  observeChannel(channel: TelegramChannelPostAddress): Promise<{
239
- id: number; type: string; username?: string; title?: string;
299
+ id: number;
300
+ type: string;
301
+ username?: string;
302
+ title?: string;
240
303
  }>;
241
- send(channel: TelegramChannelPostAddress, markdown: string): Promise<{
242
- messageId: number; chat: { id: number; type: string };
304
+ send(
305
+ channel: TelegramChannelPostAddress,
306
+ markdown: string,
307
+ ): Promise<{
308
+ messageId: number;
309
+ chat: { id: number; type: string };
243
310
  }>;
244
311
  }): Promise<TelegramChannelPostRecord> {
245
312
  const observed = await input.observeChannel(input.channel);
246
- if (observed.type !== "channel" || !Number.isSafeInteger(observed.id) || observed.id >= 0 ||
247
- (typeof input.channel === "number" && observed.id !== input.channel) ||
248
- (observed.username !== undefined && !/^[A-Za-z0-9_]{5,32}$/u.test(observed.username)) ||
249
- (observed.title !== undefined && (observed.title.length === 0 ||
250
- observed.title.length > MAX_CHANNEL_TITLE_LENGTH))) {
251
- throw new Error("Telegram channel delivery requires bounded exact getChat channel identity.");
313
+ if (
314
+ observed.type !== "channel" ||
315
+ !Number.isSafeInteger(observed.id) ||
316
+ observed.id >= 0 ||
317
+ (typeof input.channel === "number" && observed.id !== input.channel) ||
318
+ (observed.username !== undefined &&
319
+ !/^[A-Za-z0-9_]{5,32}$/u.test(observed.username)) ||
320
+ (observed.title !== undefined &&
321
+ (observed.title.length === 0 ||
322
+ observed.title.length > MAX_CHANNEL_TITLE_LENGTH))
323
+ ) {
324
+ throw new Error(
325
+ "Telegram channel delivery requires bounded exact getChat channel identity.",
326
+ );
252
327
  }
253
- input.store.prepare({ operationId: input.operationId, channel: input.channel,
254
- markdown: input.markdown, ...(input.media === undefined ? {} : { media: input.media }) });
328
+ input.store.prepare({
329
+ operationId: input.operationId,
330
+ channel: input.channel,
331
+ markdown: input.markdown,
332
+ ...(input.media === undefined ? {} : { media: input.media }),
333
+ });
255
334
  const issuance = input.store.beginPublication(input.operationId);
256
335
  if (!issuance.began) {
257
336
  if (issuance.record.state === "published") return issuance.record;
258
- throw new Error("Telegram channel post outcome is unknown; refusing automatic replay.");
337
+ throw new Error(
338
+ "Telegram channel post outcome is unknown; refusing automatic replay.",
339
+ );
259
340
  }
260
341
  const sent = await input.send(input.channel, input.markdown);
261
342
  if (sent.chat.type !== "channel" || sent.chat.id !== observed.id) {
262
- throw new Error("Telegram channel post response identity did not match the verified channel.");
343
+ throw new Error(
344
+ "Telegram channel post response identity did not match the verified channel.",
345
+ );
263
346
  }
264
- return input.store.confirmPublished({ operationId: input.operationId,
265
- channelId: sent.chat.id, messageId: sent.messageId,
266
- ...(observed.username ? { channelUsername: `@${observed.username}` as `@${string}` } : {}),
267
- ...(observed.title ? { channelTitle: observed.title } : {}) }).record;
347
+ return input.store.confirmPublished({
348
+ operationId: input.operationId,
349
+ channelId: sent.chat.id,
350
+ messageId: sent.messageId,
351
+ ...(observed.username
352
+ ? { channelUsername: `@${observed.username}` as `@${string}` }
353
+ : {}),
354
+ ...(observed.title ? { channelTitle: observed.title } : {}),
355
+ }).record;
268
356
  }
269
357
 
270
358
  function formatTelegramChannelPostToolOutput(value: unknown): string {
@@ -274,34 +362,60 @@ function formatTelegramChannelPostToolOutput(value: unknown): string {
274
362
 
275
363
  function formatTelegramChannelPostToolError(error: unknown): Error {
276
364
  const message = error instanceof Error ? error.message : String(error);
277
- return new Error(`\n${message.replace(/^\n+/u, "") || "Telegram channel post operation failed."}`);
365
+ return new Error(
366
+ `\n${message.replace(/^\n+/u, "") || "Telegram channel post operation failed."}`,
367
+ );
278
368
  }
279
369
 
280
370
  export function registerTelegramChannelPostMutationTool(
281
371
  pi: ExtensionAPI,
282
- deps: { mutate(input: { action: "edit" | "delete"; operationId: string;
283
- mutationId: string; markdown?: string }): Promise<TelegramChannelPostRecord> },
372
+ deps: {
373
+ mutate(input: {
374
+ action: "edit" | "delete";
375
+ operationId: string;
376
+ mutationId: string;
377
+ markdown?: string;
378
+ }): Promise<TelegramChannelPostRecord>;
379
+ },
284
380
  ): void {
285
381
  pi.registerTool({
286
382
  name: "telegram_channel_post",
287
383
  label: "Edit or Delete Telegram Channel Post",
288
- description: "Edit or delete one exact published post retained by telegram_channel_posts; a media post edit replaces its caption. Unknown outcomes are never replayed.",
384
+ description:
385
+ "Edit or delete one exact published post retained by telegram_channel_posts; a media post edit replaces its caption. Unknown outcomes are never replayed.",
289
386
  parameters: Type.Object({
290
387
  action: Type.Union([Type.Literal("edit"), Type.Literal("delete")]),
291
388
  operation_id: Type.String({ minLength: 1, maxLength: MAX_ID_LENGTH }),
292
- markdown: Type.Optional(Type.String({ minLength: 1, maxLength: MAX_MARKDOWN_LENGTH })),
389
+ markdown: Type.Optional(
390
+ Type.String({ minLength: 1, maxLength: MAX_MARKDOWN_LENGTH }),
391
+ ),
293
392
  }),
294
393
  async execute(toolCallId, params) {
295
394
  try {
296
- const record = await deps.mutate({ action: params.action, operationId: params.operation_id,
297
- mutationId: toolCallId, ...(params.markdown === undefined ? {} : { markdown: params.markdown }) });
298
- return { content: [{ type: "text" as const, text: formatTelegramChannelPostToolOutput(record) }],
299
- details: { record } };
395
+ const record = await deps.mutate({
396
+ action: params.action,
397
+ operationId: params.operation_id,
398
+ mutationId: toolCallId,
399
+ ...(params.markdown === undefined
400
+ ? {}
401
+ : { markdown: params.markdown }),
402
+ });
403
+ return {
404
+ content: [
405
+ {
406
+ type: "text" as const,
407
+ text: formatTelegramChannelPostToolOutput(record),
408
+ },
409
+ ],
410
+ details: { record },
411
+ };
300
412
  } catch (error) {
301
413
  if (isTelegramChannelPostValidationError(error)) {
302
414
  throw formatTelegramChannelPostToolError(error);
303
415
  }
304
- throw new Error("\nTelegram channel post mutation failed; inspect the retained local record before retrying.");
416
+ throw new Error(
417
+ "\nTelegram channel post mutation failed; inspect the retained local record before retrying.",
418
+ );
305
419
  }
306
420
  },
307
421
  });
@@ -314,12 +428,18 @@ export function registerTelegramChannelPostListTool(
314
428
  pi.registerTool({
315
429
  name: "telegram_channel_posts",
316
430
  label: "Telegram Channel Posts",
317
- description: "List bounded local records for channel posts authored by this agent path. This does not read Telegram channel history.",
431
+ description:
432
+ "List bounded local records for channel posts authored by this agent path. This does not read Telegram channel history.",
318
433
  parameters: Type.Object({
319
- chat_id: Type.Optional(Type.Union([
320
- Type.Number(), Type.String({ pattern: "^@[A-Za-z0-9_]{5,32}$" }),
321
- ])),
322
- limit: Type.Optional(Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_RECORDS })),
434
+ chat_id: Type.Optional(
435
+ Type.Union([
436
+ Type.Number(),
437
+ Type.String({ pattern: "^@[A-Za-z0-9_]{5,32}$" }),
438
+ ]),
439
+ ),
440
+ limit: Type.Optional(
441
+ Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_RECORDS }),
442
+ ),
323
443
  }),
324
444
  async execute(_toolCallId, params) {
325
445
  try {
@@ -327,36 +447,64 @@ export function registerTelegramChannelPostListTool(
327
447
  channel: params.chat_id as TelegramChannelPostAddress | undefined,
328
448
  limit: params.limit,
329
449
  });
330
- return { content: [{ type: "text" as const, text: formatTelegramChannelPostToolOutput(records) }],
331
- details: { records } };
450
+ return {
451
+ content: [
452
+ {
453
+ type: "text" as const,
454
+ text: formatTelegramChannelPostToolOutput(records),
455
+ },
456
+ ],
457
+ details: { records },
458
+ };
332
459
  } catch {
333
- throw new Error("\nTelegram channel post listing failed without exposing retained content or storage details.");
460
+ throw new Error(
461
+ "\nTelegram channel post listing failed without exposing retained content or storage details.",
462
+ );
334
463
  }
335
464
  },
336
465
  });
337
466
  }
338
467
 
339
468
  function normalizeChannel(value: unknown): TelegramChannelPostAddress {
340
- if (Number.isSafeInteger(value) && (value as number) < 0) return value as number;
469
+ if (Number.isSafeInteger(value) && (value as number) < 0)
470
+ return value as number;
341
471
  if (typeof value === "string" && /^@[A-Za-z0-9_]{5,32}$/u.test(value)) {
342
472
  return value as `@${string}`;
343
473
  }
344
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post requires an exact negative channel ID or public @username.");
474
+ throw new TelegramChannelPostJournalError(
475
+ "invalid",
476
+ "Telegram channel post requires an exact negative channel ID or public @username.",
477
+ );
345
478
  }
346
479
 
347
- function validateTelegramChannelPostMedia(value: unknown): TelegramChannelPostMediaIntent {
348
- if (!isRecord(value) || !hasOnlyKeys(value, ["kind", "fileName", "sizeBytes", "sha256"]) ||
349
- (value.kind !== "photo" && value.kind !== "video") ||
350
- typeof value.fileName !== "string" || value.fileName.length === 0 ||
351
- value.fileName.length > TELEGRAM_CHANNEL_POST_MEDIA_FILE_NAME_MAX_LENGTH ||
352
- !Number.isSafeInteger(value.sizeBytes) || (value.sizeBytes as number) <= 0 ||
353
- (value.sizeBytes as number) > TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES[value.kind] ||
354
- typeof value.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sha256)) {
355
- throw new TelegramChannelPostJournalError("invalid",
356
- "Telegram channel post journal contains an invalid media intent.");
480
+ function validateTelegramChannelPostMedia(
481
+ value: unknown,
482
+ ): TelegramChannelPostMediaIntent {
483
+ if (
484
+ !isRecord(value) ||
485
+ !hasOnlyKeys(value, ["kind", "fileName", "sizeBytes", "sha256"]) ||
486
+ (value.kind !== "photo" && value.kind !== "video") ||
487
+ typeof value.fileName !== "string" ||
488
+ value.fileName.length === 0 ||
489
+ value.fileName.length > TELEGRAM_CHANNEL_POST_MEDIA_FILE_NAME_MAX_LENGTH ||
490
+ !Number.isSafeInteger(value.sizeBytes) ||
491
+ (value.sizeBytes as number) <= 0 ||
492
+ (value.sizeBytes as number) >
493
+ TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES[value.kind] ||
494
+ typeof value.sha256 !== "string" ||
495
+ !/^[a-f0-9]{64}$/u.test(value.sha256)
496
+ ) {
497
+ throw new TelegramChannelPostJournalError(
498
+ "invalid",
499
+ "Telegram channel post journal contains an invalid media intent.",
500
+ );
357
501
  }
358
- return { kind: value.kind, fileName: value.fileName,
359
- sizeBytes: value.sizeBytes as number, sha256: value.sha256 };
502
+ return {
503
+ kind: value.kind,
504
+ fileName: value.fileName,
505
+ sizeBytes: value.sizeBytes as number,
506
+ sha256: value.sha256,
507
+ };
360
508
  }
361
509
 
362
510
  function sameTelegramChannelPostMedia(
@@ -364,355 +512,728 @@ function sameTelegramChannelPostMedia(
364
512
  right: TelegramChannelPostMediaIntent | undefined,
365
513
  ): boolean {
366
514
  if (left === undefined || right === undefined) return left === right;
367
- return left.kind === right.kind && left.fileName === right.fileName &&
368
- left.sizeBytes === right.sizeBytes && left.sha256 === right.sha256;
515
+ return (
516
+ left.kind === right.kind &&
517
+ left.fileName === right.fileName &&
518
+ left.sizeBytes === right.sizeBytes &&
519
+ left.sha256 === right.sha256
520
+ );
521
+ }
522
+
523
+ /** Evidence that only exists once Telegram has confirmed a publication. */
524
+ const TELEGRAM_CHANNEL_POST_PUBLICATION_FIELDS = [
525
+ "publishedAtMs",
526
+ "channelId",
527
+ "messageId",
528
+ "channelUsername",
529
+ "mutationId",
530
+ "attemptedMarkdown",
531
+ "mutationIssuedAtMs",
532
+ "deletedAtMs",
533
+ "lastMutationId",
534
+ "channelTitle",
535
+ ] as const;
536
+
537
+ function lacksTelegramChannelPostFields(
538
+ value: Record<string, unknown>,
539
+ fields: readonly string[],
540
+ ): boolean {
541
+ return fields.every((field) => value[field] === undefined);
369
542
  }
370
543
 
371
544
  function validateRecord(value: unknown): TelegramChannelPostRecord {
372
- if (!isRecord(value) || !hasOnlyKeys(value, ["operationId", "requestedChannel", "markdown",
373
- "media", "createdAtMs", "updatedAtMs", "state", "issuedAtMs", "publishedAtMs", "channelId",
374
- "messageId", "channelUsername", "mutationId", "attemptedMarkdown",
375
- "mutationIssuedAtMs", "deletedAtMs", "lastMutationId", "channelTitle"]) || typeof value.operationId !== "string" ||
376
- value.operationId.length === 0 || value.operationId.length > MAX_ID_LENGTH ||
377
- typeof value.markdown !== "string" || value.markdown.length === 0 ||
378
- value.markdown.length > MAX_MARKDOWN_LENGTH || !isSafeTime(value.createdAtMs) ||
379
- !isSafeTime(value.updatedAtMs) || value.updatedAtMs < value.createdAtMs) {
380
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains an invalid record.");
545
+ if (
546
+ !isRecord(value) ||
547
+ !hasOnlyKeys(value, [
548
+ "operationId",
549
+ "requestedChannel",
550
+ "markdown",
551
+ "media",
552
+ "createdAtMs",
553
+ "updatedAtMs",
554
+ "state",
555
+ "issuedAtMs",
556
+ "publishedAtMs",
557
+ "channelId",
558
+ "messageId",
559
+ "channelUsername",
560
+ "mutationId",
561
+ "attemptedMarkdown",
562
+ "mutationIssuedAtMs",
563
+ "deletedAtMs",
564
+ "lastMutationId",
565
+ "channelTitle",
566
+ ]) ||
567
+ typeof value.operationId !== "string" ||
568
+ value.operationId.length === 0 ||
569
+ value.operationId.length > MAX_ID_LENGTH ||
570
+ typeof value.markdown !== "string" ||
571
+ value.markdown.length === 0 ||
572
+ value.markdown.length > MAX_MARKDOWN_LENGTH ||
573
+ !isSafeTime(value.createdAtMs) ||
574
+ !isSafeTime(value.updatedAtMs) ||
575
+ value.updatedAtMs < value.createdAtMs
576
+ ) {
577
+ throw new TelegramChannelPostJournalError(
578
+ "invalid",
579
+ "Telegram channel post journal contains an invalid record.",
580
+ );
381
581
  }
382
- const base: TelegramChannelPostRecordBase = { operationId: value.operationId,
383
- requestedChannel: normalizeChannel(value.requestedChannel), markdown: value.markdown,
384
- createdAtMs: value.createdAtMs, updatedAtMs: value.updatedAtMs,
385
- ...(value.media === undefined ? {} : { media: validateTelegramChannelPostMedia(value.media) }) };
386
- if (value.state === "prepared" && value.issuedAtMs === undefined && value.publishedAtMs === undefined &&
387
- value.channelId === undefined && value.messageId === undefined && value.channelUsername === undefined &&
388
- value.mutationId === undefined && value.attemptedMarkdown === undefined &&
389
- value.mutationIssuedAtMs === undefined && value.deletedAtMs === undefined &&
390
- value.lastMutationId === undefined && value.channelTitle === undefined) {
582
+ const base: TelegramChannelPostRecordBase = {
583
+ operationId: value.operationId,
584
+ requestedChannel: normalizeChannel(value.requestedChannel),
585
+ markdown: value.markdown,
586
+ createdAtMs: value.createdAtMs,
587
+ updatedAtMs: value.updatedAtMs,
588
+ ...(value.media === undefined
589
+ ? {}
590
+ : { media: validateTelegramChannelPostMedia(value.media) }),
591
+ };
592
+ if (
593
+ value.state === "prepared" &&
594
+ value.issuedAtMs === undefined &&
595
+ lacksTelegramChannelPostFields(
596
+ value,
597
+ TELEGRAM_CHANNEL_POST_PUBLICATION_FIELDS,
598
+ )
599
+ ) {
391
600
  return { ...base, state: "prepared" };
392
601
  }
393
602
  if (!isSafeTime(value.issuedAtMs) || value.issuedAtMs < value.createdAtMs) {
394
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid issuance evidence.");
603
+ throw new TelegramChannelPostJournalError(
604
+ "invalid",
605
+ "Telegram channel post journal contains invalid issuance evidence.",
606
+ );
395
607
  }
396
- if (value.state === "outcome-unknown" && value.publishedAtMs === undefined && value.channelId === undefined &&
397
- value.messageId === undefined && value.channelUsername === undefined && value.mutationId === undefined &&
398
- value.attemptedMarkdown === undefined && value.mutationIssuedAtMs === undefined &&
399
- value.deletedAtMs === undefined && value.lastMutationId === undefined &&
400
- value.channelTitle === undefined) {
608
+ if (
609
+ value.state === "outcome-unknown" &&
610
+ lacksTelegramChannelPostFields(
611
+ value,
612
+ TELEGRAM_CHANNEL_POST_PUBLICATION_FIELDS,
613
+ )
614
+ ) {
401
615
  return { ...base, state: "outcome-unknown", issuedAtMs: value.issuedAtMs };
402
616
  }
403
- if (!isSafeTime(value.publishedAtMs) || value.publishedAtMs < value.issuedAtMs ||
404
- !Number.isSafeInteger(value.channelId) || (value.channelId as number) >= 0 ||
405
- !Number.isSafeInteger(value.messageId) || (value.messageId as number) <= 0 ||
406
- (value.channelUsername !== undefined && (typeof value.channelUsername !== "string" ||
617
+ if (
618
+ !isSafeTime(value.publishedAtMs) ||
619
+ value.publishedAtMs < value.issuedAtMs ||
620
+ !Number.isSafeInteger(value.channelId) ||
621
+ (value.channelId as number) >= 0 ||
622
+ !Number.isSafeInteger(value.messageId) ||
623
+ (value.messageId as number) <= 0 ||
624
+ (value.channelUsername !== undefined &&
625
+ (typeof value.channelUsername !== "string" ||
407
626
  !/^@[A-Za-z0-9_]{5,32}$/u.test(value.channelUsername))) ||
408
- (value.channelTitle !== undefined && (typeof value.channelTitle !== "string" ||
409
- value.channelTitle.length === 0 || value.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))) {
410
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid published identity.");
627
+ (value.channelTitle !== undefined &&
628
+ (typeof value.channelTitle !== "string" ||
629
+ value.channelTitle.length === 0 ||
630
+ value.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))
631
+ ) {
632
+ throw new TelegramChannelPostJournalError(
633
+ "invalid",
634
+ "Telegram channel post journal contains invalid published identity.",
635
+ );
411
636
  }
412
- const identity: TelegramPublishedChannelPostIdentity = { issuedAtMs: value.issuedAtMs,
413
- publishedAtMs: value.publishedAtMs, channelId: value.channelId as number,
637
+ const identity: TelegramPublishedChannelPostIdentity = {
638
+ issuedAtMs: value.issuedAtMs,
639
+ publishedAtMs: value.publishedAtMs,
640
+ channelId: value.channelId as number,
414
641
  messageId: value.messageId as number,
415
- ...(value.channelUsername ? { channelUsername: value.channelUsername as `@${string}` } : {}),
416
- ...(typeof value.channelTitle === "string" ? { channelTitle: value.channelTitle } : {}),
417
- ...(typeof value.lastMutationId === "string" && value.lastMutationId.length > 0 &&
418
- value.lastMutationId.length <= MAX_ID_LENGTH ? { lastMutationId: value.lastMutationId } : {}) };
419
- if (value.lastMutationId !== undefined && identity.lastMutationId === undefined) {
420
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid mutation identity.");
642
+ ...(value.channelUsername
643
+ ? { channelUsername: value.channelUsername as `@${string}` }
644
+ : {}),
645
+ ...(typeof value.channelTitle === "string"
646
+ ? { channelTitle: value.channelTitle }
647
+ : {}),
648
+ ...(typeof value.lastMutationId === "string" &&
649
+ value.lastMutationId.length > 0 &&
650
+ value.lastMutationId.length <= MAX_ID_LENGTH
651
+ ? { lastMutationId: value.lastMutationId }
652
+ : {}),
653
+ };
654
+ if (
655
+ value.lastMutationId !== undefined &&
656
+ identity.lastMutationId === undefined
657
+ ) {
658
+ throw new TelegramChannelPostJournalError(
659
+ "invalid",
660
+ "Telegram channel post journal contains invalid mutation identity.",
661
+ );
421
662
  }
422
- if (value.state === "published" && value.mutationId === undefined &&
423
- value.attemptedMarkdown === undefined && value.mutationIssuedAtMs === undefined &&
424
- value.deletedAtMs === undefined) return { ...base, ...identity, state: "published" };
425
- const validMutation = typeof value.mutationId === "string" && value.mutationId.length > 0 &&
426
- value.mutationId.length <= MAX_ID_LENGTH && isSafeTime(value.mutationIssuedAtMs) &&
663
+ if (
664
+ value.state === "published" &&
665
+ value.mutationId === undefined &&
666
+ value.attemptedMarkdown === undefined &&
667
+ value.mutationIssuedAtMs === undefined &&
668
+ value.deletedAtMs === undefined
669
+ )
670
+ return { ...base, ...identity, state: "published" };
671
+ const validMutation =
672
+ typeof value.mutationId === "string" &&
673
+ value.mutationId.length > 0 &&
674
+ value.mutationId.length <= MAX_ID_LENGTH &&
675
+ isSafeTime(value.mutationIssuedAtMs) &&
427
676
  value.mutationIssuedAtMs >= value.publishedAtMs &&
428
677
  value.mutationIssuedAtMs === value.updatedAtMs;
429
- if (value.state === "edit-outcome-unknown" && validMutation &&
430
- typeof value.attemptedMarkdown === "string" && value.attemptedMarkdown.length > 0 &&
431
- value.attemptedMarkdown.length <= MAX_MARKDOWN_LENGTH && value.deletedAtMs === undefined) {
432
- return { ...base, ...identity, state: "edit-outcome-unknown", mutationId: value.mutationId as string,
433
- attemptedMarkdown: value.attemptedMarkdown, mutationIssuedAtMs: value.mutationIssuedAtMs as number };
678
+ if (
679
+ value.state === "edit-outcome-unknown" &&
680
+ validMutation &&
681
+ typeof value.attemptedMarkdown === "string" &&
682
+ value.attemptedMarkdown.length > 0 &&
683
+ value.attemptedMarkdown.length <= MAX_MARKDOWN_LENGTH &&
684
+ value.deletedAtMs === undefined
685
+ ) {
686
+ return {
687
+ ...base,
688
+ ...identity,
689
+ state: "edit-outcome-unknown",
690
+ mutationId: value.mutationId as string,
691
+ attemptedMarkdown: value.attemptedMarkdown,
692
+ mutationIssuedAtMs: value.mutationIssuedAtMs as number,
693
+ };
434
694
  }
435
- if (value.state === "delete-outcome-unknown" && validMutation &&
436
- value.attemptedMarkdown === undefined && value.deletedAtMs === undefined) {
437
- return { ...base, ...identity, state: "delete-outcome-unknown", mutationId: value.mutationId as string,
438
- mutationIssuedAtMs: value.mutationIssuedAtMs as number };
695
+ if (
696
+ value.state === "delete-outcome-unknown" &&
697
+ validMutation &&
698
+ value.attemptedMarkdown === undefined &&
699
+ value.deletedAtMs === undefined
700
+ ) {
701
+ return {
702
+ ...base,
703
+ ...identity,
704
+ state: "delete-outcome-unknown",
705
+ mutationId: value.mutationId as string,
706
+ mutationIssuedAtMs: value.mutationIssuedAtMs as number,
707
+ };
439
708
  }
440
- if (value.state === "deleted" && typeof value.mutationId === "string" &&
441
- value.mutationId.length > 0 && value.mutationId.length <= MAX_ID_LENGTH &&
442
- isSafeTime(value.deletedAtMs) && value.deletedAtMs >= value.publishedAtMs &&
443
- value.deletedAtMs === value.updatedAtMs &&
444
- value.mutationIssuedAtMs === undefined && value.attemptedMarkdown === undefined) {
445
- return { ...base, ...identity, state: "deleted", mutationId: value.mutationId,
446
- deletedAtMs: value.deletedAtMs };
709
+ if (
710
+ value.state === "deleted" &&
711
+ typeof value.mutationId === "string" &&
712
+ value.mutationId.length > 0 &&
713
+ value.mutationId.length <= MAX_ID_LENGTH &&
714
+ isSafeTime(value.deletedAtMs) &&
715
+ value.deletedAtMs >= value.publishedAtMs &&
716
+ value.deletedAtMs === value.updatedAtMs &&
717
+ value.mutationIssuedAtMs === undefined &&
718
+ value.attemptedMarkdown === undefined
719
+ ) {
720
+ return {
721
+ ...base,
722
+ ...identity,
723
+ state: "deleted",
724
+ mutationId: value.mutationId,
725
+ deletedAtMs: value.deletedAtMs,
726
+ };
447
727
  }
448
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains an invalid state.");
728
+ throw new TelegramChannelPostJournalError(
729
+ "invalid",
730
+ "Telegram channel post journal contains an invalid state.",
731
+ );
449
732
  }
450
733
 
451
- function parseTelegramChannelPostJournalFile(value: unknown, profileName: string, tokenSha256: string): TelegramChannelPostJournalFile {
452
- if (!isRecord(value) || !hasOnlyKeys(value, ["version", "profile", "tokenSha256", "records"]) ||
453
- value.version !== CHANNEL_POST_JOURNAL_VERSION || value.profile !== profileName ||
454
- value.tokenSha256 !== tokenSha256 || !Array.isArray(value.records)) {
455
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal identity or schema does not match.");
734
+ function parseTelegramChannelPostJournalFile(
735
+ value: unknown,
736
+ profileName: string,
737
+ tokenSha256: string,
738
+ ): TelegramChannelPostJournalFile {
739
+ if (
740
+ !isRecord(value) ||
741
+ !hasOnlyKeys(value, ["version", "profile", "tokenSha256", "records"]) ||
742
+ value.version !== CHANNEL_POST_JOURNAL_VERSION ||
743
+ value.profile !== profileName ||
744
+ value.tokenSha256 !== tokenSha256 ||
745
+ !Array.isArray(value.records)
746
+ ) {
747
+ throw new TelegramChannelPostJournalError(
748
+ "conflict",
749
+ "Telegram channel post journal identity or schema does not match.",
750
+ );
456
751
  }
457
752
  const records = value.records.map(validateRecord);
458
- if (new Set(records.map(record => record.operationId)).size !== records.length) {
459
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains duplicate operation IDs.");
753
+ if (
754
+ new Set(records.map((record) => record.operationId)).size !== records.length
755
+ ) {
756
+ throw new TelegramChannelPostJournalError(
757
+ "invalid",
758
+ "Telegram channel post journal contains duplicate operation IDs.",
759
+ );
460
760
  }
461
- return { version: CHANNEL_POST_JOURNAL_VERSION, profile: profileName, tokenSha256, records };
761
+ return {
762
+ version: CHANNEL_POST_JOURNAL_VERSION,
763
+ profile: profileName,
764
+ tokenSha256,
765
+ records,
766
+ };
767
+ }
768
+
769
+ /** Exact operation lookup; a missing record is a durable conflict, never an implicit create. */
770
+ function findTelegramChannelPostRecord(
771
+ file: TelegramChannelPostJournalFile,
772
+ operationId: string,
773
+ missing: string,
774
+ ): [number, TelegramChannelPostJournalFile["records"][number]] {
775
+ const index = file.records.findIndex(
776
+ (record) => record.operationId === operationId,
777
+ );
778
+ if (index < 0) throw new TelegramChannelPostJournalError("conflict", missing);
779
+ return [index, file.records[index]!];
780
+ }
781
+
782
+ /** The profile's channel-post journal in the shared runtime directory, bound to the active bot token. */
783
+ export function openTelegramChannelPostJournalStore(input: {
784
+ profileName: string;
785
+ botToken: string;
786
+ }): TelegramChannelPostJournalStore {
787
+ return createTelegramChannelPostJournalStore({
788
+ ...resolveTelegramServiceJournalStorage(
789
+ "channel-posts",
790
+ undefined,
791
+ input.profileName,
792
+ ),
793
+ profileName: input.profileName,
794
+ tokenSha256: createTelegramUpdateJournalBotIdentity({
795
+ botToken: input.botToken,
796
+ }).tokenSha256,
797
+ });
462
798
  }
463
799
 
464
800
  export function createTelegramChannelPostJournalStore(
465
801
  options: TelegramChannelPostJournalStoreOptions,
466
802
  ): TelegramChannelPostJournalStore {
467
803
  options = { ...options };
468
- const publicationPaths = getTelegramJournalPublicationPaths(options.path, options.runtimeDir);
804
+ const publicationPaths = getTelegramJournalPublicationPaths(
805
+ options.path,
806
+ options.runtimeDir,
807
+ );
469
808
  const maxRecords = options.maxRecords ?? DEFAULT_MAX_RECORDS;
470
809
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
471
810
  const now = options.getNowMs ?? Date.now;
472
- if (!options.path || !options.profileName || !/^[a-f0-9]{64}$/u.test(options.tokenSha256) ||
473
- !Number.isSafeInteger(maxRecords) || maxRecords <= 0 || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
811
+ if (
812
+ !options.path ||
813
+ !options.profileName ||
814
+ !/^[a-f0-9]{64}$/u.test(options.tokenSha256) ||
815
+ !Number.isSafeInteger(maxRecords) ||
816
+ maxRecords <= 0 ||
817
+ !Number.isSafeInteger(maxBytes) ||
818
+ maxBytes <= 0
819
+ ) {
474
820
  throw new Error("Telegram channel post journal options are invalid.");
475
821
  }
476
- const empty = (): TelegramChannelPostJournalFile => ({ version: CHANNEL_POST_JOURNAL_VERSION,
477
- profile: options.profileName, tokenSha256: options.tokenSha256, records: [] });
822
+ const empty = (): TelegramChannelPostJournalFile => ({
823
+ version: CHANNEL_POST_JOURNAL_VERSION,
824
+ profile: options.profileName,
825
+ tokenSha256: options.tokenSha256,
826
+ records: [],
827
+ });
478
828
  const read = (): TelegramChannelPostJournalFile => {
479
- let before;
480
829
  try {
481
- before = lstatSync(options.path, { bigint: true });
482
- } catch (error) {
483
- if ((error as { code?: unknown })?.code === "ENOENT") return empty();
484
- throw new TelegramChannelPostJournalError("io", "Could not inspect Telegram channel post journal.", error);
485
- }
486
- const uid = process.getuid?.();
487
- if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK || uid === undefined || !before.isFile() ||
488
- before.isSymbolicLink() || before.uid !== BigInt(uid) || before.nlink !== 1n ||
489
- (before.mode & 0o077n) !== 0n || before.size > BigInt(maxBytes)) {
490
- throw new TelegramChannelPostJournalError(before.size > BigInt(maxBytes) ? "capacity" : "invalid",
491
- "Telegram channel post journal is not a bounded no-follow regular file.");
492
- }
493
- let fd: number | undefined;
494
- try {
495
- fd = openSync(options.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
496
- const opened = fstatSync(fd, { bigint: true });
497
- if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino ||
498
- opened.uid !== before.uid || opened.nlink !== 1n || (opened.mode & 0o077n) !== 0n ||
499
- opened.size !== before.size || opened.mtimeNs !== before.mtimeNs) {
500
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal changed during inspection.");
501
- }
502
- const value = JSON.parse(readFileSync(fd, "utf8")) as unknown;
503
- return parseTelegramChannelPostJournalFile(value, options.profileName, options.tokenSha256);
830
+ const text = readTelegramPrivateFile(options.path, maxBytes);
831
+ return text === undefined
832
+ ? empty()
833
+ : parseTelegramChannelPostJournalFile(
834
+ JSON.parse(text) as unknown,
835
+ options.profileName,
836
+ options.tokenSha256,
837
+ );
504
838
  } catch (error) {
505
839
  if (error instanceof TelegramChannelPostJournalError) throw error;
506
- throw new TelegramChannelPostJournalError("io", "Could not read Telegram channel post journal.", error);
507
- } finally {
508
- if (fd !== undefined) closeSync(fd);
840
+ if (error instanceof TelegramPrivateFileError)
841
+ throw new TelegramChannelPostJournalError(
842
+ error.failure === "capacity"
843
+ ? "capacity"
844
+ : error.failure === "changed"
845
+ ? "conflict"
846
+ : "invalid",
847
+ "Telegram channel post journal is not a bounded no-follow regular file.",
848
+ error,
849
+ );
850
+ throw new TelegramChannelPostJournalError(
851
+ "io",
852
+ "Could not read Telegram channel post journal.",
853
+ error,
854
+ );
509
855
  }
510
856
  };
511
857
  const publish = (file: TelegramChannelPostJournalFile): void => {
512
858
  if (file.records.length > maxRecords) {
513
- throw new TelegramChannelPostJournalError("capacity", "Telegram channel post journal record limit reached.");
859
+ throw new TelegramChannelPostJournalError(
860
+ "capacity",
861
+ "Telegram channel post journal record limit reached.",
862
+ );
514
863
  }
515
864
  const serialized = `${JSON.stringify(file, null, 2)}\n`;
516
865
  if (Buffer.byteLength(serialized) > maxBytes) {
517
- throw new TelegramChannelPostJournalError("capacity", "Telegram channel post journal byte limit reached.");
518
- }
519
- const temporaryPath = `${publicationPaths.temporaryBasePath}.${process.pid}.${randomUUID()}.tmp`;
520
- mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
521
- mkdirSync(dirname(temporaryPath), { recursive: true, mode: 0o700 });
522
- try {
523
- writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 });
524
- chmodSync(temporaryPath, 0o600);
525
- if (!renameTelegramPathWithRetry(temporaryPath, options.path)) {
526
- throw new Error("Telegram channel post journal staging file disappeared before publication.");
527
- }
528
- chmodSync(options.path, 0o600);
529
- } finally {
530
- try { unlinkSync(temporaryPath); } catch { /* Atomic rename consumes the temporary path. */ }
866
+ throw new TelegramChannelPostJournalError(
867
+ "capacity",
868
+ "Telegram channel post journal byte limit reached.",
869
+ );
531
870
  }
871
+ publishTelegramPrivateFile(
872
+ options.path,
873
+ publicationPaths.temporaryBasePath,
874
+ serialized,
875
+ "Telegram channel post journal",
876
+ );
532
877
  };
533
- const mutate = <T>(operation: (file: TelegramChannelPostJournalFile) => T): T => {
878
+ // Each transition stamps one clock reading that may not run behind the record's prior transition.
879
+ const readClock = (floorMs = -Infinity): number => {
880
+ const atMs = now();
881
+ if (!isSafeTime(atMs) || atMs < floorMs)
882
+ throw new TelegramChannelPostJournalError(
883
+ "invalid",
884
+ "Telegram channel post clock is invalid.",
885
+ );
886
+ return atMs;
887
+ };
888
+ const replaceRecord = (
889
+ file: TelegramChannelPostJournalFile,
890
+ index: number,
891
+ record: TelegramChannelPostRecord,
892
+ ): void => {
893
+ const records = [...file.records];
894
+ records[index] = record;
895
+ publish({ ...file, records });
896
+ };
897
+ const mutate = <T>(
898
+ operation: (file: TelegramChannelPostJournalFile) => T,
899
+ ): T => {
534
900
  try {
535
- return withTelegramFileTransaction(publicationPaths.transactionPath, () => operation(read()));
901
+ return withTelegramFileTransaction(publicationPaths.transactionPath, () =>
902
+ operation(read()),
903
+ );
536
904
  } catch (error) {
537
905
  if (error instanceof TelegramChannelPostJournalError) throw error;
538
- throw new TelegramChannelPostJournalError("io", "Telegram channel post journal mutation failed.", error);
906
+ throw new TelegramChannelPostJournalError(
907
+ "io",
908
+ "Telegram channel post journal mutation failed.",
909
+ error,
910
+ );
539
911
  }
540
912
  };
541
913
  return {
542
914
  prepare(input) {
543
915
  const operationId = input.operationId;
544
916
  const channel = normalizeChannel(input.channel);
545
- const media = input.media === undefined ? undefined
546
- : validateTelegramChannelPostMedia(input.media);
547
- if (typeof operationId !== "string" || operationId.length === 0 || operationId.length > MAX_ID_LENGTH ||
548
- typeof input.markdown !== "string" || input.markdown.length === 0 ||
549
- input.markdown.length > MAX_MARKDOWN_LENGTH) {
550
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post intent is invalid.");
917
+ const media =
918
+ input.media === undefined
919
+ ? undefined
920
+ : validateTelegramChannelPostMedia(input.media);
921
+ if (
922
+ typeof operationId !== "string" ||
923
+ operationId.length === 0 ||
924
+ operationId.length > MAX_ID_LENGTH ||
925
+ typeof input.markdown !== "string" ||
926
+ input.markdown.length === 0 ||
927
+ input.markdown.length > MAX_MARKDOWN_LENGTH
928
+ ) {
929
+ throw new TelegramChannelPostJournalError(
930
+ "invalid",
931
+ "Telegram channel post intent is invalid.",
932
+ );
551
933
  }
552
- return mutate(file => {
553
- const existing = file.records.find(record => record.operationId === operationId);
934
+ return mutate((file) => {
935
+ const existing = file.records.find(
936
+ (record) => record.operationId === operationId,
937
+ );
554
938
  if (existing) {
555
- if (existing.requestedChannel !== channel || existing.markdown !== input.markdown ||
556
- !sameTelegramChannelPostMedia(existing.media, media)) {
557
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post operation conflicts with retained intent.");
939
+ if (
940
+ existing.requestedChannel !== channel ||
941
+ existing.markdown !== input.markdown ||
942
+ !sameTelegramChannelPostMedia(existing.media, media)
943
+ ) {
944
+ throw new TelegramChannelPostJournalError(
945
+ "conflict",
946
+ "Telegram channel post operation conflicts with retained intent.",
947
+ );
558
948
  }
559
949
  return { prepared: false, record: structuredClone(existing) };
560
950
  }
561
- const atMs = now();
562
- if (!isSafeTime(atMs)) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
563
- const record: TelegramChannelPostRecord = { operationId, requestedChannel: channel,
564
- markdown: input.markdown, createdAtMs: atMs, updatedAtMs: atMs, state: "prepared",
565
- ...(media === undefined ? {} : { media }) };
951
+ const atMs = readClock();
952
+ const record: TelegramChannelPostRecord = {
953
+ operationId,
954
+ requestedChannel: channel,
955
+ markdown: input.markdown,
956
+ createdAtMs: atMs,
957
+ updatedAtMs: atMs,
958
+ state: "prepared",
959
+ ...(media === undefined ? {} : { media }),
960
+ };
566
961
  publish({ ...file, records: [...file.records, record] });
567
962
  return { prepared: true, record: structuredClone(record) };
568
963
  });
569
964
  },
570
965
  beginPublication(operationId) {
571
- return mutate(file => {
572
- const index = file.records.findIndex(record => record.operationId === operationId);
573
- if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post intent is missing.");
574
- const current = file.records[index]!;
575
- if (current.state !== "prepared") return { began: false, record: structuredClone(current) };
576
- const atMs = now();
577
- if (!isSafeTime(atMs) || atMs < current.createdAtMs) {
578
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
579
- }
580
- const record: TelegramChannelPostRecord = { ...current, state: "outcome-unknown",
581
- issuedAtMs: atMs, updatedAtMs: atMs };
582
- const records = [...file.records]; records[index] = record; publish({ ...file, records });
966
+ return mutate((file) => {
967
+ const [index, current] = findTelegramChannelPostRecord(
968
+ file,
969
+ operationId,
970
+ "Telegram channel post intent is missing.",
971
+ );
972
+ if (current.state !== "prepared")
973
+ return { began: false, record: structuredClone(current) };
974
+ const atMs = readClock(current.createdAtMs);
975
+ const record: TelegramChannelPostRecord = {
976
+ ...current,
977
+ state: "outcome-unknown",
978
+ issuedAtMs: atMs,
979
+ updatedAtMs: atMs,
980
+ };
981
+ replaceRecord(file, index, record);
583
982
  return { began: true, record: structuredClone(record) };
584
983
  });
585
984
  },
586
985
  confirmPublished(input) {
587
- return mutate(file => {
588
- const index = file.records.findIndex(record => record.operationId === input.operationId);
589
- if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post intent is missing.");
590
- const current = file.records[index]!;
986
+ return mutate((file) => {
987
+ const [index, current] = findTelegramChannelPostRecord(
988
+ file,
989
+ input.operationId,
990
+ "Telegram channel post intent is missing.",
991
+ );
591
992
  if (current.state === "published") {
592
- if (current.channelId !== input.channelId || current.messageId !== input.messageId ||
593
- current.channelUsername !== input.channelUsername ||
594
- current.channelTitle !== input.channelTitle) {
595
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post confirmation conflicts with retained identity.");
993
+ if (
994
+ current.channelId !== input.channelId ||
995
+ current.messageId !== input.messageId ||
996
+ current.channelUsername !== input.channelUsername ||
997
+ current.channelTitle !== input.channelTitle
998
+ ) {
999
+ throw new TelegramChannelPostJournalError(
1000
+ "conflict",
1001
+ "Telegram channel post confirmation conflicts with retained identity.",
1002
+ );
596
1003
  }
597
1004
  return { confirmed: false, record: structuredClone(current) };
598
1005
  }
599
- if (current.state !== "outcome-unknown" || !Number.isSafeInteger(input.channelId) || input.channelId >= 0 ||
600
- !Number.isSafeInteger(input.messageId) || input.messageId <= 0 ||
601
- (input.channelUsername !== undefined && !/^@[A-Za-z0-9_]{5,32}$/u.test(input.channelUsername)) ||
602
- (input.channelTitle !== undefined && (input.channelTitle.length === 0 ||
603
- input.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))) {
604
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post confirmation is invalid or premature.");
1006
+ if (
1007
+ current.state !== "outcome-unknown" ||
1008
+ !Number.isSafeInteger(input.channelId) ||
1009
+ input.channelId >= 0 ||
1010
+ !Number.isSafeInteger(input.messageId) ||
1011
+ input.messageId <= 0 ||
1012
+ (input.channelUsername !== undefined &&
1013
+ !/^@[A-Za-z0-9_]{5,32}$/u.test(input.channelUsername)) ||
1014
+ (input.channelTitle !== undefined &&
1015
+ (input.channelTitle.length === 0 ||
1016
+ input.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))
1017
+ ) {
1018
+ throw new TelegramChannelPostJournalError(
1019
+ "conflict",
1020
+ "Telegram channel post confirmation is invalid or premature.",
1021
+ );
605
1022
  }
606
- const atMs = now();
607
- if (!isSafeTime(atMs) || atMs < current.issuedAtMs) {
608
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
609
- }
610
- const record: TelegramChannelPostRecord = { ...current, state: "published",
611
- publishedAtMs: atMs, updatedAtMs: atMs, channelId: input.channelId,
612
- messageId: input.messageId, ...(input.channelUsername ? { channelUsername: input.channelUsername } : {}),
613
- ...(input.channelTitle ? { channelTitle: input.channelTitle } : {}) };
614
- const records = [...file.records]; records[index] = record; publish({ ...file, records });
1023
+ const atMs = readClock(current.issuedAtMs);
1024
+ const record: TelegramChannelPostRecord = {
1025
+ ...current,
1026
+ state: "published",
1027
+ publishedAtMs: atMs,
1028
+ updatedAtMs: atMs,
1029
+ channelId: input.channelId,
1030
+ messageId: input.messageId,
1031
+ ...(input.channelUsername
1032
+ ? { channelUsername: input.channelUsername }
1033
+ : {}),
1034
+ ...(input.channelTitle ? { channelTitle: input.channelTitle } : {}),
1035
+ };
1036
+ replaceRecord(file, index, record);
615
1037
  return { confirmed: true, record: structuredClone(record) };
616
1038
  });
617
1039
  },
618
1040
  beginEdit(input) {
619
- return mutate(file => {
620
- const index = file.records.findIndex(record => record.operationId === input.operationId);
621
- if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
622
- const current = file.records[index]!;
1041
+ return mutate((file) => {
1042
+ const [index, current] = findTelegramChannelPostRecord(
1043
+ file,
1044
+ input.operationId,
1045
+ "Telegram channel post is missing.",
1046
+ );
623
1047
  if (current.state === "edit-outcome-unknown") {
624
- if (current.mutationId === input.mutationId && current.attemptedMarkdown === input.markdown)
1048
+ if (
1049
+ current.mutationId === input.mutationId &&
1050
+ current.attemptedMarkdown === input.markdown
1051
+ )
625
1052
  return { began: false, record: structuredClone(current) };
626
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post already has an unresolved mutation.");
1053
+ throw new TelegramChannelPostJournalError(
1054
+ "conflict",
1055
+ "Telegram channel post already has an unresolved mutation.",
1056
+ );
627
1057
  }
628
- if (current.state === "published" && current.lastMutationId === input.mutationId) {
629
- if (current.markdown === input.markdown) return { began: false, record: structuredClone(current) };
630
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post mutation identity conflicts with retained edit.");
1058
+ if (
1059
+ current.state === "published" &&
1060
+ current.lastMutationId === input.mutationId
1061
+ ) {
1062
+ if (current.markdown === input.markdown)
1063
+ return { began: false, record: structuredClone(current) };
1064
+ throw new TelegramChannelPostJournalError(
1065
+ "conflict",
1066
+ "Telegram channel post mutation identity conflicts with retained edit.",
1067
+ );
631
1068
  }
632
- if (current.state !== "published" || !input.mutationId || input.mutationId.length > MAX_ID_LENGTH ||
633
- !input.markdown || input.markdown.length > MAX_MARKDOWN_LENGTH) {
634
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post edit is invalid or unavailable.");
1069
+ if (
1070
+ current.state !== "published" ||
1071
+ !input.mutationId ||
1072
+ input.mutationId.length > MAX_ID_LENGTH ||
1073
+ !input.markdown ||
1074
+ input.markdown.length > MAX_MARKDOWN_LENGTH
1075
+ ) {
1076
+ throw new TelegramChannelPostJournalError(
1077
+ "conflict",
1078
+ "Telegram channel post edit is invalid or unavailable.",
1079
+ );
635
1080
  }
636
- const atMs = now();
637
- if (!isSafeTime(atMs) || atMs < current.updatedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
638
- const record: TelegramChannelPostRecord = { ...current, state: "edit-outcome-unknown",
639
- mutationId: input.mutationId, attemptedMarkdown: input.markdown,
640
- mutationIssuedAtMs: atMs, updatedAtMs: atMs };
641
- const records = [...file.records]; records[index] = record; publish({ ...file, records });
1081
+ const atMs = readClock(current.updatedAtMs);
1082
+ const record: TelegramChannelPostRecord = {
1083
+ ...current,
1084
+ state: "edit-outcome-unknown",
1085
+ mutationId: input.mutationId,
1086
+ attemptedMarkdown: input.markdown,
1087
+ mutationIssuedAtMs: atMs,
1088
+ updatedAtMs: atMs,
1089
+ };
1090
+ replaceRecord(file, index, record);
642
1091
  return { began: true, record: structuredClone(record) };
643
1092
  });
644
1093
  },
645
1094
  confirmEdited(input) {
646
- return mutate(file => {
647
- const index = file.records.findIndex(record => record.operationId === input.operationId);
648
- if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
649
- const current = file.records[index]!;
650
- if (current.state === "published" && current.lastMutationId === input.mutationId)
1095
+ return mutate((file) => {
1096
+ const [index, current] = findTelegramChannelPostRecord(
1097
+ file,
1098
+ input.operationId,
1099
+ "Telegram channel post is missing.",
1100
+ );
1101
+ if (
1102
+ current.state === "published" &&
1103
+ current.lastMutationId === input.mutationId
1104
+ )
651
1105
  return { confirmed: false, record: structuredClone(current) };
652
- if (current.state !== "edit-outcome-unknown" || current.mutationId !== input.mutationId)
653
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post edit confirmation is stale.");
654
- const atMs = now();
655
- if (!isSafeTime(atMs) || atMs < current.mutationIssuedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
656
- const { mutationId, attemptedMarkdown, mutationIssuedAtMs, ...prior } = current;
657
- const record: TelegramChannelPostRecord = { ...prior, state: "published", markdown: attemptedMarkdown,
658
- lastMutationId: mutationId, updatedAtMs: atMs };
659
- const records = [...file.records]; records[index] = record; publish({ ...file, records });
1106
+ if (
1107
+ current.state !== "edit-outcome-unknown" ||
1108
+ current.mutationId !== input.mutationId
1109
+ )
1110
+ throw new TelegramChannelPostJournalError(
1111
+ "conflict",
1112
+ "Telegram channel post edit confirmation is stale.",
1113
+ );
1114
+ const atMs = readClock(current.mutationIssuedAtMs);
1115
+ const { mutationId, attemptedMarkdown, mutationIssuedAtMs, ...prior } =
1116
+ current;
1117
+ const record: TelegramChannelPostRecord = {
1118
+ ...prior,
1119
+ state: "published",
1120
+ markdown: attemptedMarkdown,
1121
+ lastMutationId: mutationId,
1122
+ updatedAtMs: atMs,
1123
+ };
1124
+ replaceRecord(file, index, record);
660
1125
  return { confirmed: true, record: structuredClone(record) };
661
1126
  });
662
1127
  },
663
1128
  beginDelete(input) {
664
- return mutate(file => {
665
- const index = file.records.findIndex(record => record.operationId === input.operationId);
666
- if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
667
- const current = file.records[index]!;
1129
+ return mutate((file) => {
1130
+ const [index, current] = findTelegramChannelPostRecord(
1131
+ file,
1132
+ input.operationId,
1133
+ "Telegram channel post is missing.",
1134
+ );
668
1135
  if (current.state === "delete-outcome-unknown") {
669
- if (current.mutationId === input.mutationId) return { began: false, record: structuredClone(current) };
670
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post already has an unresolved deletion.");
1136
+ if (current.mutationId === input.mutationId)
1137
+ return { began: false, record: structuredClone(current) };
1138
+ throw new TelegramChannelPostJournalError(
1139
+ "conflict",
1140
+ "Telegram channel post already has an unresolved deletion.",
1141
+ );
671
1142
  }
672
- if (current.state === "deleted" && current.mutationId === input.mutationId)
1143
+ if (
1144
+ current.state === "deleted" &&
1145
+ current.mutationId === input.mutationId
1146
+ )
673
1147
  return { began: false, record: structuredClone(current) };
674
- if (current.state !== "published" || !input.mutationId || input.mutationId.length > MAX_ID_LENGTH)
675
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post deletion is invalid or unavailable.");
676
- const atMs = now();
677
- if (!isSafeTime(atMs) || atMs < current.updatedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
678
- const record: TelegramChannelPostRecord = { ...current, state: "delete-outcome-unknown",
679
- mutationId: input.mutationId, mutationIssuedAtMs: atMs, updatedAtMs: atMs };
680
- const records = [...file.records]; records[index] = record; publish({ ...file, records });
1148
+ if (
1149
+ current.state !== "published" ||
1150
+ !input.mutationId ||
1151
+ input.mutationId.length > MAX_ID_LENGTH
1152
+ )
1153
+ throw new TelegramChannelPostJournalError(
1154
+ "conflict",
1155
+ "Telegram channel post deletion is invalid or unavailable.",
1156
+ );
1157
+ const atMs = readClock(current.updatedAtMs);
1158
+ const record: TelegramChannelPostRecord = {
1159
+ ...current,
1160
+ state: "delete-outcome-unknown",
1161
+ mutationId: input.mutationId,
1162
+ mutationIssuedAtMs: atMs,
1163
+ updatedAtMs: atMs,
1164
+ };
1165
+ replaceRecord(file, index, record);
681
1166
  return { began: true, record: structuredClone(record) };
682
1167
  });
683
1168
  },
684
1169
  confirmDeleted(input) {
685
- return mutate(file => {
686
- const index = file.records.findIndex(record => record.operationId === input.operationId);
687
- if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
688
- const current = file.records[index]!;
689
- if (current.state === "deleted" && current.mutationId === input.mutationId)
1170
+ return mutate((file) => {
1171
+ const [index, current] = findTelegramChannelPostRecord(
1172
+ file,
1173
+ input.operationId,
1174
+ "Telegram channel post is missing.",
1175
+ );
1176
+ if (
1177
+ current.state === "deleted" &&
1178
+ current.mutationId === input.mutationId
1179
+ )
690
1180
  return { confirmed: false, record: structuredClone(current) };
691
- if (current.state !== "delete-outcome-unknown" || current.mutationId !== input.mutationId)
692
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post deletion confirmation is stale.");
693
- const atMs = now();
694
- if (!isSafeTime(atMs) || atMs < current.mutationIssuedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
1181
+ if (
1182
+ current.state !== "delete-outcome-unknown" ||
1183
+ current.mutationId !== input.mutationId
1184
+ )
1185
+ throw new TelegramChannelPostJournalError(
1186
+ "conflict",
1187
+ "Telegram channel post deletion confirmation is stale.",
1188
+ );
1189
+ const atMs = readClock(current.mutationIssuedAtMs);
695
1190
  const { mutationIssuedAtMs, ...prior } = current;
696
- const record: TelegramChannelPostRecord = { ...prior, state: "deleted", deletedAtMs: atMs, updatedAtMs: atMs };
697
- const records = [...file.records]; records[index] = record; publish({ ...file, records });
1191
+ const record: TelegramChannelPostRecord = {
1192
+ ...prior,
1193
+ state: "deleted",
1194
+ deletedAtMs: atMs,
1195
+ updatedAtMs: atMs,
1196
+ };
1197
+ replaceRecord(file, index, record);
698
1198
  return { confirmed: true, record: structuredClone(record) };
699
1199
  });
700
1200
  },
701
1201
  get(operationId) {
702
- if (typeof operationId !== "string" || operationId.length === 0 || operationId.length > MAX_ID_LENGTH) {
703
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post operation ID is invalid.");
1202
+ if (
1203
+ typeof operationId !== "string" ||
1204
+ operationId.length === 0 ||
1205
+ operationId.length > MAX_ID_LENGTH
1206
+ ) {
1207
+ throw new TelegramChannelPostJournalError(
1208
+ "invalid",
1209
+ "Telegram channel post operation ID is invalid.",
1210
+ );
704
1211
  }
705
- const record = read().records.find(candidate => candidate.operationId === operationId);
1212
+ const record = read().records.find(
1213
+ (candidate) => candidate.operationId === operationId,
1214
+ );
706
1215
  return record === undefined ? undefined : structuredClone(record);
707
1216
  },
708
1217
  list(input = {}) {
709
1218
  const limit = input.limit ?? 20;
710
1219
  if (!Number.isSafeInteger(limit) || limit <= 0 || limit > maxRecords) {
711
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post list limit is invalid.");
1220
+ throw new TelegramChannelPostJournalError(
1221
+ "invalid",
1222
+ "Telegram channel post list limit is invalid.",
1223
+ );
712
1224
  }
713
- const channel = input.channel === undefined ? undefined : normalizeChannel(input.channel);
714
- return read().records.filter(record => channel === undefined || record.requestedChannel === channel)
715
- .slice(-limit).reverse().map(record => structuredClone(record));
1225
+ const channel =
1226
+ input.channel === undefined
1227
+ ? undefined
1228
+ : normalizeChannel(input.channel);
1229
+ return read()
1230
+ .records.filter(
1231
+ (record) =>
1232
+ channel === undefined || record.requestedChannel === channel,
1233
+ )
1234
+ .slice(-limit)
1235
+ .reverse()
1236
+ .map((record) => structuredClone(record));
716
1237
  },
717
1238
  };
718
1239
  }