@llblab/pi-kit 0.7.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  4. package/node_modules/@llblab/pi-telegram/BACKLOG.md +3 -2
  5. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +6 -1
  6. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  7. package/node_modules/@llblab/pi-telegram/docs/architecture.md +8 -6
  8. package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -4
  9. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +18 -0
  10. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
  11. package/node_modules/@llblab/pi-telegram/lib/bus.ts +5 -0
  12. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +189 -15
  13. package/node_modules/@llblab/pi-telegram/lib/commands.ts +3 -0
  14. package/node_modules/@llblab/pi-telegram/lib/config.ts +67 -4
  15. package/node_modules/@llblab/pi-telegram/lib/extension.ts +65 -6
  16. package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
  17. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +49 -3
  18. package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
  19. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +1 -0
  20. package/node_modules/@llblab/pi-telegram/lib/queue.ts +66 -8
  21. package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
  22. package/node_modules/@llblab/pi-telegram/lib/replies.ts +19 -0
  23. package/node_modules/@llblab/pi-telegram/lib/routing.ts +39 -0
  24. package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
  25. package/node_modules/@llblab/pi-telegram/lib/status.ts +41 -4
  26. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +74 -18
  27. package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
  28. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  29. package/package.json +2 -2
  30. /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 { dirname } from "node:path";
10
- import { randomUUID } from "node:crypto";
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(/&lt;/gu, "<")
128
+ .replace(/&gt;/gu, ">")
129
+ .replace(/&quot;/gu, "\"")
130
+ .replace(/&#39;/gu, "'")
131
+ .replace(/&amp;/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, markdown: input.markdown });
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: JSON.stringify(record, null, 2) }],
291
+ return { content: [{ type: "text" as const, text: formatTelegramChannelPostToolOutput(record) }],
157
292
  details: { record } };
158
- } catch {
159
- throw new Error("Telegram channel post mutation failed; inspect the retained local record before retrying.");
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: JSON.stringify(records, null, 2) }],
323
+ return { content: [{ type: "text" as const, text: formatTelegramChannelPostToolOutput(records) }],
186
324
  details: { records } };
187
325
  } catch {
188
- throw new Error("Telegram channel post listing failed without exposing retained content or storage details.");
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
- if (typeof profile?.botToken !== "string" || !profile.botToken ||
687
- createHash("sha256").update(profile.botToken).digest("hex") !== tokenSha256 ||
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
- hasBotToken: () => !!getEffectiveConfig().botToken,
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 Logs from "./logs.ts";
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
- Logs.createTelegramRuntimeDiagnosticsRuntime<Pi.ExtensionContext>();
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.get,
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
- await telegramApiRuntime.call("editMessageText", { chat_id: begun.record.channelId,
1708
- message_id: begun.record.messageId,
1709
- text: Replies.renderTelegramMarkdownToHtmlDraft(input.markdown), parse_mode: "HTML" });
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 { ok: false, message: "Telegram bot is not configured." };
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) };