@llblab/pi-telegram 0.52.2 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/AGENTS.md +9 -7
  2. package/BACKLOG.md +1 -18
  3. package/CHANGELOG.md +11 -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 +61 -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 +249 -126
  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 +125 -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 +511 -300
  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
@@ -3,14 +3,15 @@
3
3
  * Zones: telegram outbound, filesystem authority
4
4
  * Owns publication intent, outcome-unknown fencing, confirmed post identity, and bounded local listing
5
5
  */
6
- import { chmodSync, closeSync, constants, createReadStream, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { createHash } from "node:crypto";
7
+ import { createReadStream } from "node:fs";
7
8
  import { lstat } from "node:fs/promises";
8
- import { basename, dirname } from "node:path";
9
- import { getTelegramJournalPublicationPaths } from "./paths.js";
10
- import { createHash, randomUUID } from "node:crypto";
9
+ import { basename } from "node:path";
11
10
  import { Type } from "@sinclair/typebox";
12
- import { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.js";
13
- import { isWireRecord as isRecord, hasOnlyWireKeys as hasOnlyKeys, isNonNegativeWireInteger as isSafeTime, } from "./wire.js";
11
+ import { createTelegramUpdateJournalBotIdentity } from "./journal.js";
12
+ import { getTelegramJournalPublicationPaths, resolveTelegramServiceJournalStorage, } from "./paths.js";
13
+ import { publishTelegramPrivateFile, readTelegramPrivateFile, TelegramPrivateFileError, withTelegramFileTransaction, } from "./locks.js";
14
+ import { hasOnlyWireKeys as hasOnlyKeys, isWireRecord as isRecord, isNonNegativeWireInteger as isSafeTime, } from "./wire.js";
14
15
  const CHANNEL_POST_JOURNAL_VERSION = 1;
15
16
  const DEFAULT_MAX_RECORDS = 256;
16
17
  const DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
@@ -33,7 +34,12 @@ export class TelegramChannelPostValidationError extends Error {
33
34
  export function isTelegramChannelPostValidationError(error) {
34
35
  return error instanceof TelegramChannelPostValidationError;
35
36
  }
36
- const TELEGRAM_CHANNEL_POST_PHOTO_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
37
+ const TELEGRAM_CHANNEL_POST_PHOTO_EXTENSIONS = new Set([
38
+ ".jpg",
39
+ ".jpeg",
40
+ ".png",
41
+ ".webp",
42
+ ]);
37
43
  const TELEGRAM_CHANNEL_POST_VIDEO_EXTENSIONS = new Set([".mp4"]);
38
44
  export function resolveTelegramChannelPostMediaKind(path) {
39
45
  if (typeof path !== "string" || path.length === 0)
@@ -83,8 +89,12 @@ export async function inspectTelegramChannelPostMedia(path) {
83
89
  throw new TelegramChannelPostValidationError("Channel media upload requires one regular local file without symbolic links.");
84
90
  }
85
91
  assertTelegramChannelPostMediaSize(kind, stats.size);
86
- return { kind, fileName: basename(path), sizeBytes: stats.size,
87
- sha256: await hashTelegramChannelPostMedia(path) };
92
+ return {
93
+ kind,
94
+ fileName: basename(path),
95
+ sizeBytes: stats.size,
96
+ sha256: await hashTelegramChannelPostMedia(path),
97
+ };
88
98
  }
89
99
  export function getTelegramChannelPostCaptionLength(caption) {
90
100
  const visible = caption
@@ -92,7 +102,7 @@ export function getTelegramChannelPostCaptionLength(caption) {
92
102
  .replace(/<[^>]*>/gu, "")
93
103
  .replace(/&lt;/gu, "<")
94
104
  .replace(/&gt;/gu, ">")
95
- .replace(/&quot;/gu, "\"")
105
+ .replace(/&quot;/gu, '"')
96
106
  .replace(/&#39;/gu, "'")
97
107
  .replace(/&amp;/gu, "&");
98
108
  return visible.length;
@@ -113,15 +123,23 @@ export class TelegramChannelPostJournalError extends Error {
113
123
  }
114
124
  export async function publishTelegramChannelPost(input) {
115
125
  const observed = await input.observeChannel(input.channel);
116
- if (observed.type !== "channel" || !Number.isSafeInteger(observed.id) || observed.id >= 0 ||
126
+ if (observed.type !== "channel" ||
127
+ !Number.isSafeInteger(observed.id) ||
128
+ observed.id >= 0 ||
117
129
  (typeof input.channel === "number" && observed.id !== input.channel) ||
118
- (observed.username !== undefined && !/^[A-Za-z0-9_]{5,32}$/u.test(observed.username)) ||
119
- (observed.title !== undefined && (observed.title.length === 0 ||
120
- observed.title.length > MAX_CHANNEL_TITLE_LENGTH))) {
130
+ (observed.username !== undefined &&
131
+ !/^[A-Za-z0-9_]{5,32}$/u.test(observed.username)) ||
132
+ (observed.title !== undefined &&
133
+ (observed.title.length === 0 ||
134
+ observed.title.length > MAX_CHANNEL_TITLE_LENGTH))) {
121
135
  throw new Error("Telegram channel delivery requires bounded exact getChat channel identity.");
122
136
  }
123
- input.store.prepare({ operationId: input.operationId, channel: input.channel,
124
- markdown: input.markdown, ...(input.media === undefined ? {} : { media: input.media }) });
137
+ input.store.prepare({
138
+ operationId: input.operationId,
139
+ channel: input.channel,
140
+ markdown: input.markdown,
141
+ ...(input.media === undefined ? {} : { media: input.media }),
142
+ });
125
143
  const issuance = input.store.beginPublication(input.operationId);
126
144
  if (!issuance.began) {
127
145
  if (issuance.record.state === "published")
@@ -132,10 +150,15 @@ export async function publishTelegramChannelPost(input) {
132
150
  if (sent.chat.type !== "channel" || sent.chat.id !== observed.id) {
133
151
  throw new Error("Telegram channel post response identity did not match the verified channel.");
134
152
  }
135
- return input.store.confirmPublished({ operationId: input.operationId,
136
- channelId: sent.chat.id, messageId: sent.messageId,
137
- ...(observed.username ? { channelUsername: `@${observed.username}` } : {}),
138
- ...(observed.title ? { channelTitle: observed.title } : {}) }).record;
153
+ return input.store.confirmPublished({
154
+ operationId: input.operationId,
155
+ channelId: sent.chat.id,
156
+ messageId: sent.messageId,
157
+ ...(observed.username
158
+ ? { channelUsername: `@${observed.username}` }
159
+ : {}),
160
+ ...(observed.title ? { channelTitle: observed.title } : {}),
161
+ }).record;
139
162
  }
140
163
  function formatTelegramChannelPostToolOutput(value) {
141
164
  // Pi's compact tool rows need one leading newline to separate call and result.
@@ -157,10 +180,23 @@ export function registerTelegramChannelPostMutationTool(pi, deps) {
157
180
  }),
158
181
  async execute(toolCallId, params) {
159
182
  try {
160
- const record = await deps.mutate({ action: params.action, operationId: params.operation_id,
161
- mutationId: toolCallId, ...(params.markdown === undefined ? {} : { markdown: params.markdown }) });
162
- return { content: [{ type: "text", text: formatTelegramChannelPostToolOutput(record) }],
163
- details: { record } };
183
+ const record = await deps.mutate({
184
+ action: params.action,
185
+ operationId: params.operation_id,
186
+ mutationId: toolCallId,
187
+ ...(params.markdown === undefined
188
+ ? {}
189
+ : { markdown: params.markdown }),
190
+ });
191
+ return {
192
+ content: [
193
+ {
194
+ type: "text",
195
+ text: formatTelegramChannelPostToolOutput(record),
196
+ },
197
+ ],
198
+ details: { record },
199
+ };
164
200
  }
165
201
  catch (error) {
166
202
  if (isTelegramChannelPostValidationError(error)) {
@@ -178,7 +214,8 @@ export function registerTelegramChannelPostListTool(pi, deps) {
178
214
  description: "List bounded local records for channel posts authored by this agent path. This does not read Telegram channel history.",
179
215
  parameters: Type.Object({
180
216
  chat_id: Type.Optional(Type.Union([
181
- Type.Number(), Type.String({ pattern: "^@[A-Za-z0-9_]{5,32}$" }),
217
+ Type.Number(),
218
+ Type.String({ pattern: "^@[A-Za-z0-9_]{5,32}$" }),
182
219
  ])),
183
220
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_RECORDS })),
184
221
  }),
@@ -188,8 +225,15 @@ export function registerTelegramChannelPostListTool(pi, deps) {
188
225
  channel: params.chat_id,
189
226
  limit: params.limit,
190
227
  });
191
- return { content: [{ type: "text", text: formatTelegramChannelPostToolOutput(records) }],
192
- details: { records } };
228
+ return {
229
+ content: [
230
+ {
231
+ type: "text",
232
+ text: formatTelegramChannelPostToolOutput(records),
233
+ },
234
+ ],
235
+ details: { records },
236
+ };
193
237
  }
194
238
  catch {
195
239
  throw new Error("\nTelegram channel post listing failed without exposing retained content or storage details.");
@@ -206,115 +250,236 @@ function normalizeChannel(value) {
206
250
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post requires an exact negative channel ID or public @username.");
207
251
  }
208
252
  function validateTelegramChannelPostMedia(value) {
209
- if (!isRecord(value) || !hasOnlyKeys(value, ["kind", "fileName", "sizeBytes", "sha256"]) ||
253
+ if (!isRecord(value) ||
254
+ !hasOnlyKeys(value, ["kind", "fileName", "sizeBytes", "sha256"]) ||
210
255
  (value.kind !== "photo" && value.kind !== "video") ||
211
- typeof value.fileName !== "string" || value.fileName.length === 0 ||
256
+ typeof value.fileName !== "string" ||
257
+ value.fileName.length === 0 ||
212
258
  value.fileName.length > TELEGRAM_CHANNEL_POST_MEDIA_FILE_NAME_MAX_LENGTH ||
213
- !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 ||
214
- value.sizeBytes > TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES[value.kind] ||
215
- typeof value.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sha256)) {
259
+ !Number.isSafeInteger(value.sizeBytes) ||
260
+ value.sizeBytes <= 0 ||
261
+ value.sizeBytes >
262
+ TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES[value.kind] ||
263
+ typeof value.sha256 !== "string" ||
264
+ !/^[a-f0-9]{64}$/u.test(value.sha256)) {
216
265
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains an invalid media intent.");
217
266
  }
218
- return { kind: value.kind, fileName: value.fileName,
219
- sizeBytes: value.sizeBytes, sha256: value.sha256 };
267
+ return {
268
+ kind: value.kind,
269
+ fileName: value.fileName,
270
+ sizeBytes: value.sizeBytes,
271
+ sha256: value.sha256,
272
+ };
220
273
  }
221
274
  function sameTelegramChannelPostMedia(left, right) {
222
275
  if (left === undefined || right === undefined)
223
276
  return left === right;
224
- return left.kind === right.kind && left.fileName === right.fileName &&
225
- left.sizeBytes === right.sizeBytes && left.sha256 === right.sha256;
277
+ return (left.kind === right.kind &&
278
+ left.fileName === right.fileName &&
279
+ left.sizeBytes === right.sizeBytes &&
280
+ left.sha256 === right.sha256);
281
+ }
282
+ /** Evidence that only exists once Telegram has confirmed a publication. */
283
+ const TELEGRAM_CHANNEL_POST_PUBLICATION_FIELDS = [
284
+ "publishedAtMs",
285
+ "channelId",
286
+ "messageId",
287
+ "channelUsername",
288
+ "mutationId",
289
+ "attemptedMarkdown",
290
+ "mutationIssuedAtMs",
291
+ "deletedAtMs",
292
+ "lastMutationId",
293
+ "channelTitle",
294
+ ];
295
+ function lacksTelegramChannelPostFields(value, fields) {
296
+ return fields.every((field) => value[field] === undefined);
226
297
  }
227
298
  function validateRecord(value) {
228
- if (!isRecord(value) || !hasOnlyKeys(value, ["operationId", "requestedChannel", "markdown",
229
- "media", "createdAtMs", "updatedAtMs", "state", "issuedAtMs", "publishedAtMs", "channelId",
230
- "messageId", "channelUsername", "mutationId", "attemptedMarkdown",
231
- "mutationIssuedAtMs", "deletedAtMs", "lastMutationId", "channelTitle"]) || typeof value.operationId !== "string" ||
232
- value.operationId.length === 0 || value.operationId.length > MAX_ID_LENGTH ||
233
- typeof value.markdown !== "string" || value.markdown.length === 0 ||
234
- value.markdown.length > MAX_MARKDOWN_LENGTH || !isSafeTime(value.createdAtMs) ||
235
- !isSafeTime(value.updatedAtMs) || value.updatedAtMs < value.createdAtMs) {
299
+ if (!isRecord(value) ||
300
+ !hasOnlyKeys(value, [
301
+ "operationId",
302
+ "requestedChannel",
303
+ "markdown",
304
+ "media",
305
+ "createdAtMs",
306
+ "updatedAtMs",
307
+ "state",
308
+ "issuedAtMs",
309
+ "publishedAtMs",
310
+ "channelId",
311
+ "messageId",
312
+ "channelUsername",
313
+ "mutationId",
314
+ "attemptedMarkdown",
315
+ "mutationIssuedAtMs",
316
+ "deletedAtMs",
317
+ "lastMutationId",
318
+ "channelTitle",
319
+ ]) ||
320
+ typeof value.operationId !== "string" ||
321
+ value.operationId.length === 0 ||
322
+ value.operationId.length > MAX_ID_LENGTH ||
323
+ typeof value.markdown !== "string" ||
324
+ value.markdown.length === 0 ||
325
+ value.markdown.length > MAX_MARKDOWN_LENGTH ||
326
+ !isSafeTime(value.createdAtMs) ||
327
+ !isSafeTime(value.updatedAtMs) ||
328
+ value.updatedAtMs < value.createdAtMs) {
236
329
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains an invalid record.");
237
330
  }
238
- const base = { operationId: value.operationId,
239
- requestedChannel: normalizeChannel(value.requestedChannel), markdown: value.markdown,
240
- createdAtMs: value.createdAtMs, updatedAtMs: value.updatedAtMs,
241
- ...(value.media === undefined ? {} : { media: validateTelegramChannelPostMedia(value.media) }) };
242
- if (value.state === "prepared" && value.issuedAtMs === undefined && value.publishedAtMs === undefined &&
243
- value.channelId === undefined && value.messageId === undefined && value.channelUsername === undefined &&
244
- value.mutationId === undefined && value.attemptedMarkdown === undefined &&
245
- value.mutationIssuedAtMs === undefined && value.deletedAtMs === undefined &&
246
- value.lastMutationId === undefined && value.channelTitle === undefined) {
331
+ const base = {
332
+ operationId: value.operationId,
333
+ requestedChannel: normalizeChannel(value.requestedChannel),
334
+ markdown: value.markdown,
335
+ createdAtMs: value.createdAtMs,
336
+ updatedAtMs: value.updatedAtMs,
337
+ ...(value.media === undefined
338
+ ? {}
339
+ : { media: validateTelegramChannelPostMedia(value.media) }),
340
+ };
341
+ if (value.state === "prepared" &&
342
+ value.issuedAtMs === undefined &&
343
+ lacksTelegramChannelPostFields(value, TELEGRAM_CHANNEL_POST_PUBLICATION_FIELDS)) {
247
344
  return { ...base, state: "prepared" };
248
345
  }
249
346
  if (!isSafeTime(value.issuedAtMs) || value.issuedAtMs < value.createdAtMs) {
250
347
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid issuance evidence.");
251
348
  }
252
- if (value.state === "outcome-unknown" && value.publishedAtMs === undefined && value.channelId === undefined &&
253
- value.messageId === undefined && value.channelUsername === undefined && value.mutationId === undefined &&
254
- value.attemptedMarkdown === undefined && value.mutationIssuedAtMs === undefined &&
255
- value.deletedAtMs === undefined && value.lastMutationId === undefined &&
256
- value.channelTitle === undefined) {
349
+ if (value.state === "outcome-unknown" &&
350
+ lacksTelegramChannelPostFields(value, TELEGRAM_CHANNEL_POST_PUBLICATION_FIELDS)) {
257
351
  return { ...base, state: "outcome-unknown", issuedAtMs: value.issuedAtMs };
258
352
  }
259
- if (!isSafeTime(value.publishedAtMs) || value.publishedAtMs < value.issuedAtMs ||
260
- !Number.isSafeInteger(value.channelId) || value.channelId >= 0 ||
261
- !Number.isSafeInteger(value.messageId) || value.messageId <= 0 ||
262
- (value.channelUsername !== undefined && (typeof value.channelUsername !== "string" ||
263
- !/^@[A-Za-z0-9_]{5,32}$/u.test(value.channelUsername))) ||
264
- (value.channelTitle !== undefined && (typeof value.channelTitle !== "string" ||
265
- value.channelTitle.length === 0 || value.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))) {
353
+ if (!isSafeTime(value.publishedAtMs) ||
354
+ value.publishedAtMs < value.issuedAtMs ||
355
+ !Number.isSafeInteger(value.channelId) ||
356
+ value.channelId >= 0 ||
357
+ !Number.isSafeInteger(value.messageId) ||
358
+ value.messageId <= 0 ||
359
+ (value.channelUsername !== undefined &&
360
+ (typeof value.channelUsername !== "string" ||
361
+ !/^@[A-Za-z0-9_]{5,32}$/u.test(value.channelUsername))) ||
362
+ (value.channelTitle !== undefined &&
363
+ (typeof value.channelTitle !== "string" ||
364
+ value.channelTitle.length === 0 ||
365
+ value.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))) {
266
366
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid published identity.");
267
367
  }
268
- const identity = { issuedAtMs: value.issuedAtMs,
269
- publishedAtMs: value.publishedAtMs, channelId: value.channelId,
368
+ const identity = {
369
+ issuedAtMs: value.issuedAtMs,
370
+ publishedAtMs: value.publishedAtMs,
371
+ channelId: value.channelId,
270
372
  messageId: value.messageId,
271
- ...(value.channelUsername ? { channelUsername: value.channelUsername } : {}),
272
- ...(typeof value.channelTitle === "string" ? { channelTitle: value.channelTitle } : {}),
273
- ...(typeof value.lastMutationId === "string" && value.lastMutationId.length > 0 &&
274
- value.lastMutationId.length <= MAX_ID_LENGTH ? { lastMutationId: value.lastMutationId } : {}) };
275
- if (value.lastMutationId !== undefined && identity.lastMutationId === undefined) {
373
+ ...(value.channelUsername
374
+ ? { channelUsername: value.channelUsername }
375
+ : {}),
376
+ ...(typeof value.channelTitle === "string"
377
+ ? { channelTitle: value.channelTitle }
378
+ : {}),
379
+ ...(typeof value.lastMutationId === "string" &&
380
+ value.lastMutationId.length > 0 &&
381
+ value.lastMutationId.length <= MAX_ID_LENGTH
382
+ ? { lastMutationId: value.lastMutationId }
383
+ : {}),
384
+ };
385
+ if (value.lastMutationId !== undefined &&
386
+ identity.lastMutationId === undefined) {
276
387
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid mutation identity.");
277
388
  }
278
- if (value.state === "published" && value.mutationId === undefined &&
279
- value.attemptedMarkdown === undefined && value.mutationIssuedAtMs === undefined &&
389
+ if (value.state === "published" &&
390
+ value.mutationId === undefined &&
391
+ value.attemptedMarkdown === undefined &&
392
+ value.mutationIssuedAtMs === undefined &&
280
393
  value.deletedAtMs === undefined)
281
394
  return { ...base, ...identity, state: "published" };
282
- const validMutation = typeof value.mutationId === "string" && value.mutationId.length > 0 &&
283
- value.mutationId.length <= MAX_ID_LENGTH && isSafeTime(value.mutationIssuedAtMs) &&
395
+ const validMutation = typeof value.mutationId === "string" &&
396
+ value.mutationId.length > 0 &&
397
+ value.mutationId.length <= MAX_ID_LENGTH &&
398
+ isSafeTime(value.mutationIssuedAtMs) &&
284
399
  value.mutationIssuedAtMs >= value.publishedAtMs &&
285
400
  value.mutationIssuedAtMs === value.updatedAtMs;
286
- if (value.state === "edit-outcome-unknown" && validMutation &&
287
- typeof value.attemptedMarkdown === "string" && value.attemptedMarkdown.length > 0 &&
288
- value.attemptedMarkdown.length <= MAX_MARKDOWN_LENGTH && value.deletedAtMs === undefined) {
289
- return { ...base, ...identity, state: "edit-outcome-unknown", mutationId: value.mutationId,
290
- attemptedMarkdown: value.attemptedMarkdown, mutationIssuedAtMs: value.mutationIssuedAtMs };
401
+ if (value.state === "edit-outcome-unknown" &&
402
+ validMutation &&
403
+ typeof value.attemptedMarkdown === "string" &&
404
+ value.attemptedMarkdown.length > 0 &&
405
+ value.attemptedMarkdown.length <= MAX_MARKDOWN_LENGTH &&
406
+ value.deletedAtMs === undefined) {
407
+ return {
408
+ ...base,
409
+ ...identity,
410
+ state: "edit-outcome-unknown",
411
+ mutationId: value.mutationId,
412
+ attemptedMarkdown: value.attemptedMarkdown,
413
+ mutationIssuedAtMs: value.mutationIssuedAtMs,
414
+ };
291
415
  }
292
- if (value.state === "delete-outcome-unknown" && validMutation &&
293
- value.attemptedMarkdown === undefined && value.deletedAtMs === undefined) {
294
- return { ...base, ...identity, state: "delete-outcome-unknown", mutationId: value.mutationId,
295
- mutationIssuedAtMs: value.mutationIssuedAtMs };
416
+ if (value.state === "delete-outcome-unknown" &&
417
+ validMutation &&
418
+ value.attemptedMarkdown === undefined &&
419
+ value.deletedAtMs === undefined) {
420
+ return {
421
+ ...base,
422
+ ...identity,
423
+ state: "delete-outcome-unknown",
424
+ mutationId: value.mutationId,
425
+ mutationIssuedAtMs: value.mutationIssuedAtMs,
426
+ };
296
427
  }
297
- if (value.state === "deleted" && typeof value.mutationId === "string" &&
298
- value.mutationId.length > 0 && value.mutationId.length <= MAX_ID_LENGTH &&
299
- isSafeTime(value.deletedAtMs) && value.deletedAtMs >= value.publishedAtMs &&
428
+ if (value.state === "deleted" &&
429
+ typeof value.mutationId === "string" &&
430
+ value.mutationId.length > 0 &&
431
+ value.mutationId.length <= MAX_ID_LENGTH &&
432
+ isSafeTime(value.deletedAtMs) &&
433
+ value.deletedAtMs >= value.publishedAtMs &&
300
434
  value.deletedAtMs === value.updatedAtMs &&
301
- value.mutationIssuedAtMs === undefined && value.attemptedMarkdown === undefined) {
302
- return { ...base, ...identity, state: "deleted", mutationId: value.mutationId,
303
- deletedAtMs: value.deletedAtMs };
435
+ value.mutationIssuedAtMs === undefined &&
436
+ value.attemptedMarkdown === undefined) {
437
+ return {
438
+ ...base,
439
+ ...identity,
440
+ state: "deleted",
441
+ mutationId: value.mutationId,
442
+ deletedAtMs: value.deletedAtMs,
443
+ };
304
444
  }
305
445
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains an invalid state.");
306
446
  }
307
447
  function parseTelegramChannelPostJournalFile(value, profileName, tokenSha256) {
308
- if (!isRecord(value) || !hasOnlyKeys(value, ["version", "profile", "tokenSha256", "records"]) ||
309
- value.version !== CHANNEL_POST_JOURNAL_VERSION || value.profile !== profileName ||
310
- value.tokenSha256 !== tokenSha256 || !Array.isArray(value.records)) {
448
+ if (!isRecord(value) ||
449
+ !hasOnlyKeys(value, ["version", "profile", "tokenSha256", "records"]) ||
450
+ value.version !== CHANNEL_POST_JOURNAL_VERSION ||
451
+ value.profile !== profileName ||
452
+ value.tokenSha256 !== tokenSha256 ||
453
+ !Array.isArray(value.records)) {
311
454
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal identity or schema does not match.");
312
455
  }
313
456
  const records = value.records.map(validateRecord);
314
- if (new Set(records.map(record => record.operationId)).size !== records.length) {
457
+ if (new Set(records.map((record) => record.operationId)).size !== records.length) {
315
458
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains duplicate operation IDs.");
316
459
  }
317
- return { version: CHANNEL_POST_JOURNAL_VERSION, profile: profileName, tokenSha256, records };
460
+ return {
461
+ version: CHANNEL_POST_JOURNAL_VERSION,
462
+ profile: profileName,
463
+ tokenSha256,
464
+ records,
465
+ };
466
+ }
467
+ /** Exact operation lookup; a missing record is a durable conflict, never an implicit create. */
468
+ function findTelegramChannelPostRecord(file, operationId, missing) {
469
+ const index = file.records.findIndex((record) => record.operationId === operationId);
470
+ if (index < 0)
471
+ throw new TelegramChannelPostJournalError("conflict", missing);
472
+ return [index, file.records[index]];
473
+ }
474
+ /** The profile's channel-post journal in the shared runtime directory, bound to the active bot token. */
475
+ export function openTelegramChannelPostJournalStore(input) {
476
+ return createTelegramChannelPostJournalStore({
477
+ ...resolveTelegramServiceJournalStorage("channel-posts", undefined, input.profileName),
478
+ profileName: input.profileName,
479
+ tokenSha256: createTelegramUpdateJournalBotIdentity({
480
+ botToken: input.botToken,
481
+ }).tokenSha256,
482
+ });
318
483
  }
319
484
  export function createTelegramChannelPostJournalStore(options) {
320
485
  options = { ...options };
@@ -322,49 +487,39 @@ export function createTelegramChannelPostJournalStore(options) {
322
487
  const maxRecords = options.maxRecords ?? DEFAULT_MAX_RECORDS;
323
488
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
324
489
  const now = options.getNowMs ?? Date.now;
325
- if (!options.path || !options.profileName || !/^[a-f0-9]{64}$/u.test(options.tokenSha256) ||
326
- !Number.isSafeInteger(maxRecords) || maxRecords <= 0 || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
490
+ if (!options.path ||
491
+ !options.profileName ||
492
+ !/^[a-f0-9]{64}$/u.test(options.tokenSha256) ||
493
+ !Number.isSafeInteger(maxRecords) ||
494
+ maxRecords <= 0 ||
495
+ !Number.isSafeInteger(maxBytes) ||
496
+ maxBytes <= 0) {
327
497
  throw new Error("Telegram channel post journal options are invalid.");
328
498
  }
329
- const empty = () => ({ version: CHANNEL_POST_JOURNAL_VERSION,
330
- profile: options.profileName, tokenSha256: options.tokenSha256, records: [] });
499
+ const empty = () => ({
500
+ version: CHANNEL_POST_JOURNAL_VERSION,
501
+ profile: options.profileName,
502
+ tokenSha256: options.tokenSha256,
503
+ records: [],
504
+ });
331
505
  const read = () => {
332
- let before;
333
- try {
334
- before = lstatSync(options.path, { bigint: true });
335
- }
336
- catch (error) {
337
- if (error?.code === "ENOENT")
338
- return empty();
339
- throw new TelegramChannelPostJournalError("io", "Could not inspect Telegram channel post journal.", error);
340
- }
341
- const uid = process.getuid?.();
342
- if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK || uid === undefined || !before.isFile() ||
343
- before.isSymbolicLink() || before.uid !== BigInt(uid) || before.nlink !== 1n ||
344
- (before.mode & 63n) !== 0n || before.size > BigInt(maxBytes)) {
345
- throw new TelegramChannelPostJournalError(before.size > BigInt(maxBytes) ? "capacity" : "invalid", "Telegram channel post journal is not a bounded no-follow regular file.");
346
- }
347
- let fd;
348
506
  try {
349
- fd = openSync(options.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
350
- const opened = fstatSync(fd, { bigint: true });
351
- if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino ||
352
- opened.uid !== before.uid || opened.nlink !== 1n || (opened.mode & 63n) !== 0n ||
353
- opened.size !== before.size || opened.mtimeNs !== before.mtimeNs) {
354
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal changed during inspection.");
355
- }
356
- const value = JSON.parse(readFileSync(fd, "utf8"));
357
- return parseTelegramChannelPostJournalFile(value, options.profileName, options.tokenSha256);
507
+ const text = readTelegramPrivateFile(options.path, maxBytes);
508
+ return text === undefined
509
+ ? empty()
510
+ : parseTelegramChannelPostJournalFile(JSON.parse(text), options.profileName, options.tokenSha256);
358
511
  }
359
512
  catch (error) {
360
513
  if (error instanceof TelegramChannelPostJournalError)
361
514
  throw error;
515
+ if (error instanceof TelegramPrivateFileError)
516
+ throw new TelegramChannelPostJournalError(error.failure === "capacity"
517
+ ? "capacity"
518
+ : error.failure === "changed"
519
+ ? "conflict"
520
+ : "invalid", "Telegram channel post journal is not a bounded no-follow regular file.", error);
362
521
  throw new TelegramChannelPostJournalError("io", "Could not read Telegram channel post journal.", error);
363
522
  }
364
- finally {
365
- if (fd !== undefined)
366
- closeSync(fd);
367
- }
368
523
  };
369
524
  const publish = (file) => {
370
525
  if (file.records.length > maxRecords) {
@@ -374,23 +529,19 @@ export function createTelegramChannelPostJournalStore(options) {
374
529
  if (Buffer.byteLength(serialized) > maxBytes) {
375
530
  throw new TelegramChannelPostJournalError("capacity", "Telegram channel post journal byte limit reached.");
376
531
  }
377
- const temporaryPath = `${publicationPaths.temporaryBasePath}.${process.pid}.${randomUUID()}.tmp`;
378
- mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
379
- mkdirSync(dirname(temporaryPath), { recursive: true, mode: 0o700 });
380
- try {
381
- writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 });
382
- chmodSync(temporaryPath, 0o600);
383
- if (!renameTelegramPathWithRetry(temporaryPath, options.path)) {
384
- throw new Error("Telegram channel post journal staging file disappeared before publication.");
385
- }
386
- chmodSync(options.path, 0o600);
387
- }
388
- finally {
389
- try {
390
- unlinkSync(temporaryPath);
391
- }
392
- catch { /* Atomic rename consumes the temporary path. */ }
393
- }
532
+ publishTelegramPrivateFile(options.path, publicationPaths.temporaryBasePath, serialized, "Telegram channel post journal");
533
+ };
534
+ // Each transition stamps one clock reading that may not run behind the record's prior transition.
535
+ const readClock = (floorMs = -Infinity) => {
536
+ const atMs = now();
537
+ if (!isSafeTime(atMs) || atMs < floorMs)
538
+ throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
539
+ return atMs;
540
+ };
541
+ const replaceRecord = (file, index, record) => {
542
+ const records = [...file.records];
543
+ records[index] = record;
544
+ publish({ ...file, records });
394
545
  };
395
546
  const mutate = (operation) => {
396
547
  try {
@@ -406,193 +557,210 @@ export function createTelegramChannelPostJournalStore(options) {
406
557
  prepare(input) {
407
558
  const operationId = input.operationId;
408
559
  const channel = normalizeChannel(input.channel);
409
- const media = input.media === undefined ? undefined
560
+ const media = input.media === undefined
561
+ ? undefined
410
562
  : validateTelegramChannelPostMedia(input.media);
411
- if (typeof operationId !== "string" || operationId.length === 0 || operationId.length > MAX_ID_LENGTH ||
412
- typeof input.markdown !== "string" || input.markdown.length === 0 ||
563
+ if (typeof operationId !== "string" ||
564
+ operationId.length === 0 ||
565
+ operationId.length > MAX_ID_LENGTH ||
566
+ typeof input.markdown !== "string" ||
567
+ input.markdown.length === 0 ||
413
568
  input.markdown.length > MAX_MARKDOWN_LENGTH) {
414
569
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post intent is invalid.");
415
570
  }
416
- return mutate(file => {
417
- const existing = file.records.find(record => record.operationId === operationId);
571
+ return mutate((file) => {
572
+ const existing = file.records.find((record) => record.operationId === operationId);
418
573
  if (existing) {
419
- if (existing.requestedChannel !== channel || existing.markdown !== input.markdown ||
574
+ if (existing.requestedChannel !== channel ||
575
+ existing.markdown !== input.markdown ||
420
576
  !sameTelegramChannelPostMedia(existing.media, media)) {
421
577
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post operation conflicts with retained intent.");
422
578
  }
423
579
  return { prepared: false, record: structuredClone(existing) };
424
580
  }
425
- const atMs = now();
426
- if (!isSafeTime(atMs))
427
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
428
- const record = { operationId, requestedChannel: channel,
429
- markdown: input.markdown, createdAtMs: atMs, updatedAtMs: atMs, state: "prepared",
430
- ...(media === undefined ? {} : { media }) };
581
+ const atMs = readClock();
582
+ const record = {
583
+ operationId,
584
+ requestedChannel: channel,
585
+ markdown: input.markdown,
586
+ createdAtMs: atMs,
587
+ updatedAtMs: atMs,
588
+ state: "prepared",
589
+ ...(media === undefined ? {} : { media }),
590
+ };
431
591
  publish({ ...file, records: [...file.records, record] });
432
592
  return { prepared: true, record: structuredClone(record) };
433
593
  });
434
594
  },
435
595
  beginPublication(operationId) {
436
- return mutate(file => {
437
- const index = file.records.findIndex(record => record.operationId === operationId);
438
- if (index < 0)
439
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post intent is missing.");
440
- const current = file.records[index];
596
+ return mutate((file) => {
597
+ const [index, current] = findTelegramChannelPostRecord(file, operationId, "Telegram channel post intent is missing.");
441
598
  if (current.state !== "prepared")
442
599
  return { began: false, record: structuredClone(current) };
443
- const atMs = now();
444
- if (!isSafeTime(atMs) || atMs < current.createdAtMs) {
445
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
446
- }
447
- const record = { ...current, state: "outcome-unknown",
448
- issuedAtMs: atMs, updatedAtMs: atMs };
449
- const records = [...file.records];
450
- records[index] = record;
451
- publish({ ...file, records });
600
+ const atMs = readClock(current.createdAtMs);
601
+ const record = {
602
+ ...current,
603
+ state: "outcome-unknown",
604
+ issuedAtMs: atMs,
605
+ updatedAtMs: atMs,
606
+ };
607
+ replaceRecord(file, index, record);
452
608
  return { began: true, record: structuredClone(record) };
453
609
  });
454
610
  },
455
611
  confirmPublished(input) {
456
- return mutate(file => {
457
- const index = file.records.findIndex(record => record.operationId === input.operationId);
458
- if (index < 0)
459
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post intent is missing.");
460
- const current = file.records[index];
612
+ return mutate((file) => {
613
+ const [index, current] = findTelegramChannelPostRecord(file, input.operationId, "Telegram channel post intent is missing.");
461
614
  if (current.state === "published") {
462
- if (current.channelId !== input.channelId || current.messageId !== input.messageId ||
615
+ if (current.channelId !== input.channelId ||
616
+ current.messageId !== input.messageId ||
463
617
  current.channelUsername !== input.channelUsername ||
464
618
  current.channelTitle !== input.channelTitle) {
465
619
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post confirmation conflicts with retained identity.");
466
620
  }
467
621
  return { confirmed: false, record: structuredClone(current) };
468
622
  }
469
- if (current.state !== "outcome-unknown" || !Number.isSafeInteger(input.channelId) || input.channelId >= 0 ||
470
- !Number.isSafeInteger(input.messageId) || input.messageId <= 0 ||
471
- (input.channelUsername !== undefined && !/^@[A-Za-z0-9_]{5,32}$/u.test(input.channelUsername)) ||
472
- (input.channelTitle !== undefined && (input.channelTitle.length === 0 ||
473
- input.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))) {
623
+ if (current.state !== "outcome-unknown" ||
624
+ !Number.isSafeInteger(input.channelId) ||
625
+ input.channelId >= 0 ||
626
+ !Number.isSafeInteger(input.messageId) ||
627
+ input.messageId <= 0 ||
628
+ (input.channelUsername !== undefined &&
629
+ !/^@[A-Za-z0-9_]{5,32}$/u.test(input.channelUsername)) ||
630
+ (input.channelTitle !== undefined &&
631
+ (input.channelTitle.length === 0 ||
632
+ input.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))) {
474
633
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post confirmation is invalid or premature.");
475
634
  }
476
- const atMs = now();
477
- if (!isSafeTime(atMs) || atMs < current.issuedAtMs) {
478
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
479
- }
480
- const record = { ...current, state: "published",
481
- publishedAtMs: atMs, updatedAtMs: atMs, channelId: input.channelId,
482
- messageId: input.messageId, ...(input.channelUsername ? { channelUsername: input.channelUsername } : {}),
483
- ...(input.channelTitle ? { channelTitle: input.channelTitle } : {}) };
484
- const records = [...file.records];
485
- records[index] = record;
486
- publish({ ...file, records });
635
+ const atMs = readClock(current.issuedAtMs);
636
+ const record = {
637
+ ...current,
638
+ state: "published",
639
+ publishedAtMs: atMs,
640
+ updatedAtMs: atMs,
641
+ channelId: input.channelId,
642
+ messageId: input.messageId,
643
+ ...(input.channelUsername
644
+ ? { channelUsername: input.channelUsername }
645
+ : {}),
646
+ ...(input.channelTitle ? { channelTitle: input.channelTitle } : {}),
647
+ };
648
+ replaceRecord(file, index, record);
487
649
  return { confirmed: true, record: structuredClone(record) };
488
650
  });
489
651
  },
490
652
  beginEdit(input) {
491
- return mutate(file => {
492
- const index = file.records.findIndex(record => record.operationId === input.operationId);
493
- if (index < 0)
494
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
495
- const current = file.records[index];
653
+ return mutate((file) => {
654
+ const [index, current] = findTelegramChannelPostRecord(file, input.operationId, "Telegram channel post is missing.");
496
655
  if (current.state === "edit-outcome-unknown") {
497
- if (current.mutationId === input.mutationId && current.attemptedMarkdown === input.markdown)
656
+ if (current.mutationId === input.mutationId &&
657
+ current.attemptedMarkdown === input.markdown)
498
658
  return { began: false, record: structuredClone(current) };
499
659
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post already has an unresolved mutation.");
500
660
  }
501
- if (current.state === "published" && current.lastMutationId === input.mutationId) {
661
+ if (current.state === "published" &&
662
+ current.lastMutationId === input.mutationId) {
502
663
  if (current.markdown === input.markdown)
503
664
  return { began: false, record: structuredClone(current) };
504
665
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post mutation identity conflicts with retained edit.");
505
666
  }
506
- if (current.state !== "published" || !input.mutationId || input.mutationId.length > MAX_ID_LENGTH ||
507
- !input.markdown || input.markdown.length > MAX_MARKDOWN_LENGTH) {
667
+ if (current.state !== "published" ||
668
+ !input.mutationId ||
669
+ input.mutationId.length > MAX_ID_LENGTH ||
670
+ !input.markdown ||
671
+ input.markdown.length > MAX_MARKDOWN_LENGTH) {
508
672
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post edit is invalid or unavailable.");
509
673
  }
510
- const atMs = now();
511
- if (!isSafeTime(atMs) || atMs < current.updatedAtMs)
512
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
513
- const record = { ...current, state: "edit-outcome-unknown",
514
- mutationId: input.mutationId, attemptedMarkdown: input.markdown,
515
- mutationIssuedAtMs: atMs, updatedAtMs: atMs };
516
- const records = [...file.records];
517
- records[index] = record;
518
- publish({ ...file, records });
674
+ const atMs = readClock(current.updatedAtMs);
675
+ const record = {
676
+ ...current,
677
+ state: "edit-outcome-unknown",
678
+ mutationId: input.mutationId,
679
+ attemptedMarkdown: input.markdown,
680
+ mutationIssuedAtMs: atMs,
681
+ updatedAtMs: atMs,
682
+ };
683
+ replaceRecord(file, index, record);
519
684
  return { began: true, record: structuredClone(record) };
520
685
  });
521
686
  },
522
687
  confirmEdited(input) {
523
- return mutate(file => {
524
- const index = file.records.findIndex(record => record.operationId === input.operationId);
525
- if (index < 0)
526
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
527
- const current = file.records[index];
528
- if (current.state === "published" && current.lastMutationId === input.mutationId)
688
+ return mutate((file) => {
689
+ const [index, current] = findTelegramChannelPostRecord(file, input.operationId, "Telegram channel post is missing.");
690
+ if (current.state === "published" &&
691
+ current.lastMutationId === input.mutationId)
529
692
  return { confirmed: false, record: structuredClone(current) };
530
- if (current.state !== "edit-outcome-unknown" || current.mutationId !== input.mutationId)
693
+ if (current.state !== "edit-outcome-unknown" ||
694
+ current.mutationId !== input.mutationId)
531
695
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post edit confirmation is stale.");
532
- const atMs = now();
533
- if (!isSafeTime(atMs) || atMs < current.mutationIssuedAtMs)
534
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
696
+ const atMs = readClock(current.mutationIssuedAtMs);
535
697
  const { mutationId, attemptedMarkdown, mutationIssuedAtMs, ...prior } = current;
536
- const record = { ...prior, state: "published", markdown: attemptedMarkdown,
537
- lastMutationId: mutationId, updatedAtMs: atMs };
538
- const records = [...file.records];
539
- records[index] = record;
540
- publish({ ...file, records });
698
+ const record = {
699
+ ...prior,
700
+ state: "published",
701
+ markdown: attemptedMarkdown,
702
+ lastMutationId: mutationId,
703
+ updatedAtMs: atMs,
704
+ };
705
+ replaceRecord(file, index, record);
541
706
  return { confirmed: true, record: structuredClone(record) };
542
707
  });
543
708
  },
544
709
  beginDelete(input) {
545
- return mutate(file => {
546
- const index = file.records.findIndex(record => record.operationId === input.operationId);
547
- if (index < 0)
548
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
549
- const current = file.records[index];
710
+ return mutate((file) => {
711
+ const [index, current] = findTelegramChannelPostRecord(file, input.operationId, "Telegram channel post is missing.");
550
712
  if (current.state === "delete-outcome-unknown") {
551
713
  if (current.mutationId === input.mutationId)
552
714
  return { began: false, record: structuredClone(current) };
553
715
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post already has an unresolved deletion.");
554
716
  }
555
- if (current.state === "deleted" && current.mutationId === input.mutationId)
717
+ if (current.state === "deleted" &&
718
+ current.mutationId === input.mutationId)
556
719
  return { began: false, record: structuredClone(current) };
557
- if (current.state !== "published" || !input.mutationId || input.mutationId.length > MAX_ID_LENGTH)
720
+ if (current.state !== "published" ||
721
+ !input.mutationId ||
722
+ input.mutationId.length > MAX_ID_LENGTH)
558
723
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post deletion is invalid or unavailable.");
559
- const atMs = now();
560
- if (!isSafeTime(atMs) || atMs < current.updatedAtMs)
561
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
562
- const record = { ...current, state: "delete-outcome-unknown",
563
- mutationId: input.mutationId, mutationIssuedAtMs: atMs, updatedAtMs: atMs };
564
- const records = [...file.records];
565
- records[index] = record;
566
- publish({ ...file, records });
724
+ const atMs = readClock(current.updatedAtMs);
725
+ const record = {
726
+ ...current,
727
+ state: "delete-outcome-unknown",
728
+ mutationId: input.mutationId,
729
+ mutationIssuedAtMs: atMs,
730
+ updatedAtMs: atMs,
731
+ };
732
+ replaceRecord(file, index, record);
567
733
  return { began: true, record: structuredClone(record) };
568
734
  });
569
735
  },
570
736
  confirmDeleted(input) {
571
- return mutate(file => {
572
- const index = file.records.findIndex(record => record.operationId === input.operationId);
573
- if (index < 0)
574
- throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
575
- const current = file.records[index];
576
- if (current.state === "deleted" && current.mutationId === input.mutationId)
737
+ return mutate((file) => {
738
+ const [index, current] = findTelegramChannelPostRecord(file, input.operationId, "Telegram channel post is missing.");
739
+ if (current.state === "deleted" &&
740
+ current.mutationId === input.mutationId)
577
741
  return { confirmed: false, record: structuredClone(current) };
578
- if (current.state !== "delete-outcome-unknown" || current.mutationId !== input.mutationId)
742
+ if (current.state !== "delete-outcome-unknown" ||
743
+ current.mutationId !== input.mutationId)
579
744
  throw new TelegramChannelPostJournalError("conflict", "Telegram channel post deletion confirmation is stale.");
580
- const atMs = now();
581
- if (!isSafeTime(atMs) || atMs < current.mutationIssuedAtMs)
582
- throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
745
+ const atMs = readClock(current.mutationIssuedAtMs);
583
746
  const { mutationIssuedAtMs, ...prior } = current;
584
- const record = { ...prior, state: "deleted", deletedAtMs: atMs, updatedAtMs: atMs };
585
- const records = [...file.records];
586
- records[index] = record;
587
- publish({ ...file, records });
747
+ const record = {
748
+ ...prior,
749
+ state: "deleted",
750
+ deletedAtMs: atMs,
751
+ updatedAtMs: atMs,
752
+ };
753
+ replaceRecord(file, index, record);
588
754
  return { confirmed: true, record: structuredClone(record) };
589
755
  });
590
756
  },
591
757
  get(operationId) {
592
- if (typeof operationId !== "string" || operationId.length === 0 || operationId.length > MAX_ID_LENGTH) {
758
+ if (typeof operationId !== "string" ||
759
+ operationId.length === 0 ||
760
+ operationId.length > MAX_ID_LENGTH) {
593
761
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post operation ID is invalid.");
594
762
  }
595
- const record = read().records.find(candidate => candidate.operationId === operationId);
763
+ const record = read().records.find((candidate) => candidate.operationId === operationId);
596
764
  return record === undefined ? undefined : structuredClone(record);
597
765
  },
598
766
  list(input = {}) {
@@ -600,9 +768,14 @@ export function createTelegramChannelPostJournalStore(options) {
600
768
  if (!Number.isSafeInteger(limit) || limit <= 0 || limit > maxRecords) {
601
769
  throw new TelegramChannelPostJournalError("invalid", "Telegram channel post list limit is invalid.");
602
770
  }
603
- const channel = input.channel === undefined ? undefined : normalizeChannel(input.channel);
604
- return read().records.filter(record => channel === undefined || record.requestedChannel === channel)
605
- .slice(-limit).reverse().map(record => structuredClone(record));
771
+ const channel = input.channel === undefined
772
+ ? undefined
773
+ : normalizeChannel(input.channel);
774
+ return read()
775
+ .records.filter((record) => channel === undefined || record.requestedChannel === channel)
776
+ .slice(-limit)
777
+ .reverse()
778
+ .map((record) => structuredClone(record));
606
779
  },
607
780
  };
608
781
  }