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