@llblab/pi-kit 0.7.0 → 0.8.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/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +21 -21
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -122
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +11 -0
- package/node_modules/@llblab/pi-state-flow/README.md +41 -35
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +36 -16
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -4
- package/node_modules/@llblab/pi-state-flow/index.ts +23 -1
- package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +191 -29
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -1
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +42 -7
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +38 -8
- package/node_modules/@llblab/pi-state-flow/lib/episode.ts +1 -13
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +290 -134
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +32 -16
- package/node_modules/@llblab/pi-state-flow/lib/logging.ts +41 -0
- package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +12 -6
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +16 -0
- package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +167 -31
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +15 -6
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +40 -34
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +4 -9
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +25 -5
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +17 -147
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +49 -27
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +3 -2
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +6 -1
- package/node_modules/@llblab/pi-telegram/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +8 -6
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -4
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +18 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +5 -0
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +189 -15
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +3 -0
- package/node_modules/@llblab/pi-telegram/lib/config.ts +67 -4
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +65 -6
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +49 -3
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +1 -0
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +66 -8
- package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +19 -0
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +39 -0
- package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
- package/node_modules/@llblab/pi-telegram/lib/status.ts +41 -4
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +74 -18
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +3 -3
- package/node_modules/@llblab/pi-state-flow/lib/validation.ts +0 -27
- /package/node_modules/@llblab/pi-telegram/lib/{logs.ts → logging.ts} +0 -0
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Owns publication intent, outcome-unknown fencing, confirmed post identity, and bounded local listing
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync,
|
|
7
|
+
import { chmodSync, closeSync, constants, createReadStream, fstatSync, lstatSync, mkdirSync, openSync,
|
|
8
8
|
readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { lstat } from "node:fs/promises";
|
|
10
|
+
import { basename, dirname } from "node:path";
|
|
11
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
12
|
|
|
12
13
|
import { Type } from "@sinclair/typebox";
|
|
13
14
|
|
|
@@ -21,6 +22,125 @@ const MAX_ID_LENGTH = 256;
|
|
|
21
22
|
const MAX_MARKDOWN_LENGTH = 100_000;
|
|
22
23
|
const MAX_CHANNEL_TITLE_LENGTH = 255;
|
|
23
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
|
+
|
|
24
144
|
type ChannelPostJournalCode = "invalid" | "conflict" | "capacity" | "io";
|
|
25
145
|
|
|
26
146
|
export class TelegramChannelPostJournalError extends Error {
|
|
@@ -39,6 +159,7 @@ interface TelegramChannelPostRecordBase {
|
|
|
39
159
|
operationId: string;
|
|
40
160
|
requestedChannel: TelegramChannelPostAddress;
|
|
41
161
|
markdown: string;
|
|
162
|
+
media?: TelegramChannelPostMediaIntent;
|
|
42
163
|
createdAtMs: number;
|
|
43
164
|
updatedAtMs: number;
|
|
44
165
|
}
|
|
@@ -82,8 +203,10 @@ export interface TelegramChannelPostJournalStoreOptions {
|
|
|
82
203
|
}
|
|
83
204
|
|
|
84
205
|
export interface TelegramChannelPostJournalStore {
|
|
85
|
-
prepare(input: { operationId: string; channel: TelegramChannelPostAddress; markdown: string
|
|
206
|
+
prepare(input: { operationId: string; channel: TelegramChannelPostAddress; markdown: string;
|
|
207
|
+
media?: TelegramChannelPostMediaIntent }):
|
|
86
208
|
{ prepared: boolean; record: TelegramChannelPostRecord };
|
|
209
|
+
get(operationId: string): TelegramChannelPostRecord | undefined;
|
|
87
210
|
beginPublication(operationId: string): { began: boolean; record: TelegramChannelPostRecord };
|
|
88
211
|
confirmPublished(input: { operationId: string; channelId: number; messageId: number;
|
|
89
212
|
channelUsername?: `@${string}`; channelTitle?: string }):
|
|
@@ -104,6 +227,7 @@ export async function publishTelegramChannelPost(input: {
|
|
|
104
227
|
operationId: string;
|
|
105
228
|
channel: TelegramChannelPostAddress;
|
|
106
229
|
markdown: string;
|
|
230
|
+
media?: TelegramChannelPostMediaIntent;
|
|
107
231
|
observeChannel(channel: TelegramChannelPostAddress): Promise<{
|
|
108
232
|
id: number; type: string; username?: string; title?: string;
|
|
109
233
|
}>;
|
|
@@ -119,7 +243,8 @@ export async function publishTelegramChannelPost(input: {
|
|
|
119
243
|
observed.title.length > MAX_CHANNEL_TITLE_LENGTH))) {
|
|
120
244
|
throw new Error("Telegram channel delivery requires bounded exact getChat channel identity.");
|
|
121
245
|
}
|
|
122
|
-
input.store.prepare({ operationId: input.operationId, channel: input.channel,
|
|
246
|
+
input.store.prepare({ operationId: input.operationId, channel: input.channel,
|
|
247
|
+
markdown: input.markdown, ...(input.media === undefined ? {} : { media: input.media }) });
|
|
123
248
|
const issuance = input.store.beginPublication(input.operationId);
|
|
124
249
|
if (!issuance.began) {
|
|
125
250
|
if (issuance.record.state === "published") return issuance.record;
|
|
@@ -135,6 +260,16 @@ export async function publishTelegramChannelPost(input: {
|
|
|
135
260
|
...(observed.title ? { channelTitle: observed.title } : {}) }).record;
|
|
136
261
|
}
|
|
137
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
|
+
|
|
138
273
|
export function registerTelegramChannelPostMutationTool(
|
|
139
274
|
pi: ExtensionAPI,
|
|
140
275
|
deps: { mutate(input: { action: "edit" | "delete"; operationId: string;
|
|
@@ -143,7 +278,7 @@ export function registerTelegramChannelPostMutationTool(
|
|
|
143
278
|
pi.registerTool({
|
|
144
279
|
name: "telegram_channel_post",
|
|
145
280
|
label: "Edit or Delete Telegram Channel Post",
|
|
146
|
-
description: "Edit or delete one exact published post retained by telegram_channel_posts. Unknown outcomes are never replayed.",
|
|
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.",
|
|
147
282
|
parameters: Type.Object({
|
|
148
283
|
action: Type.Union([Type.Literal("edit"), Type.Literal("delete")]),
|
|
149
284
|
operation_id: Type.String({ minLength: 1, maxLength: MAX_ID_LENGTH }),
|
|
@@ -153,10 +288,13 @@ export function registerTelegramChannelPostMutationTool(
|
|
|
153
288
|
try {
|
|
154
289
|
const record = await deps.mutate({ action: params.action, operationId: params.operation_id,
|
|
155
290
|
mutationId: toolCallId, ...(params.markdown === undefined ? {} : { markdown: params.markdown }) });
|
|
156
|
-
return { content: [{ type: "text" as const, text:
|
|
291
|
+
return { content: [{ type: "text" as const, text: formatTelegramChannelPostToolOutput(record) }],
|
|
157
292
|
details: { record } };
|
|
158
|
-
} catch {
|
|
159
|
-
|
|
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.");
|
|
160
298
|
}
|
|
161
299
|
},
|
|
162
300
|
});
|
|
@@ -182,10 +320,10 @@ export function registerTelegramChannelPostListTool(
|
|
|
182
320
|
channel: params.chat_id as TelegramChannelPostAddress | undefined,
|
|
183
321
|
limit: params.limit,
|
|
184
322
|
});
|
|
185
|
-
return { content: [{ type: "text" as const, text:
|
|
323
|
+
return { content: [{ type: "text" as const, text: formatTelegramChannelPostToolOutput(records) }],
|
|
186
324
|
details: { records } };
|
|
187
325
|
} catch {
|
|
188
|
-
throw new Error("
|
|
326
|
+
throw new Error("\nTelegram channel post listing failed without exposing retained content or storage details.");
|
|
189
327
|
}
|
|
190
328
|
},
|
|
191
329
|
});
|
|
@@ -212,9 +350,33 @@ function normalizeChannel(value: unknown): TelegramChannelPostAddress {
|
|
|
212
350
|
throw new TelegramChannelPostJournalError("invalid", "Telegram channel post requires an exact negative channel ID or public @username.");
|
|
213
351
|
}
|
|
214
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
|
+
|
|
215
377
|
function validateRecord(value: unknown): TelegramChannelPostRecord {
|
|
216
378
|
if (!isRecord(value) || !hasOnlyKeys(value, ["operationId", "requestedChannel", "markdown",
|
|
217
|
-
"createdAtMs", "updatedAtMs", "state", "issuedAtMs", "publishedAtMs", "channelId",
|
|
379
|
+
"media", "createdAtMs", "updatedAtMs", "state", "issuedAtMs", "publishedAtMs", "channelId",
|
|
218
380
|
"messageId", "channelUsername", "mutationId", "attemptedMarkdown",
|
|
219
381
|
"mutationIssuedAtMs", "deletedAtMs", "lastMutationId", "channelTitle"]) || typeof value.operationId !== "string" ||
|
|
220
382
|
value.operationId.length === 0 || value.operationId.length > MAX_ID_LENGTH ||
|
|
@@ -225,7 +387,8 @@ function validateRecord(value: unknown): TelegramChannelPostRecord {
|
|
|
225
387
|
}
|
|
226
388
|
const base: TelegramChannelPostRecordBase = { operationId: value.operationId,
|
|
227
389
|
requestedChannel: normalizeChannel(value.requestedChannel), markdown: value.markdown,
|
|
228
|
-
createdAtMs: value.createdAtMs, updatedAtMs: value.updatedAtMs
|
|
390
|
+
createdAtMs: value.createdAtMs, updatedAtMs: value.updatedAtMs,
|
|
391
|
+
...(value.media === undefined ? {} : { media: validateTelegramChannelPostMedia(value.media) }) };
|
|
229
392
|
if (value.state === "prepared" && value.issuedAtMs === undefined && value.publishedAtMs === undefined &&
|
|
230
393
|
value.channelId === undefined && value.messageId === undefined && value.channelUsername === undefined &&
|
|
231
394
|
value.mutationId === undefined && value.attemptedMarkdown === undefined &&
|
|
@@ -379,6 +542,8 @@ export function createTelegramChannelPostJournalStore(
|
|
|
379
542
|
prepare(input) {
|
|
380
543
|
const operationId = input.operationId;
|
|
381
544
|
const channel = normalizeChannel(input.channel);
|
|
545
|
+
const media = input.media === undefined ? undefined
|
|
546
|
+
: validateTelegramChannelPostMedia(input.media);
|
|
382
547
|
if (typeof operationId !== "string" || operationId.length === 0 || operationId.length > MAX_ID_LENGTH ||
|
|
383
548
|
typeof input.markdown !== "string" || input.markdown.length === 0 ||
|
|
384
549
|
input.markdown.length > MAX_MARKDOWN_LENGTH) {
|
|
@@ -387,7 +552,8 @@ export function createTelegramChannelPostJournalStore(
|
|
|
387
552
|
return mutate(file => {
|
|
388
553
|
const existing = file.records.find(record => record.operationId === operationId);
|
|
389
554
|
if (existing) {
|
|
390
|
-
if (existing.requestedChannel !== channel || existing.markdown !== input.markdown
|
|
555
|
+
if (existing.requestedChannel !== channel || existing.markdown !== input.markdown ||
|
|
556
|
+
!sameTelegramChannelPostMedia(existing.media, media)) {
|
|
391
557
|
throw new TelegramChannelPostJournalError("conflict", "Telegram channel post operation conflicts with retained intent.");
|
|
392
558
|
}
|
|
393
559
|
return { prepared: false, record: structuredClone(existing) };
|
|
@@ -395,7 +561,8 @@ export function createTelegramChannelPostJournalStore(
|
|
|
395
561
|
const atMs = now();
|
|
396
562
|
if (!isSafeTime(atMs)) throw new TelegramChannelPostJournalError("invalid", "Telegram channel post clock is invalid.");
|
|
397
563
|
const record: TelegramChannelPostRecord = { operationId, requestedChannel: channel,
|
|
398
|
-
markdown: input.markdown, createdAtMs: atMs, updatedAtMs: atMs, state: "prepared"
|
|
564
|
+
markdown: input.markdown, createdAtMs: atMs, updatedAtMs: atMs, state: "prepared",
|
|
565
|
+
...(media === undefined ? {} : { media }) };
|
|
399
566
|
publish({ ...file, records: [...file.records, record] });
|
|
400
567
|
return { prepared: true, record: structuredClone(record) };
|
|
401
568
|
});
|
|
@@ -531,6 +698,13 @@ export function createTelegramChannelPostJournalStore(
|
|
|
531
698
|
return { confirmed: true, record: structuredClone(record) };
|
|
532
699
|
});
|
|
533
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
|
+
},
|
|
534
708
|
list(input = {}) {
|
|
535
709
|
const limit = input.limit ?? 20;
|
|
536
710
|
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > maxRecords) {
|
|
@@ -383,6 +383,7 @@ export interface TelegramBridgeCommandRegistrationDeps {
|
|
|
383
383
|
getStatusLines: (options?: TelegramBridgeStatusLineOptions) => string[];
|
|
384
384
|
reloadConfig: () => Promise<void>;
|
|
385
385
|
hasBotToken: () => boolean;
|
|
386
|
+
getBotTokenDiagnostic?: () => string | undefined;
|
|
386
387
|
startPolling: (
|
|
387
388
|
ctx: ExtensionCommandContext,
|
|
388
389
|
options?: TelegramBridgeCommandStartPollingOptions,
|
|
@@ -543,6 +544,8 @@ export function registerTelegramBridgeCommands(
|
|
|
543
544
|
await (deps.activateDefaultProfileConfig?.(ctx) ?? deps.reloadConfig());
|
|
544
545
|
}
|
|
545
546
|
if (!deps.hasBotToken()) {
|
|
547
|
+
const botTokenDiagnostic = deps.getBotTokenDiagnostic?.();
|
|
548
|
+
if (botTokenDiagnostic) ctx.ui.notify(botTokenDiagnostic, "error");
|
|
546
549
|
const profileNames = deps.getProfileNames?.() ?? [];
|
|
547
550
|
if (!profileName && profileNames.length > 0) {
|
|
548
551
|
ctx.ui.notify(
|
|
@@ -62,6 +62,59 @@ function getConfigPath(): string {
|
|
|
62
62
|
return resolveTelegramConfigPath();
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
const TELEGRAM_BOT_TOKEN_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
66
|
+
|
|
67
|
+
/** Parsed stored bot-token form: a literal secret or one environment-variable reference. */
|
|
68
|
+
export type TelegramBotTokenReference =
|
|
69
|
+
| { kind: "literal"; token: string }
|
|
70
|
+
| { kind: "environment"; variable: string }
|
|
71
|
+
| { kind: "malformed" };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse a persisted bot-token value. `$NAME` and `${NAME}` are exact
|
|
75
|
+
* environment-variable references. Any other `$`-prefixed value is malformed
|
|
76
|
+
* rather than a literal secret so a broken reference fails closed.
|
|
77
|
+
*/
|
|
78
|
+
export function getTelegramBotTokenReference(
|
|
79
|
+
value: string | undefined,
|
|
80
|
+
): TelegramBotTokenReference | undefined {
|
|
81
|
+
const trimmed = value?.trim();
|
|
82
|
+
if (!trimmed) return undefined;
|
|
83
|
+
if (!trimmed.startsWith("$")) return { kind: "literal", token: trimmed };
|
|
84
|
+
const body =
|
|
85
|
+
trimmed.startsWith("${") && trimmed.endsWith("}")
|
|
86
|
+
? trimmed.slice(2, -1)
|
|
87
|
+
: trimmed.slice(1);
|
|
88
|
+
return TELEGRAM_BOT_TOKEN_ENV_NAME_PATTERN.test(body)
|
|
89
|
+
? { kind: "environment", variable: body }
|
|
90
|
+
: { kind: "malformed" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Resolve a persisted token at a validation/activation boundary. */
|
|
94
|
+
export function resolveTelegramBotToken(
|
|
95
|
+
value: string | undefined,
|
|
96
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
97
|
+
): string | undefined {
|
|
98
|
+
const reference = getTelegramBotTokenReference(value);
|
|
99
|
+
if (reference?.kind === "literal") return reference.token;
|
|
100
|
+
if (reference?.kind !== "environment") return undefined;
|
|
101
|
+
return env[reference.variable]?.trim() || undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Redacted diagnostic for an unresolved or malformed token reference. */
|
|
105
|
+
export function getTelegramBotTokenDiagnostic(
|
|
106
|
+
value: string | undefined,
|
|
107
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
108
|
+
): string | undefined {
|
|
109
|
+
const reference = getTelegramBotTokenReference(value);
|
|
110
|
+
if (reference?.kind === "malformed") {
|
|
111
|
+
return "Telegram bot token environment reference is malformed; use $NAME or ${NAME}.";
|
|
112
|
+
}
|
|
113
|
+
if (reference?.kind !== "environment") return undefined;
|
|
114
|
+
if (resolveTelegramBotToken(value, env)) return undefined;
|
|
115
|
+
return `Telegram bot token environment variable ${reference.variable} is not set.`;
|
|
116
|
+
}
|
|
117
|
+
|
|
65
118
|
export type TelegramOutboundCommandTemplateConfig =
|
|
66
119
|
string | CommandTemplateObjectConfig;
|
|
67
120
|
export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
|
|
@@ -206,6 +259,7 @@ export interface TelegramConfigStore {
|
|
|
206
259
|
activateProfile: (profileName: string | undefined) => boolean;
|
|
207
260
|
getActiveProfileName: () => string | undefined;
|
|
208
261
|
getBotToken: () => string | undefined;
|
|
262
|
+
getBotTokenDiagnostic: () => string | undefined;
|
|
209
263
|
hasBotToken: () => boolean;
|
|
210
264
|
getAllowedUserId: () => number | undefined;
|
|
211
265
|
getLegacyPollingCursor: () => number | undefined;
|
|
@@ -261,6 +315,8 @@ export interface TelegramConfigStoreOptions {
|
|
|
261
315
|
initialConfig?: TelegramConfig;
|
|
262
316
|
agentDir?: string;
|
|
263
317
|
configPath?: string;
|
|
318
|
+
/** Environment used to resolve `$NAME` token references; defaults to process.env. */
|
|
319
|
+
env?: NodeJS.ProcessEnv;
|
|
264
320
|
recordRuntimeEvent?: (
|
|
265
321
|
category: string,
|
|
266
322
|
error: unknown,
|
|
@@ -653,6 +709,7 @@ export function createTelegramConfigStore(
|
|
|
653
709
|
let lastLoadRecoveredInvalidConfig = false;
|
|
654
710
|
const agentDir = options.agentDir ?? resolveAgentDir();
|
|
655
711
|
const configPath = options.configPath ?? getConfigPath();
|
|
712
|
+
const env = options.env ?? process.env;
|
|
656
713
|
const getEffectiveConfig = () =>
|
|
657
714
|
applyTelegramProfile(config, activeProfileName);
|
|
658
715
|
const setEffectiveConfig = (nextConfig: TelegramConfig) => {
|
|
@@ -683,8 +740,12 @@ export function createTelegramConfigStore(
|
|
|
683
740
|
return withTelegramFileTransaction(`${configPath}.transaction`, () => {
|
|
684
741
|
const latest = readTelegramConfigForTransaction(configPath);
|
|
685
742
|
const profile = latest.profiles?.[profileName];
|
|
686
|
-
|
|
687
|
-
|
|
743
|
+
const resolvedToken =
|
|
744
|
+
typeof profile?.botToken === "string"
|
|
745
|
+
? resolveTelegramBotToken(profile.botToken, env)
|
|
746
|
+
: undefined;
|
|
747
|
+
if (!profile || !resolvedToken ||
|
|
748
|
+
createHash("sha256").update(resolvedToken).digest("hex") !== tokenSha256 ||
|
|
688
749
|
(profile.allowedUserId !== undefined &&
|
|
689
750
|
(!Number.isSafeInteger(profile.allowedUserId) || profile.allowedUserId <= 0))) {
|
|
690
751
|
throw new Error("Telegram pairing admission authority is unavailable or changed.");
|
|
@@ -723,8 +784,10 @@ export function createTelegramConfigStore(
|
|
|
723
784
|
return true;
|
|
724
785
|
},
|
|
725
786
|
getActiveProfileName: () => activeProfileName,
|
|
726
|
-
getBotToken: () => getEffectiveConfig().botToken,
|
|
727
|
-
|
|
787
|
+
getBotToken: () => resolveTelegramBotToken(getEffectiveConfig().botToken, env),
|
|
788
|
+
getBotTokenDiagnostic: () =>
|
|
789
|
+
getTelegramBotTokenDiagnostic(getEffectiveConfig().botToken, env),
|
|
790
|
+
hasBotToken: () => !!resolveTelegramBotToken(getEffectiveConfig().botToken, env),
|
|
728
791
|
getAllowedUserId: () => getEffectiveConfig().allowedUserId,
|
|
729
792
|
getLegacyPollingCursor: () =>
|
|
730
793
|
(getEffectiveConfig() as TelegramConfig & TelegramLegacyCursorCarrier)
|
|
@@ -21,7 +21,7 @@ import * as Inbound from "./inbound.ts";
|
|
|
21
21
|
import * as Journal from "./journal.ts";
|
|
22
22
|
import * as Lifecycle from "./lifecycle.ts";
|
|
23
23
|
import * as Locks from "./locks.ts";
|
|
24
|
-
import * as
|
|
24
|
+
import * as Logging from "./logging.ts";
|
|
25
25
|
import * as Media from "./media.ts";
|
|
26
26
|
import * as MenuQueue from "./menu-queue.ts";
|
|
27
27
|
import * as MenuSettings from "./menu-settings.ts";
|
|
@@ -82,7 +82,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
82
82
|
} = piRuntime;
|
|
83
83
|
const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
|
|
84
84
|
const runtimeDiagnostics =
|
|
85
|
-
|
|
85
|
+
Logging.createTelegramRuntimeDiagnosticsRuntime<Pi.ExtensionContext>();
|
|
86
86
|
const runtimeEvents = runtimeDiagnostics.events;
|
|
87
87
|
const recordRuntimeEvent = runtimeDiagnostics.recordRuntimeEvent;
|
|
88
88
|
const configStore = Config.createTelegramConfigStore({ recordRuntimeEvent });
|
|
@@ -443,7 +443,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
443
443
|
Pi.ExtensionContext,
|
|
444
444
|
Queue.TelegramQueueItem<Pi.ExtensionContext>
|
|
445
445
|
>({
|
|
446
|
-
getConfig: configStore
|
|
446
|
+
getConfig: Status.createTelegramBridgeStatusConfigGetter(configStore),
|
|
447
447
|
getActiveProfileName: configStore.getActiveProfileName,
|
|
448
448
|
getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths,
|
|
449
449
|
isPollingActive: Polling.createTelegramPollingActivityReader(
|
|
@@ -582,6 +582,13 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
582
582
|
answerGuestQuery,
|
|
583
583
|
});
|
|
584
584
|
|
|
585
|
+
// Answer guest queries immediately and replace the ACK with the final text.
|
|
586
|
+
const answerGuestQueryForInlineMessage =
|
|
587
|
+
telegramApiRuntime.answerGuestQueryForInlineMessage;
|
|
588
|
+
const editGuestReply = Replies.createGuestMarkdownReplyEditor({
|
|
589
|
+
editGuestInlineMessage: telegramApiRuntime.editGuestInlineMessage,
|
|
590
|
+
});
|
|
591
|
+
|
|
585
592
|
const promptDispatchRuntime = Runtime.createTelegramPromptDispatchRuntime({
|
|
586
593
|
lifecycle,
|
|
587
594
|
typing,
|
|
@@ -970,6 +977,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
970
977
|
sendInteractiveMessage,
|
|
971
978
|
deleteMessage: deleteTelegramMessage,
|
|
972
979
|
answerGuestQuery,
|
|
980
|
+
answerGuestQueryForInlineMessage,
|
|
973
981
|
sendTextReply,
|
|
974
982
|
setMyCommands,
|
|
975
983
|
validateThreadName(threadName) {
|
|
@@ -1408,6 +1416,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1408
1416
|
lock: lockRuntime,
|
|
1409
1417
|
transportMonitor: telegramThreadCapabilityMonitor,
|
|
1410
1418
|
hasBotToken: configStore.hasBotToken,
|
|
1419
|
+
getBotTokenDiagnostic: configStore.getBotTokenDiagnostic,
|
|
1411
1420
|
canStartPolling: Pi.canStartPollingInExtensionContext,
|
|
1412
1421
|
isContextCurrent: telegramSessionContextStore.isCurrent,
|
|
1413
1422
|
formatStartBlockedMessage: Pi.formatPollingStartBlockedByRunMode,
|
|
@@ -1675,6 +1684,41 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1675
1684
|
});
|
|
1676
1685
|
return record.state === "published" ? record.messageId : undefined;
|
|
1677
1686
|
},
|
|
1687
|
+
async sendChannelMediaMessage(channel, mediaPath, markdown, options) {
|
|
1688
|
+
if (!lockRuntime.owns()) {
|
|
1689
|
+
throw new Error("Telegram channel media delivery requires direct leader transport ownership.");
|
|
1690
|
+
}
|
|
1691
|
+
const profileName = configStore.getActiveProfileName() ?? "default";
|
|
1692
|
+
const botToken = configStore.getBotToken();
|
|
1693
|
+
if (!botToken) throw new Error("Telegram channel media delivery requires an active bot token.");
|
|
1694
|
+
const media = await ChannelPosts.inspectTelegramChannelPostMedia(mediaPath);
|
|
1695
|
+
const caption = Replies.renderTelegramMarkdownToHtmlDraft(markdown);
|
|
1696
|
+
ChannelPosts.assertTelegramChannelPostCaptionWithinLimit(caption);
|
|
1697
|
+
const store = ChannelPosts.createTelegramChannelPostJournalStore({
|
|
1698
|
+
path: Paths.resolveTelegramChannelPostJournalPath(undefined, profileName),
|
|
1699
|
+
profileName,
|
|
1700
|
+
tokenSha256: Journal.createTelegramUpdateJournalBotIdentity({ botToken }).tokenSha256,
|
|
1701
|
+
});
|
|
1702
|
+
const record = await ChannelPosts.publishTelegramChannelPost({
|
|
1703
|
+
store, operationId: options.operationId,
|
|
1704
|
+
channel: channel as ChannelPosts.TelegramChannelPostAddress, markdown, media,
|
|
1705
|
+
async observeChannel(channelAddress) {
|
|
1706
|
+
return telegramApiRuntime.call("getChat", { chat_id: channelAddress });
|
|
1707
|
+
},
|
|
1708
|
+
async send(channelAddress) {
|
|
1709
|
+
const sent = await telegramApiRuntime.callMultipart<TelegramApi.TelegramSentMessage & {
|
|
1710
|
+
chat: { id: number; type: string };
|
|
1711
|
+
}>(media.kind === "photo" ? "sendPhoto" : "sendVideo", {
|
|
1712
|
+
chat_id: String(channelAddress),
|
|
1713
|
+
caption,
|
|
1714
|
+
parse_mode: "HTML",
|
|
1715
|
+
...(options.replyMarkup ? { reply_markup: JSON.stringify(options.replyMarkup) } : {}),
|
|
1716
|
+
}, media.kind === "photo" ? "photo" : "video", mediaPath, media.fileName);
|
|
1717
|
+
return { messageId: sent.message_id, chat: sent.chat };
|
|
1718
|
+
},
|
|
1719
|
+
});
|
|
1720
|
+
return record.state === "published" ? record.messageId : undefined;
|
|
1721
|
+
},
|
|
1678
1722
|
listChannelPosts(input) {
|
|
1679
1723
|
const profileName = configStore.getActiveProfileName() ?? "default";
|
|
1680
1724
|
const botToken = configStore.getBotToken();
|
|
@@ -1696,6 +1740,12 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1696
1740
|
});
|
|
1697
1741
|
if (input.action === "edit") {
|
|
1698
1742
|
if (!input.markdown) throw new Error("Telegram channel post edit requires markdown.");
|
|
1743
|
+
const current = store.get(input.operationId);
|
|
1744
|
+
const caption = current?.media
|
|
1745
|
+
? Replies.renderTelegramMarkdownToHtmlDraft(input.markdown) : undefined;
|
|
1746
|
+
if (caption !== undefined) {
|
|
1747
|
+
ChannelPosts.assertTelegramChannelPostCaptionWithinLimit(caption);
|
|
1748
|
+
}
|
|
1699
1749
|
const begun = store.beginEdit({ operationId: input.operationId,
|
|
1700
1750
|
mutationId: input.mutationId, markdown: input.markdown });
|
|
1701
1751
|
if (!begun.began) {
|
|
@@ -1704,9 +1754,17 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1704
1754
|
throw new Error("Telegram channel post edit outcome is unknown; refusing automatic replay.");
|
|
1705
1755
|
}
|
|
1706
1756
|
if (begun.record.state !== "edit-outcome-unknown") throw new Error("Telegram channel post edit authority is invalid.");
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1757
|
+
if (begun.record.media) {
|
|
1758
|
+
if (caption === undefined) {
|
|
1759
|
+
throw new Error("Telegram channel post media caption edit requires retained media identity.");
|
|
1760
|
+
}
|
|
1761
|
+
await telegramApiRuntime.call("editMessageCaption", { chat_id: begun.record.channelId,
|
|
1762
|
+
message_id: begun.record.messageId, caption, parse_mode: "HTML" });
|
|
1763
|
+
} else {
|
|
1764
|
+
await telegramApiRuntime.call("editMessageText", { chat_id: begun.record.channelId,
|
|
1765
|
+
message_id: begun.record.messageId,
|
|
1766
|
+
text: Replies.renderTelegramMarkdownToHtmlDraft(input.markdown), parse_mode: "HTML" });
|
|
1767
|
+
}
|
|
1710
1768
|
return store.confirmEdited({ operationId: input.operationId, mutationId: input.mutationId }).record;
|
|
1711
1769
|
}
|
|
1712
1770
|
if (input.markdown !== undefined) throw new Error("Telegram channel post deletion does not accept markdown.");
|
|
@@ -1769,6 +1827,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1769
1827
|
answerGuestQuery,
|
|
1770
1828
|
deleteMessage: deleteTelegramMessage,
|
|
1771
1829
|
sendGuestReply,
|
|
1830
|
+
editGuestReply,
|
|
1772
1831
|
finalizeMarkdownPreview,
|
|
1773
1832
|
preparePreviewDelivery,
|
|
1774
1833
|
proactivePushTargetGetter,
|
|
@@ -1144,6 +1144,7 @@ export interface TelegramLockedPollingRuntimeDeps<
|
|
|
1144
1144
|
> {
|
|
1145
1145
|
lock: TelegramLockRuntime<TContext>;
|
|
1146
1146
|
hasBotToken: () => boolean;
|
|
1147
|
+
getBotTokenDiagnostic?: () => string | undefined;
|
|
1147
1148
|
canStartPolling?: (ctx: TContext) => boolean;
|
|
1148
1149
|
isContextCurrent?: (ctx: TContext) => boolean;
|
|
1149
1150
|
formatStartBlockedMessage?: (ctx: TContext) => string;
|
|
@@ -1305,7 +1306,11 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1305
1306
|
return {
|
|
1306
1307
|
start: async (ctx, options = {}) => {
|
|
1307
1308
|
if (!deps.hasBotToken()) {
|
|
1308
|
-
return {
|
|
1309
|
+
return {
|
|
1310
|
+
ok: false,
|
|
1311
|
+
message:
|
|
1312
|
+
deps.getBotTokenDiagnostic?.() ?? "Telegram bot is not configured.",
|
|
1313
|
+
};
|
|
1309
1314
|
}
|
|
1310
1315
|
if (!canStartPolling(ctx)) {
|
|
1311
1316
|
return { ok: false, message: formatStartBlockedMessage(ctx) };
|