@llblab/pi-kit 0.6.0 → 0.7.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/CHANGELOG.md +8 -0
- package/README.md +4 -4
- package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
- package/node_modules/@llblab/pi-actors/README.md +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
- package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
- package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
- package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
- package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
- package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
- package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
- package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
- package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
- package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
- package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
- package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
- package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
- package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
- package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
- package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
- package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
- package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
- package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
- package/node_modules/@llblab/pi-actors/package.json +1 -1
- package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
- package/node_modules/@llblab/pi-state-flow/README.md +203 -107
- package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
- package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
- package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
- package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
- package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
- package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
- package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
- package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
- package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
- package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
- package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
- package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
- package/node_modules/@llblab/pi-state-flow/package.json +8 -2
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
- package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +23 -5
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
- package/node_modules/@llblab/pi-telegram/README.md +13 -9
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +227 -23
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -6
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -6
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
- package/node_modules/@llblab/pi-telegram/index.ts +4 -1418
- package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +46 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +355 -26
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +718 -0
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +237 -11
- package/node_modules/@llblab/pi-telegram/lib/config.ts +242 -26
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +1851 -0
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +44 -2
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +152 -13
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +97 -10
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +6 -2
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +118 -26
- package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +21 -2
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +344 -112
- package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
- package/node_modules/@llblab/pi-telegram/lib/status.ts +51 -4
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +353 -22
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
- package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
- package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
- package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
- package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
- package/node_modules/@llblab/skills/package.json +2 -3
- package/package.json +6 -5
- /package/node_modules/@llblab/pi-telegram/lib/{logs.ts → logging.ts} +0 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable journal for Telegram channel posts authored by this agent path
|
|
3
|
+
* Zones: telegram outbound, filesystem authority
|
|
4
|
+
* Owns publication intent, outcome-unknown fencing, confirmed post identity, and bounded local listing
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { chmodSync, closeSync, constants, createReadStream, fstatSync, lstatSync, mkdirSync, openSync,
|
|
8
|
+
readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { lstat } from "node:fs/promises";
|
|
10
|
+
import { basename, dirname } from "node:path";
|
|
11
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
import { Type } from "@sinclair/typebox";
|
|
14
|
+
|
|
15
|
+
import { renameTelegramPathWithRetry, withTelegramFileTransaction } from "./locks.ts";
|
|
16
|
+
import type { ExtensionAPI } from "./pi.ts";
|
|
17
|
+
|
|
18
|
+
const CHANNEL_POST_JOURNAL_VERSION = 1;
|
|
19
|
+
const DEFAULT_MAX_RECORDS = 256;
|
|
20
|
+
const DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
|
|
21
|
+
const MAX_ID_LENGTH = 256;
|
|
22
|
+
const MAX_MARKDOWN_LENGTH = 100_000;
|
|
23
|
+
const MAX_CHANNEL_TITLE_LENGTH = 255;
|
|
24
|
+
|
|
25
|
+
export type TelegramChannelPostMediaKind = "photo" | "video";
|
|
26
|
+
|
|
27
|
+
export interface TelegramChannelPostMediaIntent {
|
|
28
|
+
kind: TelegramChannelPostMediaKind;
|
|
29
|
+
fileName: string;
|
|
30
|
+
sizeBytes: number;
|
|
31
|
+
sha256: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES: Record<TelegramChannelPostMediaKind, number> = {
|
|
35
|
+
photo: 10 * 1024 * 1024,
|
|
36
|
+
video: 50 * 1024 * 1024,
|
|
37
|
+
};
|
|
38
|
+
export const TELEGRAM_CHANNEL_POST_CAPTION_MAX_LENGTH = 1024;
|
|
39
|
+
export const TELEGRAM_CHANNEL_POST_MEDIA_FILE_NAME_MAX_LENGTH = 255;
|
|
40
|
+
|
|
41
|
+
/** Safe, content-free local validation failure for channel media publication intent. */
|
|
42
|
+
export class TelegramChannelPostValidationError extends Error {
|
|
43
|
+
constructor(message: string) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "TelegramChannelPostValidationError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isTelegramChannelPostValidationError(
|
|
50
|
+
error: unknown,
|
|
51
|
+
): error is TelegramChannelPostValidationError {
|
|
52
|
+
return error instanceof TelegramChannelPostValidationError;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TELEGRAM_CHANNEL_POST_PHOTO_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
|
|
56
|
+
const TELEGRAM_CHANNEL_POST_VIDEO_EXTENSIONS = new Set([".mp4"]);
|
|
57
|
+
|
|
58
|
+
export function resolveTelegramChannelPostMediaKind(
|
|
59
|
+
path: string,
|
|
60
|
+
): TelegramChannelPostMediaKind | undefined {
|
|
61
|
+
if (typeof path !== "string" || path.length === 0) return undefined;
|
|
62
|
+
const name = basename(path).toLowerCase();
|
|
63
|
+
const dot = name.lastIndexOf(".");
|
|
64
|
+
if (dot <= 0) return undefined;
|
|
65
|
+
const extension = name.slice(dot);
|
|
66
|
+
if (TELEGRAM_CHANNEL_POST_PHOTO_EXTENSIONS.has(extension)) return "photo";
|
|
67
|
+
if (TELEGRAM_CHANNEL_POST_VIDEO_EXTENSIONS.has(extension)) return "video";
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function assertTelegramChannelPostMediaSize(
|
|
72
|
+
kind: TelegramChannelPostMediaKind,
|
|
73
|
+
sizeBytes: number,
|
|
74
|
+
): void {
|
|
75
|
+
if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) {
|
|
76
|
+
throw new TelegramChannelPostValidationError("Channel media file is empty or unreadable.");
|
|
77
|
+
}
|
|
78
|
+
const limit = TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES[kind];
|
|
79
|
+
if (sizeBytes > limit) {
|
|
80
|
+
throw new TelegramChannelPostValidationError(
|
|
81
|
+
`Channel ${kind} exceeds the Telegram ${kind} upload limit of ${limit} bytes.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function hashTelegramChannelPostMedia(path: string): Promise<string> {
|
|
87
|
+
return await new Promise<string>((resolve, reject) => {
|
|
88
|
+
const hash = createHash("sha256");
|
|
89
|
+
const stream = createReadStream(path);
|
|
90
|
+
stream.on("data", (chunk: Buffer) => hash.update(chunk));
|
|
91
|
+
stream.on("error", reject);
|
|
92
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function inspectTelegramChannelPostMedia(
|
|
97
|
+
path: string,
|
|
98
|
+
): Promise<TelegramChannelPostMediaIntent> {
|
|
99
|
+
const kind = resolveTelegramChannelPostMediaKind(path);
|
|
100
|
+
if (!kind) {
|
|
101
|
+
throw new TelegramChannelPostValidationError(
|
|
102
|
+
"Unsupported channel media type. Supported single files: .jpg, .jpeg, .png, .webp photos and .mp4 videos; albums are not supported.",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
let stats;
|
|
106
|
+
try {
|
|
107
|
+
stats = await lstat(path);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new TelegramChannelPostValidationError(
|
|
110
|
+
"Channel media upload requires one readable regular local file.",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
114
|
+
throw new TelegramChannelPostValidationError(
|
|
115
|
+
"Channel media upload requires one regular local file without symbolic links.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
assertTelegramChannelPostMediaSize(kind, stats.size);
|
|
119
|
+
return { kind, fileName: basename(path), sizeBytes: stats.size,
|
|
120
|
+
sha256: await hashTelegramChannelPostMedia(path) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getTelegramChannelPostCaptionLength(caption: string): number {
|
|
124
|
+
const visible = caption
|
|
125
|
+
.replace(/<br\s*\/?>/giu, "\n")
|
|
126
|
+
.replace(/<[^>]*>/gu, "")
|
|
127
|
+
.replace(/</gu, "<")
|
|
128
|
+
.replace(/>/gu, ">")
|
|
129
|
+
.replace(/"/gu, "\"")
|
|
130
|
+
.replace(/'/gu, "'")
|
|
131
|
+
.replace(/&/gu, "&");
|
|
132
|
+
return visible.length;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function assertTelegramChannelPostCaptionWithinLimit(caption: string): void {
|
|
136
|
+
if (getTelegramChannelPostCaptionLength(caption) >
|
|
137
|
+
TELEGRAM_CHANNEL_POST_CAPTION_MAX_LENGTH) {
|
|
138
|
+
throw new TelegramChannelPostValidationError(
|
|
139
|
+
`Channel media caption exceeds the Telegram limit of ${TELEGRAM_CHANNEL_POST_CAPTION_MAX_LENGTH} characters.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type ChannelPostJournalCode = "invalid" | "conflict" | "capacity" | "io";
|
|
145
|
+
|
|
146
|
+
export class TelegramChannelPostJournalError extends Error {
|
|
147
|
+
readonly code: ChannelPostJournalCode;
|
|
148
|
+
|
|
149
|
+
constructor(code: ChannelPostJournalCode, message: string, cause?: unknown) {
|
|
150
|
+
super(message, { cause });
|
|
151
|
+
this.name = "TelegramChannelPostJournalError";
|
|
152
|
+
this.code = code;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type TelegramChannelPostAddress = number | `@${string}`;
|
|
157
|
+
|
|
158
|
+
interface TelegramChannelPostRecordBase {
|
|
159
|
+
operationId: string;
|
|
160
|
+
requestedChannel: TelegramChannelPostAddress;
|
|
161
|
+
markdown: string;
|
|
162
|
+
media?: TelegramChannelPostMediaIntent;
|
|
163
|
+
createdAtMs: number;
|
|
164
|
+
updatedAtMs: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface TelegramPublishedChannelPostIdentity {
|
|
168
|
+
issuedAtMs: number;
|
|
169
|
+
publishedAtMs: number;
|
|
170
|
+
channelId: number;
|
|
171
|
+
messageId: number;
|
|
172
|
+
channelUsername?: `@${string}`;
|
|
173
|
+
channelTitle?: string;
|
|
174
|
+
lastMutationId?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type TelegramChannelPostRecord = TelegramChannelPostRecordBase & (
|
|
178
|
+
| { state: "prepared" }
|
|
179
|
+
| { state: "outcome-unknown"; issuedAtMs: number }
|
|
180
|
+
| ({ state: "published" } & TelegramPublishedChannelPostIdentity)
|
|
181
|
+
| ({ state: "edit-outcome-unknown"; mutationId: string; attemptedMarkdown: string;
|
|
182
|
+
mutationIssuedAtMs: number } & TelegramPublishedChannelPostIdentity)
|
|
183
|
+
| ({ state: "delete-outcome-unknown"; mutationId: string;
|
|
184
|
+
mutationIssuedAtMs: number } & TelegramPublishedChannelPostIdentity)
|
|
185
|
+
| ({ state: "deleted"; mutationId: string; deletedAtMs: number } &
|
|
186
|
+
TelegramPublishedChannelPostIdentity)
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
interface TelegramChannelPostJournalFile {
|
|
190
|
+
version: typeof CHANNEL_POST_JOURNAL_VERSION;
|
|
191
|
+
profile: string;
|
|
192
|
+
tokenSha256: string;
|
|
193
|
+
records: TelegramChannelPostRecord[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface TelegramChannelPostJournalStoreOptions {
|
|
197
|
+
path: string;
|
|
198
|
+
profileName: string;
|
|
199
|
+
tokenSha256: string;
|
|
200
|
+
maxRecords?: number;
|
|
201
|
+
maxBytes?: number;
|
|
202
|
+
getNowMs?: () => number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface TelegramChannelPostJournalStore {
|
|
206
|
+
prepare(input: { operationId: string; channel: TelegramChannelPostAddress; markdown: string;
|
|
207
|
+
media?: TelegramChannelPostMediaIntent }):
|
|
208
|
+
{ prepared: boolean; record: TelegramChannelPostRecord };
|
|
209
|
+
get(operationId: string): TelegramChannelPostRecord | undefined;
|
|
210
|
+
beginPublication(operationId: string): { began: boolean; record: TelegramChannelPostRecord };
|
|
211
|
+
confirmPublished(input: { operationId: string; channelId: number; messageId: number;
|
|
212
|
+
channelUsername?: `@${string}`; channelTitle?: string }):
|
|
213
|
+
{ confirmed: boolean; record: TelegramChannelPostRecord };
|
|
214
|
+
beginEdit(input: { operationId: string; mutationId: string; markdown: string }):
|
|
215
|
+
{ began: boolean; record: TelegramChannelPostRecord };
|
|
216
|
+
confirmEdited(input: { operationId: string; mutationId: string }):
|
|
217
|
+
{ confirmed: boolean; record: TelegramChannelPostRecord };
|
|
218
|
+
beginDelete(input: { operationId: string; mutationId: string }):
|
|
219
|
+
{ began: boolean; record: TelegramChannelPostRecord };
|
|
220
|
+
confirmDeleted(input: { operationId: string; mutationId: string }):
|
|
221
|
+
{ confirmed: boolean; record: TelegramChannelPostRecord };
|
|
222
|
+
list(input?: { channel?: TelegramChannelPostAddress; limit?: number }): TelegramChannelPostRecord[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function publishTelegramChannelPost(input: {
|
|
226
|
+
store: TelegramChannelPostJournalStore;
|
|
227
|
+
operationId: string;
|
|
228
|
+
channel: TelegramChannelPostAddress;
|
|
229
|
+
markdown: string;
|
|
230
|
+
media?: TelegramChannelPostMediaIntent;
|
|
231
|
+
observeChannel(channel: TelegramChannelPostAddress): Promise<{
|
|
232
|
+
id: number; type: string; username?: string; title?: string;
|
|
233
|
+
}>;
|
|
234
|
+
send(channel: TelegramChannelPostAddress, markdown: string): Promise<{
|
|
235
|
+
messageId: number; chat: { id: number; type: string };
|
|
236
|
+
}>;
|
|
237
|
+
}): Promise<TelegramChannelPostRecord> {
|
|
238
|
+
const observed = await input.observeChannel(input.channel);
|
|
239
|
+
if (observed.type !== "channel" || !Number.isSafeInteger(observed.id) || observed.id >= 0 ||
|
|
240
|
+
(typeof input.channel === "number" && observed.id !== input.channel) ||
|
|
241
|
+
(observed.username !== undefined && !/^[A-Za-z0-9_]{5,32}$/u.test(observed.username)) ||
|
|
242
|
+
(observed.title !== undefined && (observed.title.length === 0 ||
|
|
243
|
+
observed.title.length > MAX_CHANNEL_TITLE_LENGTH))) {
|
|
244
|
+
throw new Error("Telegram channel delivery requires bounded exact getChat channel identity.");
|
|
245
|
+
}
|
|
246
|
+
input.store.prepare({ operationId: input.operationId, channel: input.channel,
|
|
247
|
+
markdown: input.markdown, ...(input.media === undefined ? {} : { media: input.media }) });
|
|
248
|
+
const issuance = input.store.beginPublication(input.operationId);
|
|
249
|
+
if (!issuance.began) {
|
|
250
|
+
if (issuance.record.state === "published") return issuance.record;
|
|
251
|
+
throw new Error("Telegram channel post outcome is unknown; refusing automatic replay.");
|
|
252
|
+
}
|
|
253
|
+
const sent = await input.send(input.channel, input.markdown);
|
|
254
|
+
if (sent.chat.type !== "channel" || sent.chat.id !== observed.id) {
|
|
255
|
+
throw new Error("Telegram channel post response identity did not match the verified channel.");
|
|
256
|
+
}
|
|
257
|
+
return input.store.confirmPublished({ operationId: input.operationId,
|
|
258
|
+
channelId: sent.chat.id, messageId: sent.messageId,
|
|
259
|
+
...(observed.username ? { channelUsername: `@${observed.username}` as `@${string}` } : {}),
|
|
260
|
+
...(observed.title ? { channelTitle: observed.title } : {}) }).record;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function formatTelegramChannelPostToolOutput(value: unknown): string {
|
|
264
|
+
// Pi's compact tool rows need one leading newline to separate call and result.
|
|
265
|
+
return `\n${JSON.stringify(value, null, 2)}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function formatTelegramChannelPostToolError(error: unknown): Error {
|
|
269
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
270
|
+
return new Error(`\n${message.replace(/^\n+/u, "") || "Telegram channel post operation failed."}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function registerTelegramChannelPostMutationTool(
|
|
274
|
+
pi: ExtensionAPI,
|
|
275
|
+
deps: { mutate(input: { action: "edit" | "delete"; operationId: string;
|
|
276
|
+
mutationId: string; markdown?: string }): Promise<TelegramChannelPostRecord> },
|
|
277
|
+
): void {
|
|
278
|
+
pi.registerTool({
|
|
279
|
+
name: "telegram_channel_post",
|
|
280
|
+
label: "Edit or Delete Telegram Channel Post",
|
|
281
|
+
description: "Edit or delete one exact published post retained by telegram_channel_posts; a media post edit replaces its caption. Unknown outcomes are never replayed.",
|
|
282
|
+
parameters: Type.Object({
|
|
283
|
+
action: Type.Union([Type.Literal("edit"), Type.Literal("delete")]),
|
|
284
|
+
operation_id: Type.String({ minLength: 1, maxLength: MAX_ID_LENGTH }),
|
|
285
|
+
markdown: Type.Optional(Type.String({ minLength: 1, maxLength: MAX_MARKDOWN_LENGTH })),
|
|
286
|
+
}),
|
|
287
|
+
async execute(toolCallId, params) {
|
|
288
|
+
try {
|
|
289
|
+
const record = await deps.mutate({ action: params.action, operationId: params.operation_id,
|
|
290
|
+
mutationId: toolCallId, ...(params.markdown === undefined ? {} : { markdown: params.markdown }) });
|
|
291
|
+
return { content: [{ type: "text" as const, text: formatTelegramChannelPostToolOutput(record) }],
|
|
292
|
+
details: { record } };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (isTelegramChannelPostValidationError(error)) {
|
|
295
|
+
throw formatTelegramChannelPostToolError(error);
|
|
296
|
+
}
|
|
297
|
+
throw new Error("\nTelegram channel post mutation failed; inspect the retained local record before retrying.");
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function registerTelegramChannelPostListTool(
|
|
304
|
+
pi: ExtensionAPI,
|
|
305
|
+
deps: { list: TelegramChannelPostJournalStore["list"] },
|
|
306
|
+
): void {
|
|
307
|
+
pi.registerTool({
|
|
308
|
+
name: "telegram_channel_posts",
|
|
309
|
+
label: "Telegram Channel Posts",
|
|
310
|
+
description: "List bounded local records for channel posts authored by this agent path. This does not read Telegram channel history.",
|
|
311
|
+
parameters: Type.Object({
|
|
312
|
+
chat_id: Type.Optional(Type.Union([
|
|
313
|
+
Type.Number(), Type.String({ pattern: "^@[A-Za-z0-9_]{5,32}$" }),
|
|
314
|
+
])),
|
|
315
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_RECORDS })),
|
|
316
|
+
}),
|
|
317
|
+
async execute(_toolCallId, params) {
|
|
318
|
+
try {
|
|
319
|
+
const records = deps.list({
|
|
320
|
+
channel: params.chat_id as TelegramChannelPostAddress | undefined,
|
|
321
|
+
limit: params.limit,
|
|
322
|
+
});
|
|
323
|
+
return { content: [{ type: "text" as const, text: formatTelegramChannelPostToolOutput(records) }],
|
|
324
|
+
details: { records } };
|
|
325
|
+
} catch {
|
|
326
|
+
throw new Error("\nTelegram channel post listing failed without exposing retained content or storage details.");
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
333
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function hasOnlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
337
|
+
const allowed = new Set(keys);
|
|
338
|
+
return Object.keys(value).every(key => allowed.has(key));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function isSafeTime(value: unknown): value is number {
|
|
342
|
+
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function normalizeChannel(value: unknown): TelegramChannelPostAddress {
|
|
346
|
+
if (Number.isSafeInteger(value) && (value as number) < 0) return value as number;
|
|
347
|
+
if (typeof value === "string" && /^@[A-Za-z0-9_]{5,32}$/u.test(value)) {
|
|
348
|
+
return value as `@${string}`;
|
|
349
|
+
}
|
|
350
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post requires an exact negative channel ID or public @username.");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function validateTelegramChannelPostMedia(value: unknown): TelegramChannelPostMediaIntent {
|
|
354
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["kind", "fileName", "sizeBytes", "sha256"]) ||
|
|
355
|
+
(value.kind !== "photo" && value.kind !== "video") ||
|
|
356
|
+
typeof value.fileName !== "string" || value.fileName.length === 0 ||
|
|
357
|
+
value.fileName.length > TELEGRAM_CHANNEL_POST_MEDIA_FILE_NAME_MAX_LENGTH ||
|
|
358
|
+
!Number.isSafeInteger(value.sizeBytes) || (value.sizeBytes as number) <= 0 ||
|
|
359
|
+
(value.sizeBytes as number) > TELEGRAM_CHANNEL_POST_MEDIA_MAX_BYTES[value.kind] ||
|
|
360
|
+
typeof value.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.sha256)) {
|
|
361
|
+
throw new TelegramChannelPostJournalError("invalid",
|
|
362
|
+
"Telegram channel post journal contains an invalid media intent.");
|
|
363
|
+
}
|
|
364
|
+
return { kind: value.kind, fileName: value.fileName,
|
|
365
|
+
sizeBytes: value.sizeBytes as number, sha256: value.sha256 };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function sameTelegramChannelPostMedia(
|
|
369
|
+
left: TelegramChannelPostMediaIntent | undefined,
|
|
370
|
+
right: TelegramChannelPostMediaIntent | undefined,
|
|
371
|
+
): boolean {
|
|
372
|
+
if (left === undefined || right === undefined) return left === right;
|
|
373
|
+
return left.kind === right.kind && left.fileName === right.fileName &&
|
|
374
|
+
left.sizeBytes === right.sizeBytes && left.sha256 === right.sha256;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function validateRecord(value: unknown): TelegramChannelPostRecord {
|
|
378
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["operationId", "requestedChannel", "markdown",
|
|
379
|
+
"media", "createdAtMs", "updatedAtMs", "state", "issuedAtMs", "publishedAtMs", "channelId",
|
|
380
|
+
"messageId", "channelUsername", "mutationId", "attemptedMarkdown",
|
|
381
|
+
"mutationIssuedAtMs", "deletedAtMs", "lastMutationId", "channelTitle"]) || typeof value.operationId !== "string" ||
|
|
382
|
+
value.operationId.length === 0 || value.operationId.length > MAX_ID_LENGTH ||
|
|
383
|
+
typeof value.markdown !== "string" || value.markdown.length === 0 ||
|
|
384
|
+
value.markdown.length > MAX_MARKDOWN_LENGTH || !isSafeTime(value.createdAtMs) ||
|
|
385
|
+
!isSafeTime(value.updatedAtMs) || value.updatedAtMs < value.createdAtMs) {
|
|
386
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains an invalid record.");
|
|
387
|
+
}
|
|
388
|
+
const base: TelegramChannelPostRecordBase = { operationId: value.operationId,
|
|
389
|
+
requestedChannel: normalizeChannel(value.requestedChannel), markdown: value.markdown,
|
|
390
|
+
createdAtMs: value.createdAtMs, updatedAtMs: value.updatedAtMs,
|
|
391
|
+
...(value.media === undefined ? {} : { media: validateTelegramChannelPostMedia(value.media) }) };
|
|
392
|
+
if (value.state === "prepared" && value.issuedAtMs === undefined && value.publishedAtMs === undefined &&
|
|
393
|
+
value.channelId === undefined && value.messageId === undefined && value.channelUsername === undefined &&
|
|
394
|
+
value.mutationId === undefined && value.attemptedMarkdown === undefined &&
|
|
395
|
+
value.mutationIssuedAtMs === undefined && value.deletedAtMs === undefined &&
|
|
396
|
+
value.lastMutationId === undefined && value.channelTitle === undefined) {
|
|
397
|
+
return { ...base, state: "prepared" };
|
|
398
|
+
}
|
|
399
|
+
if (!isSafeTime(value.issuedAtMs) || value.issuedAtMs < value.createdAtMs) {
|
|
400
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid issuance evidence.");
|
|
401
|
+
}
|
|
402
|
+
if (value.state === "outcome-unknown" && value.publishedAtMs === undefined && value.channelId === undefined &&
|
|
403
|
+
value.messageId === undefined && value.channelUsername === undefined && value.mutationId === undefined &&
|
|
404
|
+
value.attemptedMarkdown === undefined && value.mutationIssuedAtMs === undefined &&
|
|
405
|
+
value.deletedAtMs === undefined && value.lastMutationId === undefined &&
|
|
406
|
+
value.channelTitle === undefined) {
|
|
407
|
+
return { ...base, state: "outcome-unknown", issuedAtMs: value.issuedAtMs };
|
|
408
|
+
}
|
|
409
|
+
if (!isSafeTime(value.publishedAtMs) || value.publishedAtMs < value.issuedAtMs ||
|
|
410
|
+
!Number.isSafeInteger(value.channelId) || (value.channelId as number) >= 0 ||
|
|
411
|
+
!Number.isSafeInteger(value.messageId) || (value.messageId as number) <= 0 ||
|
|
412
|
+
(value.channelUsername !== undefined && (typeof value.channelUsername !== "string" ||
|
|
413
|
+
!/^@[A-Za-z0-9_]{5,32}$/u.test(value.channelUsername))) ||
|
|
414
|
+
(value.channelTitle !== undefined && (typeof value.channelTitle !== "string" ||
|
|
415
|
+
value.channelTitle.length === 0 || value.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))) {
|
|
416
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid published identity.");
|
|
417
|
+
}
|
|
418
|
+
const identity: TelegramPublishedChannelPostIdentity = { issuedAtMs: value.issuedAtMs,
|
|
419
|
+
publishedAtMs: value.publishedAtMs, channelId: value.channelId as number,
|
|
420
|
+
messageId: value.messageId as number,
|
|
421
|
+
...(value.channelUsername ? { channelUsername: value.channelUsername as `@${string}` } : {}),
|
|
422
|
+
...(typeof value.channelTitle === "string" ? { channelTitle: value.channelTitle } : {}),
|
|
423
|
+
...(typeof value.lastMutationId === "string" && value.lastMutationId.length > 0 &&
|
|
424
|
+
value.lastMutationId.length <= MAX_ID_LENGTH ? { lastMutationId: value.lastMutationId } : {}) };
|
|
425
|
+
if (value.lastMutationId !== undefined && identity.lastMutationId === undefined) {
|
|
426
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains invalid mutation identity.");
|
|
427
|
+
}
|
|
428
|
+
if (value.state === "published" && value.mutationId === undefined &&
|
|
429
|
+
value.attemptedMarkdown === undefined && value.mutationIssuedAtMs === undefined &&
|
|
430
|
+
value.deletedAtMs === undefined) return { ...base, ...identity, state: "published" };
|
|
431
|
+
const validMutation = typeof value.mutationId === "string" && value.mutationId.length > 0 &&
|
|
432
|
+
value.mutationId.length <= MAX_ID_LENGTH && isSafeTime(value.mutationIssuedAtMs) &&
|
|
433
|
+
value.mutationIssuedAtMs >= value.publishedAtMs &&
|
|
434
|
+
value.mutationIssuedAtMs === value.updatedAtMs;
|
|
435
|
+
if (value.state === "edit-outcome-unknown" && validMutation &&
|
|
436
|
+
typeof value.attemptedMarkdown === "string" && value.attemptedMarkdown.length > 0 &&
|
|
437
|
+
value.attemptedMarkdown.length <= MAX_MARKDOWN_LENGTH && value.deletedAtMs === undefined) {
|
|
438
|
+
return { ...base, ...identity, state: "edit-outcome-unknown", mutationId: value.mutationId as string,
|
|
439
|
+
attemptedMarkdown: value.attemptedMarkdown, mutationIssuedAtMs: value.mutationIssuedAtMs as number };
|
|
440
|
+
}
|
|
441
|
+
if (value.state === "delete-outcome-unknown" && validMutation &&
|
|
442
|
+
value.attemptedMarkdown === undefined && value.deletedAtMs === undefined) {
|
|
443
|
+
return { ...base, ...identity, state: "delete-outcome-unknown", mutationId: value.mutationId as string,
|
|
444
|
+
mutationIssuedAtMs: value.mutationIssuedAtMs as number };
|
|
445
|
+
}
|
|
446
|
+
if (value.state === "deleted" && typeof value.mutationId === "string" &&
|
|
447
|
+
value.mutationId.length > 0 && value.mutationId.length <= MAX_ID_LENGTH &&
|
|
448
|
+
isSafeTime(value.deletedAtMs) && value.deletedAtMs >= value.publishedAtMs &&
|
|
449
|
+
value.deletedAtMs === value.updatedAtMs &&
|
|
450
|
+
value.mutationIssuedAtMs === undefined && value.attemptedMarkdown === undefined) {
|
|
451
|
+
return { ...base, ...identity, state: "deleted", mutationId: value.mutationId,
|
|
452
|
+
deletedAtMs: value.deletedAtMs };
|
|
453
|
+
}
|
|
454
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains an invalid state.");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function createTelegramChannelPostJournalStore(
|
|
458
|
+
options: TelegramChannelPostJournalStoreOptions,
|
|
459
|
+
): TelegramChannelPostJournalStore {
|
|
460
|
+
const maxRecords = options.maxRecords ?? DEFAULT_MAX_RECORDS;
|
|
461
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
462
|
+
const now = options.getNowMs ?? Date.now;
|
|
463
|
+
if (!options.path || !options.profileName || !/^[a-f0-9]{64}$/u.test(options.tokenSha256) ||
|
|
464
|
+
!Number.isSafeInteger(maxRecords) || maxRecords <= 0 || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
465
|
+
throw new Error("Telegram channel post journal options are invalid.");
|
|
466
|
+
}
|
|
467
|
+
const empty = (): TelegramChannelPostJournalFile => ({ version: CHANNEL_POST_JOURNAL_VERSION,
|
|
468
|
+
profile: options.profileName, tokenSha256: options.tokenSha256, records: [] });
|
|
469
|
+
const read = (): TelegramChannelPostJournalFile => {
|
|
470
|
+
let before;
|
|
471
|
+
try {
|
|
472
|
+
before = lstatSync(options.path, { bigint: true });
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if ((error as { code?: unknown })?.code === "ENOENT") return empty();
|
|
475
|
+
throw new TelegramChannelPostJournalError("io", "Could not inspect Telegram channel post journal.", error);
|
|
476
|
+
}
|
|
477
|
+
const uid = process.getuid?.();
|
|
478
|
+
if (!constants.O_NOFOLLOW || !constants.O_NONBLOCK || uid === undefined || !before.isFile() ||
|
|
479
|
+
before.isSymbolicLink() || before.uid !== BigInt(uid) || before.nlink !== 1n ||
|
|
480
|
+
(before.mode & 0o077n) !== 0n || before.size > BigInt(maxBytes)) {
|
|
481
|
+
throw new TelegramChannelPostJournalError(before.size > BigInt(maxBytes) ? "capacity" : "invalid",
|
|
482
|
+
"Telegram channel post journal is not a bounded no-follow regular file.");
|
|
483
|
+
}
|
|
484
|
+
let fd: number | undefined;
|
|
485
|
+
try {
|
|
486
|
+
fd = openSync(options.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
487
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
488
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino ||
|
|
489
|
+
opened.uid !== before.uid || opened.nlink !== 1n || (opened.mode & 0o077n) !== 0n ||
|
|
490
|
+
opened.size !== before.size || opened.mtimeNs !== before.mtimeNs) {
|
|
491
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal changed during inspection.");
|
|
492
|
+
}
|
|
493
|
+
const value = JSON.parse(readFileSync(fd, "utf8")) as unknown;
|
|
494
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["version", "profile", "tokenSha256", "records"]) ||
|
|
495
|
+
value.version !== CHANNEL_POST_JOURNAL_VERSION || value.profile !== options.profileName ||
|
|
496
|
+
value.tokenSha256 !== options.tokenSha256 || !Array.isArray(value.records)) {
|
|
497
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post journal identity or schema does not match.");
|
|
498
|
+
}
|
|
499
|
+
const records = value.records.map(validateRecord);
|
|
500
|
+
if (new Set(records.map(record => record.operationId)).size !== records.length) {
|
|
501
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post journal contains duplicate operation IDs.");
|
|
502
|
+
}
|
|
503
|
+
return { version: CHANNEL_POST_JOURNAL_VERSION, profile: options.profileName,
|
|
504
|
+
tokenSha256: options.tokenSha256, records };
|
|
505
|
+
} catch (error) {
|
|
506
|
+
if (error instanceof TelegramChannelPostJournalError) throw error;
|
|
507
|
+
throw new TelegramChannelPostJournalError("io", "Could not read Telegram channel post journal.", error);
|
|
508
|
+
} finally {
|
|
509
|
+
if (fd !== undefined) closeSync(fd);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
const publish = (file: TelegramChannelPostJournalFile): void => {
|
|
513
|
+
if (file.records.length > maxRecords) {
|
|
514
|
+
throw new TelegramChannelPostJournalError("capacity", "Telegram channel post journal record limit reached.");
|
|
515
|
+
}
|
|
516
|
+
const serialized = `${JSON.stringify(file, null, 2)}\n`;
|
|
517
|
+
if (Buffer.byteLength(serialized) > maxBytes) {
|
|
518
|
+
throw new TelegramChannelPostJournalError("capacity", "Telegram channel post journal byte limit reached.");
|
|
519
|
+
}
|
|
520
|
+
const temporaryPath = `${options.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
521
|
+
mkdirSync(dirname(options.path), { 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. */ }
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
const mutate = <T>(operation: (file: TelegramChannelPostJournalFile) => T): T => {
|
|
534
|
+
try {
|
|
535
|
+
return withTelegramFileTransaction(`${options.path}.transaction`, () => operation(read()));
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (error instanceof TelegramChannelPostJournalError) throw error;
|
|
538
|
+
throw new TelegramChannelPostJournalError("io", "Telegram channel post journal mutation failed.", error);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
return {
|
|
542
|
+
prepare(input) {
|
|
543
|
+
const operationId = input.operationId;
|
|
544
|
+
const channel = normalizeChannel(input.channel);
|
|
545
|
+
const media = input.media === undefined ? undefined
|
|
546
|
+
: validateTelegramChannelPostMedia(input.media);
|
|
547
|
+
if (typeof operationId !== "string" || operationId.length === 0 || operationId.length > MAX_ID_LENGTH ||
|
|
548
|
+
typeof input.markdown !== "string" || input.markdown.length === 0 ||
|
|
549
|
+
input.markdown.length > MAX_MARKDOWN_LENGTH) {
|
|
550
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post intent is invalid.");
|
|
551
|
+
}
|
|
552
|
+
return mutate(file => {
|
|
553
|
+
const existing = file.records.find(record => record.operationId === operationId);
|
|
554
|
+
if (existing) {
|
|
555
|
+
if (existing.requestedChannel !== channel || existing.markdown !== input.markdown ||
|
|
556
|
+
!sameTelegramChannelPostMedia(existing.media, media)) {
|
|
557
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post operation conflicts with retained intent.");
|
|
558
|
+
}
|
|
559
|
+
return { prepared: false, record: structuredClone(existing) };
|
|
560
|
+
}
|
|
561
|
+
const atMs = now();
|
|
562
|
+
if (!isSafeTime(atMs)) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
|
|
563
|
+
const record: TelegramChannelPostRecord = { operationId, requestedChannel: channel,
|
|
564
|
+
markdown: input.markdown, createdAtMs: atMs, updatedAtMs: atMs, state: "prepared",
|
|
565
|
+
...(media === undefined ? {} : { media }) };
|
|
566
|
+
publish({ ...file, records: [...file.records, record] });
|
|
567
|
+
return { prepared: true, record: structuredClone(record) };
|
|
568
|
+
});
|
|
569
|
+
},
|
|
570
|
+
beginPublication(operationId) {
|
|
571
|
+
return mutate(file => {
|
|
572
|
+
const index = file.records.findIndex(record => record.operationId === operationId);
|
|
573
|
+
if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post intent is missing.");
|
|
574
|
+
const current = file.records[index]!;
|
|
575
|
+
if (current.state !== "prepared") return { began: false, record: structuredClone(current) };
|
|
576
|
+
const atMs = now();
|
|
577
|
+
if (!isSafeTime(atMs) || atMs < current.createdAtMs) {
|
|
578
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
|
|
579
|
+
}
|
|
580
|
+
const record: TelegramChannelPostRecord = { ...current, state: "outcome-unknown",
|
|
581
|
+
issuedAtMs: atMs, updatedAtMs: atMs };
|
|
582
|
+
const records = [...file.records]; records[index] = record; publish({ ...file, records });
|
|
583
|
+
return { began: true, record: structuredClone(record) };
|
|
584
|
+
});
|
|
585
|
+
},
|
|
586
|
+
confirmPublished(input) {
|
|
587
|
+
return mutate(file => {
|
|
588
|
+
const index = file.records.findIndex(record => record.operationId === input.operationId);
|
|
589
|
+
if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post intent is missing.");
|
|
590
|
+
const current = file.records[index]!;
|
|
591
|
+
if (current.state === "published") {
|
|
592
|
+
if (current.channelId !== input.channelId || current.messageId !== input.messageId ||
|
|
593
|
+
current.channelUsername !== input.channelUsername ||
|
|
594
|
+
current.channelTitle !== input.channelTitle) {
|
|
595
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post confirmation conflicts with retained identity.");
|
|
596
|
+
}
|
|
597
|
+
return { confirmed: false, record: structuredClone(current) };
|
|
598
|
+
}
|
|
599
|
+
if (current.state !== "outcome-unknown" || !Number.isSafeInteger(input.channelId) || input.channelId >= 0 ||
|
|
600
|
+
!Number.isSafeInteger(input.messageId) || input.messageId <= 0 ||
|
|
601
|
+
(input.channelUsername !== undefined && !/^@[A-Za-z0-9_]{5,32}$/u.test(input.channelUsername)) ||
|
|
602
|
+
(input.channelTitle !== undefined && (input.channelTitle.length === 0 ||
|
|
603
|
+
input.channelTitle.length > MAX_CHANNEL_TITLE_LENGTH))) {
|
|
604
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post confirmation is invalid or premature.");
|
|
605
|
+
}
|
|
606
|
+
const atMs = now();
|
|
607
|
+
if (!isSafeTime(atMs) || atMs < current.issuedAtMs) {
|
|
608
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
|
|
609
|
+
}
|
|
610
|
+
const record: TelegramChannelPostRecord = { ...current, state: "published",
|
|
611
|
+
publishedAtMs: atMs, updatedAtMs: atMs, channelId: input.channelId,
|
|
612
|
+
messageId: input.messageId, ...(input.channelUsername ? { channelUsername: input.channelUsername } : {}),
|
|
613
|
+
...(input.channelTitle ? { channelTitle: input.channelTitle } : {}) };
|
|
614
|
+
const records = [...file.records]; records[index] = record; publish({ ...file, records });
|
|
615
|
+
return { confirmed: true, record: structuredClone(record) };
|
|
616
|
+
});
|
|
617
|
+
},
|
|
618
|
+
beginEdit(input) {
|
|
619
|
+
return mutate(file => {
|
|
620
|
+
const index = file.records.findIndex(record => record.operationId === input.operationId);
|
|
621
|
+
if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
|
|
622
|
+
const current = file.records[index]!;
|
|
623
|
+
if (current.state === "edit-outcome-unknown") {
|
|
624
|
+
if (current.mutationId === input.mutationId && current.attemptedMarkdown === input.markdown)
|
|
625
|
+
return { began: false, record: structuredClone(current) };
|
|
626
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post already has an unresolved mutation.");
|
|
627
|
+
}
|
|
628
|
+
if (current.state === "published" && current.lastMutationId === input.mutationId) {
|
|
629
|
+
if (current.markdown === input.markdown) return { began: false, record: structuredClone(current) };
|
|
630
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post mutation identity conflicts with retained edit.");
|
|
631
|
+
}
|
|
632
|
+
if (current.state !== "published" || !input.mutationId || input.mutationId.length > MAX_ID_LENGTH ||
|
|
633
|
+
!input.markdown || input.markdown.length > MAX_MARKDOWN_LENGTH) {
|
|
634
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post edit is invalid or unavailable.");
|
|
635
|
+
}
|
|
636
|
+
const atMs = now();
|
|
637
|
+
if (!isSafeTime(atMs) || atMs < current.updatedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
|
|
638
|
+
const record: TelegramChannelPostRecord = { ...current, state: "edit-outcome-unknown",
|
|
639
|
+
mutationId: input.mutationId, attemptedMarkdown: input.markdown,
|
|
640
|
+
mutationIssuedAtMs: atMs, updatedAtMs: atMs };
|
|
641
|
+
const records = [...file.records]; records[index] = record; publish({ ...file, records });
|
|
642
|
+
return { began: true, record: structuredClone(record) };
|
|
643
|
+
});
|
|
644
|
+
},
|
|
645
|
+
confirmEdited(input) {
|
|
646
|
+
return mutate(file => {
|
|
647
|
+
const index = file.records.findIndex(record => record.operationId === input.operationId);
|
|
648
|
+
if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
|
|
649
|
+
const current = file.records[index]!;
|
|
650
|
+
if (current.state === "published" && current.lastMutationId === input.mutationId)
|
|
651
|
+
return { confirmed: false, record: structuredClone(current) };
|
|
652
|
+
if (current.state !== "edit-outcome-unknown" || current.mutationId !== input.mutationId)
|
|
653
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post edit confirmation is stale.");
|
|
654
|
+
const atMs = now();
|
|
655
|
+
if (!isSafeTime(atMs) || atMs < current.mutationIssuedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
|
|
656
|
+
const { mutationId, attemptedMarkdown, mutationIssuedAtMs, ...prior } = current;
|
|
657
|
+
const record: TelegramChannelPostRecord = { ...prior, state: "published", markdown: attemptedMarkdown,
|
|
658
|
+
lastMutationId: mutationId, updatedAtMs: atMs };
|
|
659
|
+
const records = [...file.records]; records[index] = record; publish({ ...file, records });
|
|
660
|
+
return { confirmed: true, record: structuredClone(record) };
|
|
661
|
+
});
|
|
662
|
+
},
|
|
663
|
+
beginDelete(input) {
|
|
664
|
+
return mutate(file => {
|
|
665
|
+
const index = file.records.findIndex(record => record.operationId === input.operationId);
|
|
666
|
+
if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
|
|
667
|
+
const current = file.records[index]!;
|
|
668
|
+
if (current.state === "delete-outcome-unknown") {
|
|
669
|
+
if (current.mutationId === input.mutationId) return { began: false, record: structuredClone(current) };
|
|
670
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post already has an unresolved deletion.");
|
|
671
|
+
}
|
|
672
|
+
if (current.state === "deleted" && current.mutationId === input.mutationId)
|
|
673
|
+
return { began: false, record: structuredClone(current) };
|
|
674
|
+
if (current.state !== "published" || !input.mutationId || input.mutationId.length > MAX_ID_LENGTH)
|
|
675
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post deletion is invalid or unavailable.");
|
|
676
|
+
const atMs = now();
|
|
677
|
+
if (!isSafeTime(atMs) || atMs < current.updatedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
|
|
678
|
+
const record: TelegramChannelPostRecord = { ...current, state: "delete-outcome-unknown",
|
|
679
|
+
mutationId: input.mutationId, mutationIssuedAtMs: atMs, updatedAtMs: atMs };
|
|
680
|
+
const records = [...file.records]; records[index] = record; publish({ ...file, records });
|
|
681
|
+
return { began: true, record: structuredClone(record) };
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
confirmDeleted(input) {
|
|
685
|
+
return mutate(file => {
|
|
686
|
+
const index = file.records.findIndex(record => record.operationId === input.operationId);
|
|
687
|
+
if (index < 0) throw new TelegramChannelPostJournalError("conflict", "Telegram channel post is missing.");
|
|
688
|
+
const current = file.records[index]!;
|
|
689
|
+
if (current.state === "deleted" && current.mutationId === input.mutationId)
|
|
690
|
+
return { confirmed: false, record: structuredClone(current) };
|
|
691
|
+
if (current.state !== "delete-outcome-unknown" || current.mutationId !== input.mutationId)
|
|
692
|
+
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post deletion confirmation is stale.");
|
|
693
|
+
const atMs = now();
|
|
694
|
+
if (!isSafeTime(atMs) || atMs < current.mutationIssuedAtMs) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
|
|
695
|
+
const { mutationIssuedAtMs, ...prior } = current;
|
|
696
|
+
const record: TelegramChannelPostRecord = { ...prior, state: "deleted", deletedAtMs: atMs, updatedAtMs: atMs };
|
|
697
|
+
const records = [...file.records]; records[index] = record; publish({ ...file, records });
|
|
698
|
+
return { confirmed: true, record: structuredClone(record) };
|
|
699
|
+
});
|
|
700
|
+
},
|
|
701
|
+
get(operationId) {
|
|
702
|
+
if (typeof operationId !== "string" || operationId.length === 0 || operationId.length > MAX_ID_LENGTH) {
|
|
703
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post operation ID is invalid.");
|
|
704
|
+
}
|
|
705
|
+
const record = read().records.find(candidate => candidate.operationId === operationId);
|
|
706
|
+
return record === undefined ? undefined : structuredClone(record);
|
|
707
|
+
},
|
|
708
|
+
list(input = {}) {
|
|
709
|
+
const limit = input.limit ?? 20;
|
|
710
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > maxRecords) {
|
|
711
|
+
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post list limit is invalid.");
|
|
712
|
+
}
|
|
713
|
+
const channel = input.channel === undefined ? undefined : normalizeChannel(input.channel);
|
|
714
|
+
return read().records.filter(record => channel === undefined || record.requestedChannel === channel)
|
|
715
|
+
.slice(-limit).reverse().map(record => structuredClone(record));
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
}
|