@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.
- package/AGENTS.md +9 -7
- package/BACKLOG.md +1 -18
- package/CHANGELOG.md +15 -0
- package/README.md +7 -11
- package/api/commands.ts +3 -0
- package/dist/api/commands.d.ts +1 -1
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +15 -42
- package/dist/lib/activity.d.ts +9 -0
- package/dist/lib/activity.js +39 -10
- package/dist/lib/agent-messages.js +7 -3
- package/dist/lib/bindings.d.ts +43 -5
- package/dist/lib/bindings.js +163 -20
- package/dist/lib/bus-api.d.ts +12 -0
- package/dist/lib/bus-api.js +182 -79
- package/dist/lib/bus-follower.d.ts +111 -31
- package/dist/lib/bus-follower.js +1324 -359
- package/dist/lib/bus-leader.d.ts +41 -91
- package/dist/lib/bus-leader.js +969 -637
- package/dist/lib/bus-transport.js +16 -9
- package/dist/lib/bus.d.ts +215 -18
- package/dist/lib/bus.js +1048 -327
- package/dist/lib/channel-posts.d.ts +5 -0
- package/dist/lib/channel-posts.js +434 -261
- package/dist/lib/command-templates.d.ts +26 -5
- package/dist/lib/command-templates.js +92 -176
- package/dist/lib/commands.d.ts +168 -82
- package/dist/lib/commands.js +1409 -291
- package/dist/lib/config.d.ts +1 -8
- package/dist/lib/config.js +34 -76
- package/dist/lib/delivery.d.ts +2 -2
- package/dist/lib/delivery.js +16 -16
- package/dist/lib/extension.js +639 -171
- package/dist/lib/generative-app-worker.mjs +19 -7
- package/dist/lib/generative-apps.d.ts +0 -5
- package/dist/lib/generative-apps.js +61 -49
- package/dist/lib/inbound.d.ts +5 -0
- package/dist/lib/inbound.js +35 -59
- package/dist/lib/journal.d.ts +33 -3
- package/dist/lib/journal.js +2063 -782
- package/dist/lib/lifecycle.d.ts +2 -6
- package/dist/lib/lifecycle.js +75 -58
- package/dist/lib/locks.d.ts +29 -3
- package/dist/lib/locks.js +289 -108
- package/dist/lib/logging.js +50 -14
- package/dist/lib/media.d.ts +22 -1
- package/dist/lib/media.js +46 -28
- package/dist/lib/menu-model.d.ts +19 -5
- package/dist/lib/menu-model.js +53 -11
- package/dist/lib/menu-queue.d.ts +5 -4
- package/dist/lib/menu-queue.js +45 -63
- package/dist/lib/menu-settings.d.ts +5 -4
- package/dist/lib/menu-settings.js +114 -78
- package/dist/lib/menu-status.d.ts +1 -1
- package/dist/lib/menu-status.js +8 -25
- package/dist/lib/menu-thinking.d.ts +6 -1
- package/dist/lib/menu-thinking.js +14 -21
- package/dist/lib/menu.d.ts +8 -61
- package/dist/lib/menu.js +92 -164
- package/dist/lib/model.d.ts +1 -1
- package/dist/lib/model.js +2 -16
- package/dist/lib/outbound-attachments.d.ts +6 -27
- package/dist/lib/outbound-attachments.js +45 -38
- package/dist/lib/outbound-buttons.js +22 -16
- package/dist/lib/outbound-markup.d.ts +0 -2
- package/dist/lib/outbound-markup.js +51 -64
- package/dist/lib/outbound-voice.js +6 -3
- package/dist/lib/outbound.d.ts +6 -6
- package/dist/lib/outbound.js +65 -51
- package/dist/lib/ownership.js +6 -2
- package/dist/lib/paths.d.ts +5 -0
- package/dist/lib/paths.js +37 -21
- package/dist/lib/pi.d.ts +2 -0
- package/dist/lib/pi.js +8 -1
- package/dist/lib/polling.d.ts +21 -37
- package/dist/lib/polling.js +59 -50
- package/dist/lib/preview.js +39 -19
- package/dist/lib/process-identity.d.ts +24 -0
- package/dist/lib/process-identity.js +133 -0
- package/dist/lib/prompts.js +2 -7
- package/dist/lib/queue.d.ts +14 -16
- package/dist/lib/queue.js +252 -127
- package/dist/lib/recovery.d.ts +2 -83
- package/dist/lib/recovery.js +20 -274
- package/dist/lib/replies.d.ts +16 -24
- package/dist/lib/replies.js +99 -116
- package/dist/lib/routing.d.ts +120 -7
- package/dist/lib/routing.js +4667 -1152
- package/dist/lib/runtime.d.ts +1 -11
- package/dist/lib/sections.d.ts +4 -29
- package/dist/lib/sections.js +7 -20
- package/dist/lib/skills.js +4 -3
- package/dist/lib/status.js +86 -57
- package/dist/lib/sync.d.ts +1 -3
- package/dist/lib/sync.js +67 -43
- package/dist/lib/target.d.ts +4 -0
- package/dist/lib/target.js +18 -0
- package/dist/lib/telegram-api.d.ts +18 -13
- package/dist/lib/telegram-api.js +148 -53
- package/dist/lib/text-groups.js +4 -39
- package/dist/lib/thread-cleanup-manager.d.ts +6 -27
- package/dist/lib/thread-cleanup-manager.js +369 -186
- package/dist/lib/thread-display.d.ts +1 -2
- package/dist/lib/thread-display.js +58 -38
- package/dist/lib/thread-naming.d.ts +28 -8
- package/dist/lib/thread-naming.js +175 -66
- package/dist/lib/thread-reconciler.d.ts +42 -16
- package/dist/lib/thread-reconciler.js +90 -50
- package/dist/lib/threads.d.ts +82 -20
- package/dist/lib/threads.js +2247 -855
- package/dist/lib/time-injection.js +1 -2
- package/dist/lib/turns.js +1 -19
- package/dist/lib/updates.d.ts +169 -80
- package/dist/lib/updates.js +2418 -641
- package/dist/lib/voice.d.ts +7 -22
- package/dist/lib/voice.js +7 -22
- package/dist/lib/wire.js +1 -1
- package/dist/lib/workspace-admission.d.ts +3 -1
- package/dist/lib/workspace-admission.js +425 -211
- package/dist/lib/workspace-identity.js +8 -13
- package/dist/lib/workspace-retirement.d.ts +1 -1
- package/dist/lib/workspace-retirement.js +328 -140
- package/dist/lib/workspace-slots.js +8 -4
- package/dist/package.json +1 -1
- package/docs/architecture.md +31 -29
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +15 -3
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +29 -1
- package/docs/ui-style.md +25 -8
- package/lib/activity-verbosity.ts +59 -82
- package/lib/activity.ts +146 -102
- package/lib/agent-messages.ts +12 -5
- package/lib/bindings.ts +389 -82
- package/lib/bus-api.ts +263 -102
- package/lib/bus-follower.ts +2440 -601
- package/lib/bus-leader.ts +2209 -1290
- package/lib/bus-transport.ts +46 -12
- package/lib/bus.ts +1673 -444
- package/lib/channel-posts.ts +848 -327
- package/lib/command-templates.ts +152 -265
- package/lib/commands.ts +2398 -555
- package/lib/config.ts +177 -170
- package/lib/delivery.ts +41 -29
- package/lib/extension.ts +1206 -433
- package/lib/generative-app-worker.mjs +19 -7
- package/lib/generative-apps.ts +324 -211
- package/lib/inbound.ts +95 -97
- package/lib/journal.ts +5311 -1763
- package/lib/keyboard.ts +1 -3
- package/lib/lifecycle.ts +128 -78
- package/lib/locks.ts +677 -203
- package/lib/logging.ts +147 -58
- package/lib/media.ts +82 -43
- package/lib/menu-model.ts +85 -18
- package/lib/menu-queue.ts +88 -129
- package/lib/menu-settings.ts +260 -155
- package/lib/menu-status.ts +20 -60
- package/lib/menu-thinking.ts +41 -56
- package/lib/menu.ts +112 -285
- package/lib/model.ts +8 -22
- package/lib/outbound-attachments.ts +156 -125
- package/lib/outbound-buttons.ts +76 -49
- package/lib/outbound-markup.ts +96 -94
- package/lib/outbound-voice.ts +33 -27
- package/lib/outbound.ts +128 -125
- package/lib/ownership.ts +19 -10
- package/lib/paths.ts +148 -49
- package/lib/pi.ts +35 -15
- package/lib/polling.ts +144 -147
- package/lib/preview.ts +93 -40
- package/lib/process-identity.ts +164 -0
- package/lib/prompts.ts +4 -11
- package/lib/queue.ts +516 -301
- package/lib/recovery.ts +50 -454
- package/lib/rendering.ts +1 -3
- package/lib/replies.ts +248 -209
- package/lib/routing.ts +8509 -2186
- package/lib/runtime.ts +1 -12
- package/lib/sections.ts +24 -89
- package/lib/setup.ts +1 -3
- package/lib/skills.ts +9 -4
- package/lib/status.ts +215 -110
- package/lib/sync.ts +448 -361
- package/lib/target.ts +22 -3
- package/lib/telegram-api.ts +302 -127
- package/lib/text-groups.ts +5 -39
- package/lib/thread-cleanup-manager.ts +833 -331
- package/lib/thread-display.ts +213 -97
- package/lib/thread-naming.ts +232 -87
- package/lib/thread-reconciler.ts +155 -72
- package/lib/threads.ts +5318 -1845
- package/lib/time-injection.ts +1 -4
- package/lib/turns.ts +15 -28
- package/lib/updates.ts +5184 -1612
- package/lib/voice.ts +7 -35
- package/lib/wire.ts +1 -1
- package/lib/workspace-admission.ts +996 -416
- package/lib/workspace-identity.ts +14 -17
- package/lib/workspace-retirement.ts +878 -376
- package/lib/workspace-slots.ts +22 -13
- package/package.json +1 -1
- package/scripts/check-downgrade.mjs +0 -472
- 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 {
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { createReadStream } from "node:fs";
|
|
7
8
|
import { lstat } from "node:fs/promises";
|
|
8
|
-
import { basename
|
|
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 {
|
|
13
|
-
import {
|
|
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([
|
|
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 {
|
|
87
|
-
|
|
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(/</gu, "<")
|
|
94
104
|
.replace(/>/gu, ">")
|
|
95
|
-
.replace(/"/gu, "
|
|
105
|
+
.replace(/"/gu, '"')
|
|
96
106
|
.replace(/'/gu, "'")
|
|
97
107
|
.replace(/&/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" ||
|
|
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 &&
|
|
119
|
-
|
|
120
|
-
|
|
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({
|
|
124
|
-
|
|
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({
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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({
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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(),
|
|
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 {
|
|
192
|
-
|
|
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) ||
|
|
253
|
+
if (!isRecord(value) ||
|
|
254
|
+
!hasOnlyKeys(value, ["kind", "fileName", "sizeBytes", "sha256"]) ||
|
|
210
255
|
(value.kind !== "photo" && value.kind !== "video") ||
|
|
211
|
-
typeof value.fileName !== "string" ||
|
|
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) ||
|
|
214
|
-
value.sizeBytes
|
|
215
|
-
|
|
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 {
|
|
219
|
-
|
|
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 &&
|
|
225
|
-
left.
|
|
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) ||
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 = {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
value.
|
|
245
|
-
|
|
246
|
-
|
|
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" &&
|
|
253
|
-
value
|
|
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) ||
|
|
260
|
-
|
|
261
|
-
!Number.isSafeInteger(value.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 = {
|
|
269
|
-
|
|
368
|
+
const identity = {
|
|
369
|
+
issuedAtMs: value.issuedAtMs,
|
|
370
|
+
publishedAtMs: value.publishedAtMs,
|
|
371
|
+
channelId: value.channelId,
|
|
270
372
|
messageId: value.messageId,
|
|
271
|
-
...(value.channelUsername
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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" &&
|
|
279
|
-
value.
|
|
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" &&
|
|
283
|
-
value.mutationId.length
|
|
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" &&
|
|
287
|
-
|
|
288
|
-
value.attemptedMarkdown
|
|
289
|
-
|
|
290
|
-
|
|
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" &&
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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" &&
|
|
298
|
-
|
|
299
|
-
|
|
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 &&
|
|
302
|
-
|
|
303
|
-
|
|
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) ||
|
|
309
|
-
value
|
|
310
|
-
value.
|
|
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 {
|
|
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 ||
|
|
326
|
-
!
|
|
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 = () => ({
|
|
330
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
560
|
+
const media = input.media === undefined
|
|
561
|
+
? undefined
|
|
410
562
|
: validateTelegramChannelPostMedia(input.media);
|
|
411
|
-
if (typeof operationId !== "string" ||
|
|
412
|
-
|
|
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 ||
|
|
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 =
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
markdown: input.markdown,
|
|
430
|
-
|
|
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
|
|
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 =
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
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 ||
|
|
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" ||
|
|
470
|
-
!Number.isSafeInteger(input.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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 =
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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 &&
|
|
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" &&
|
|
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" ||
|
|
507
|
-
!input.
|
|
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 =
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
mutationId: input.mutationId,
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
|
525
|
-
if (
|
|
526
|
-
|
|
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" ||
|
|
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 =
|
|
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 = {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
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" &&
|
|
717
|
+
if (current.state === "deleted" &&
|
|
718
|
+
current.mutationId === input.mutationId)
|
|
556
719
|
return { began: false, record: structuredClone(current) };
|
|
557
|
-
if (current.state !== "published" ||
|
|
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 =
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
mutationId: input.mutationId,
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
573
|
-
if (
|
|
574
|
-
|
|
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" ||
|
|
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 =
|
|
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 = {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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" ||
|
|
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
|
|
604
|
-
|
|
605
|
-
|
|
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
|
}
|