pi-braid-telegram 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Telegram inline-keyboard pause/resume control.
3
+ *
4
+ * The for a button to control the agent through Telegram
5
+ * ("Seems need a button for pause or whatever control this through tg").
6
+ * This module adds:
7
+ * - an inline keyboard (reply_markup) with ⏸ Pause / ▶ Resume / 📊 Status
8
+ * buttons (callback_data: "pause" | "resume" | "status");
9
+ * - callback_query handling: pause → paused=true, resume → paused=false,
10
+ * status → reply the current state; every tap is answered via
11
+ * answerCallbackQuery and the keyboard is updated in place via
12
+ * editMessageText (paused shows ▶ Resume, running shows ⏸ Pause);
13
+ * - a persisted pause state file (default
14
+ * `<braidDir>/telegram/pause-state.json`, atomic write-temp-rename) that
15
+ * survives restarts; the pre-move `~/.pi/agent/telegram-pause-state.json`
16
+ * stays readable until the first post-move write replaces it (reads prefer
17
+ * the braid-root file, writes only ever land there);
18
+ * - `getPauseState()` — the exported read seam for the CONSUMER (the
19
+ * orchestrator decides how to honor the flag; this module never blocks the
20
+ * poller or the core loop).
21
+ *
22
+ * Design: the pause flag is a signal for the consumer, not a gate here. The
23
+ * poller keeps polling and delivering; pause/resume/status callbacks are
24
+ * handled entirely within this module (answer + edit) and never a turn.
25
+ *
26
+ * Config follows the pi "settings" philosophy (packages/core braid-settings):
27
+ * the state-file path defaults to the fixed path above and can be overridden
28
+ * via `braid.telegram.pauseStateFile` in `~/.pi/agent/settings.json` — no
29
+ * process.env keys are introduced.
30
+ */
31
+
32
+ import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
33
+ import { dirname, join } from "node:path";
34
+ import { homedir } from "node:os";
35
+ import { existsSync, readFileSync } from "node:fs";
36
+ import {
37
+ defaultPauseStateFile,
38
+ legacyPauseStateFile,
39
+ warnUnderivableRoot,
40
+ } from "./state-paths.js";
41
+ import type { TelegramClient, TelegramReplyMarkup } from "./telegram-client.js";
42
+
43
+ export { defaultPauseStateFile, legacyPauseStateFile };
44
+
45
+ // Callback actions (button callback_data)
46
+
47
+ export const CALLBACK_PAUSE = "pause";
48
+ export const CALLBACK_RESUME = "resume";
49
+ export const CALLBACK_STATUS = "status";
50
+
51
+ /** Control-message body shown while the agent is running. */
52
+ export const CONTROL_RUNNING_TEXT =
53
+ "▶ Agent is running — inbound flows normally.";
54
+ /** Control-message body shown while the agent is paused. */
55
+ export const CONTROL_PAUSED_TEXT = "⏸ Agent is paused — inbound will be held.";
56
+
57
+ export interface PauseState {
58
+ paused: boolean;
59
+ /** Epoch ms of the last pause/resume transition (0 when never changed). */
60
+ updatedAt: number;
61
+ }
62
+
63
+ /** settings.json (pi-native) — braid.telegram block, mirrors braid-settings.ts. */
64
+ const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
65
+
66
+ interface BraidSettings {
67
+ braid?: {
68
+ telegram?: {
69
+ /** Override for the pause-state file path (default: defaultPauseStateFile()). */
70
+ pauseStateFile?: string;
71
+ };
72
+ };
73
+ }
74
+
75
+ /** The `braid.telegram.pauseStateFile` override, or undefined. Never throws. */
76
+ function configuredPauseStateFile(): string | undefined {
77
+ try {
78
+ if (existsSync(SETTINGS_PATH)) {
79
+ const parsed = JSON.parse(
80
+ readFileSync(SETTINGS_PATH, "utf8"),
81
+ ) as BraidSettings;
82
+ const override = parsed?.braid?.telegram?.pauseStateFile;
83
+ if (typeof override === "string" && override.trim())
84
+ return override.trim();
85
+ }
86
+ } catch {
87
+ // non-fatal — fall through to the default
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ /**
93
+ * Write resolution: `braid.telegram.pauseStateFile` when configured, else the
94
+ * braid-root default. Every write goes here — the pre-move agent-dir file is
95
+ * read-only history. Null when no absolute root resolves: the caller refuses
96
+ * to persist instead of writing beside the cwd.
97
+ */
98
+ export function resolvePauseStateFile(): string | null {
99
+ return configuredPauseStateFile() ?? defaultPauseStateFile();
100
+ }
101
+
102
+ /**
103
+ * Read resolution: the configured override, else the braid-root file when it
104
+ * exists, else the pre-move agent-dir file when that exists (so a pause set
105
+ * before the relocation keeps holding), else the braid-root path (a miss the
106
+ * caller treats as "not paused"). Null when no absolute root resolves.
107
+ */
108
+ export function resolvePauseStateReadFile(): string | null {
109
+ const configured = configuredPauseStateFile();
110
+ if (configured) return configured;
111
+ const current = defaultPauseStateFile();
112
+ if (current !== null && existsSync(current)) return current;
113
+ const legacy = legacyPauseStateFile();
114
+ if (legacy !== null && existsSync(legacy)) return legacy;
115
+ return current;
116
+ }
117
+
118
+ /** Read the pause state; a missing/corrupt file means "not paused" (never throws). */
119
+ export async function loadPauseState(
120
+ stateFile: string | null,
121
+ ): Promise<PauseState> {
122
+ if (stateFile === null) return { paused: false, updatedAt: 0 };
123
+ try {
124
+ const raw = await readFile(stateFile, "utf8");
125
+ const parsed = JSON.parse(raw) as Partial<PauseState>;
126
+ return {
127
+ paused: parsed?.paused === true,
128
+ updatedAt: Number(parsed?.updatedAt) || 0,
129
+ };
130
+ } catch {
131
+ return { paused: false, updatedAt: 0 };
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Persist the pause state ATOMICALLY (write-temp-rename): the JSON is written
137
+ * to `<stateFile>.tmp` first, then renamed over the target, so a crash
138
+ * mid-write can never leave a truncated state file behind.
139
+ */
140
+ export async function savePauseState(
141
+ stateFile: string,
142
+ state: PauseState,
143
+ ): Promise<void> {
144
+ const dir = dirname(stateFile);
145
+ try {
146
+ await mkdir(dir, { recursive: true });
147
+ } catch {
148
+ // parent may already exist; write will surface real errors
149
+ }
150
+ const tmp = `${stateFile}.tmp`;
151
+ await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
152
+ await rename(tmp, stateFile);
153
+ }
154
+
155
+ /**
156
+ * Exported read seam for the CONSUMER (orchestrator): check the persisted
157
+ * pause flag before processing inbound. `stateFile` defaults to the resolved
158
+ * settings/default path. Never throws.
159
+ */
160
+ export async function getPauseState(
161
+ stateFile: string | null = resolvePauseStateReadFile(),
162
+ ): Promise<PauseState> {
163
+ return loadPauseState(stateFile);
164
+ }
165
+
166
+ /**
167
+ * Build the control keyboard for the given state: running shows ⏸ Pause +
168
+ * 📊 Status; paused shows ▶ Resume + 📊 Status (the action that changes the
169
+ * current state is always the primary button).
170
+ */
171
+ export function buildControlKeyboard(paused: boolean): TelegramReplyMarkup {
172
+ return {
173
+ inline_keyboard: [
174
+ [
175
+ paused
176
+ ? { text: "▶ Resume", callback_data: CALLBACK_RESUME }
177
+ : { text: "⏸ Pause", callback_data: CALLBACK_PAUSE },
178
+ { text: "📊 Status", callback_data: CALLBACK_STATUS },
179
+ ],
180
+ ],
181
+ };
182
+ }
183
+
184
+ export interface SendControlKeyboardResult {
185
+ ok: boolean;
186
+ messageId?: number;
187
+ error?: string;
188
+ }
189
+
190
+ /**
191
+ * Send the control message (status line + inline keyboard) to a chat. The
192
+ * orchestrator/agent can call this to place the control in a chat; the
193
+ * keyboard is then kept in sync by handlePauseCallbackQuery's edit.
194
+ */
195
+ export async function sendControlKeyboard(
196
+ client: Pick<TelegramClient, "sendMessage">,
197
+ token: string,
198
+ chatId: number | string,
199
+ paused: boolean,
200
+ ): Promise<SendControlKeyboardResult> {
201
+ const text = paused ? CONTROL_PAUSED_TEXT : CONTROL_RUNNING_TEXT;
202
+ const res = await client.sendMessage(token, chatId, text, {
203
+ replyMarkup: buildControlKeyboard(paused),
204
+ });
205
+ return res.ok
206
+ ? { ok: true, messageId: res.messageId }
207
+ : { ok: false, error: res.error };
208
+ }
209
+
210
+ /** Comparable pause-state reader for the placement helper (see PauseControlDeps.getPauseState). */
211
+ export type PauseStateReaderLike = () => Promise<PauseState>;
212
+
213
+ export interface PlaceControlKeyboardDeps {
214
+ client: Pick<TelegramClient, "sendMessage">;
215
+ token: string;
216
+ /**
217
+ * Pause-state reader — defaults to the (unpersisted-default) getPauseState().
218
+ * Injectable so the consumer can reuse its gate's reader (index.ts passes the
219
+ * same readPauseState the poller gate uses: placement and gate always agree).
220
+ */
221
+ getPauseState?: PauseStateReaderLike;
222
+ log?: (
223
+ level: "debug" | "info" | "warn" | "error",
224
+ message: string,
225
+ ...rest: unknown[]
226
+ ) => void;
227
+ }
228
+
229
+ export interface PlaceControlKeyboardResult {
230
+ ok: boolean;
231
+ /**
232
+ * True when no usable chat id was given (owner chat not known yet — pure
233
+ * first-message bootstrap). NOTHING is sent; the caller retries once the
234
+ * first owner message captures the real chat id. Never true alongside ok:true.
235
+ */
236
+ deferred: boolean;
237
+ messageId?: number;
238
+ error?: string;
239
+ }
240
+
241
+ /**
242
+ * Consumer-side placement helper: ONE call sends the
243
+ * control keyboard to `chatId`, reflecting the CURRENT persisted pause flag —
244
+ * running shows ⏸ Pause, paused shows ▶ Resume. This stays delivery-neutral:
245
+ * NOTHING self-sends on import — the CONSUMER (index.ts) calls this at the
246
+ * poller-start seam. Best-effort: failures are returned in the result, never
247
+ * thrown. When `chatId` is empty/0 the call returns `deferred: true` without
248
+ * sending, so a poller that started before the owner chat was discovered
249
+ * (bootstrap) can place once the first owner message provides a chat id.
250
+ */
251
+ export async function placeControlKeyboard(
252
+ chatId: number | string | undefined,
253
+ deps: PlaceControlKeyboardDeps,
254
+ ): Promise<PlaceControlKeyboardResult> {
255
+ const log = deps.log ?? (() => {});
256
+ if (chatId == null || chatId === "" || chatId === 0) {
257
+ log(
258
+ "debug",
259
+ "telegram: control keyboard deferred — owner chat not known yet",
260
+ );
261
+ return { ok: false, deferred: true };
262
+ }
263
+ const state = deps.getPauseState
264
+ ? await deps.getPauseState()
265
+ : await getPauseState();
266
+ const res = await sendControlKeyboard(
267
+ deps.client,
268
+ deps.token,
269
+ chatId,
270
+ state.paused,
271
+ );
272
+ if (res.ok) {
273
+ log(
274
+ "info",
275
+ `telegram: control keyboard placed in chat ${chatId} (${state.paused ? "paused — ▶ Resume" : "running — ⏸ Pause"}${res.messageId != null ? `, msg ${res.messageId}` : ""})`,
276
+ );
277
+ } else {
278
+ log(
279
+ "warn",
280
+ `telegram: control keyboard placement failed for chat ${chatId}: ${res.error ?? "unknown"}`,
281
+ );
282
+ }
283
+ return { ...res, deferred: false };
284
+ }
285
+
286
+ /** The callback_query shape this handler consumes (subset of Telegram's). */
287
+ export interface PauseCallbackQuery {
288
+ id: string;
289
+ from?: { id: number; username?: string; first_name?: string };
290
+ /** The message the button is attached to (absent in inline mode). */
291
+ message?: { message_id: number; chat: { id: number; type?: string } };
292
+ data?: string;
293
+ }
294
+
295
+ export interface PauseControlDeps {
296
+ client: Pick<TelegramClient, "answerCallbackQuery" | "editMessageText">;
297
+ token: string;
298
+ /** Path to the pause-state file (persistence). Null when no absolute braid
299
+ * root resolves: reads degrade to "not paused", writes are refused. */
300
+ stateFile: string | null;
301
+ log?: (
302
+ level: "debug" | "info" | "warn" | "error",
303
+ message: string,
304
+ ...rest: unknown[]
305
+ ) => void;
306
+ }
307
+
308
+ export interface PauseCallbackOutcome {
309
+ ok: boolean;
310
+ action: "pause" | "resume" | "status" | "unknown";
311
+ /** State AFTER the tap (persisted truth). */
312
+ paused: boolean;
313
+ /** answerCallbackQuery succeeded. */
314
+ answered: boolean;
315
+ /** editMessageText (keyboard refresh) succeeded. */
316
+ edited: boolean;
317
+ error?: string;
318
+ }
319
+
320
+ async function answerCallback(
321
+ deps: PauseControlDeps,
322
+ callbackQueryId: string,
323
+ text: string,
324
+ ): Promise<boolean> {
325
+ const res = await deps.client.answerCallbackQuery(
326
+ deps.token,
327
+ callbackQueryId,
328
+ { text },
329
+ );
330
+ return res.ok;
331
+ }
332
+
333
+ /**
334
+ * Handle one inline-keyboard tap: pause → persist paused=true, resume →
335
+ * persist paused=false, status → no change. Every tap is answered via
336
+ * answerCallbackQuery (toast with the current state) and — when the tap
337
+ * carries a message — the keyboard is updated in place via editMessageText so
338
+ * paused shows ▶ Resume and running shows ⏸ Pause. Idempotent: re-tapping the
339
+ * same action is a no-op on state (still answered + keyboard refreshed).
340
+ * Never throws; API failures are reported in the outcome.
341
+ */
342
+ export async function handlePauseCallbackQuery(
343
+ deps: PauseControlDeps,
344
+ callbackQuery: PauseCallbackQuery,
345
+ ): Promise<PauseCallbackOutcome> {
346
+ const log = deps.log ?? (() => {});
347
+ const data =
348
+ typeof callbackQuery.data === "string" ? callbackQuery.data.trim() : "";
349
+ const stateFile = deps.stateFile;
350
+ // Reads prefer the braid-root file but fall back to the pre-move agent-dir
351
+ // copy while the braid-root file does not exist yet; the writes below always
352
+ // target `stateFile` (the braid-root default or the configured override).
353
+ const readFile =
354
+ stateFile === resolvePauseStateFile()
355
+ ? resolvePauseStateReadFile()
356
+ : stateFile;
357
+ const state = await loadPauseState(readFile);
358
+
359
+ // Unknown action: answer with a hint, no state change, no keyboard edit.
360
+ if (
361
+ data !== CALLBACK_PAUSE &&
362
+ data !== CALLBACK_RESUME &&
363
+ data !== CALLBACK_STATUS
364
+ ) {
365
+ const answered = await answerCallback(
366
+ deps,
367
+ callbackQuery.id,
368
+ `Unknown action: ${data || "(empty)"}`,
369
+ );
370
+ return {
371
+ ok: true,
372
+ action: "unknown",
373
+ paused: state.paused,
374
+ answered,
375
+ edited: false,
376
+ };
377
+ }
378
+
379
+ if (data === CALLBACK_PAUSE && !state.paused) {
380
+ if (stateFile === null) {
381
+ warnUnderivableRoot("pause-state write refused");
382
+ } else {
383
+ await savePauseState(stateFile, { paused: true, updatedAt: Date.now() });
384
+ log(
385
+ "info",
386
+ `telegram pause: agent paused via callback (${callbackQuery.id})`,
387
+ );
388
+ }
389
+ } else if (data === CALLBACK_RESUME && state.paused) {
390
+ if (stateFile === null) {
391
+ warnUnderivableRoot("pause-state write refused");
392
+ } else {
393
+ await savePauseState(stateFile, { paused: false, updatedAt: Date.now() });
394
+ log(
395
+ "info",
396
+ `telegram pause: agent resumed via callback (${callbackQuery.id})`,
397
+ );
398
+ }
399
+ }
400
+ // Re-read so `paused` reflects the persisted truth (idempotent re-taps).
401
+ const current = await loadPauseState(readFile);
402
+
403
+ const statusText = current.paused ? "⏸ Agent paused" : "▶ Agent running";
404
+ const answered = await answerCallback(deps, callbackQuery.id, statusText);
405
+
406
+ let edited = false;
407
+ const msg = callbackQuery.message;
408
+ if (msg?.message_id && msg?.chat?.id != null) {
409
+ const res = await deps.client.editMessageText(
410
+ deps.token,
411
+ msg.chat.id,
412
+ msg.message_id,
413
+ current.paused ? CONTROL_PAUSED_TEXT : CONTROL_RUNNING_TEXT,
414
+ { replyMarkup: buildControlKeyboard(current.paused) },
415
+ );
416
+ edited = res.ok;
417
+ if (!res.ok)
418
+ log(
419
+ "warn",
420
+ `telegram pause: keyboard edit failed (${res.error ?? "unknown"})`,
421
+ );
422
+ }
423
+
424
+ return { ok: true, action: data, paused: current.paused, answered, edited };
425
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * photo.ts — shared inbound photo extraction (owner photo attachments).
3
+ *
4
+ * Both inbound consumers (background poller in inbound-poller.ts and the
5
+ * agentic pull in telegram-inbox.ts) route accepted photo messages through
6
+ * these helpers: pick the largest photo variant, resolve it to a download
7
+ * path via getFile, fetch the bytes, save them under the media dir, and
8
+ * produce the agent-facing delivery text carrying the absolute path (or, on
9
+ * failure, the file_id + an error note — never drop the message).
10
+ *
11
+ * Pure + injectable-fetch driven, mirroring the client's test conventions:
12
+ * `fetchFn` defaults to globalThis.fetch, so tests substitute a recorded
13
+ * fixture fake with NO network.
14
+ */
15
+
16
+ import { mkdir, writeFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { defaultMediaDir, warnUnderivableRoot } from "./state-paths.js";
19
+ import {
20
+ getFile,
21
+ downloadFile,
22
+ type TelegramFetch,
23
+ } from "./telegram-client.js";
24
+
25
+ /** One element of a Telegram `photo` array (PhotoSize). */
26
+ export interface TelegramPhotoSize {
27
+ file_id: string;
28
+ file_unique_id?: string;
29
+ width?: number;
30
+ height?: number;
31
+ file_size?: number;
32
+ }
33
+
34
+ /** True when a message carries a non-empty `photo` array (photo inbound). */
35
+ export function hasPhoto(message: unknown): boolean {
36
+ if (message == null || typeof message !== "object") return false;
37
+ const photo = (message as { photo?: unknown }).photo;
38
+ return Array.isArray(photo) && photo.length > 0;
39
+ }
40
+
41
+ /**
42
+ * Pick the largest photo variant: the one with the greatest `file_size`, or
43
+ * (when no sizes are present) the LAST array element — Telegram orders the
44
+ * `photo` array by size ascending, so the last is the largest. Returns
45
+ * undefined when no usable variant (with a file_id) exists.
46
+ */
47
+ export function pickPhotoVariant(
48
+ photo: unknown,
49
+ ): TelegramPhotoSize | undefined {
50
+ if (!Array.isArray(photo) || photo.length === 0) return undefined;
51
+ const variants = photo.filter(
52
+ (v): v is TelegramPhotoSize =>
53
+ v != null &&
54
+ typeof v === "object" &&
55
+ typeof (v as { file_id?: unknown }).file_id === "string",
56
+ );
57
+ if (variants.length === 0) return undefined;
58
+ let largest = variants[variants.length - 1];
59
+ for (const v of variants) {
60
+ if (
61
+ typeof v.file_size === "number" &&
62
+ v.file_size > (largest.file_size ?? 0)
63
+ ) {
64
+ largest = v;
65
+ }
66
+ }
67
+ return largest;
68
+ }
69
+
70
+ /** Derive a safe short file extension from the Telegram file_path (fallback jpg). */
71
+ function extFromFilePath(filePath?: string): string {
72
+ if (!filePath) return "jpg";
73
+ const last = filePath.split("/").pop() ?? "";
74
+ const dot = last.lastIndexOf(".");
75
+ if (dot >= 0 && dot < last.length - 1) {
76
+ const ext = last.slice(dot + 1).toLowerCase();
77
+ if (/^[a-z0-9]{1,8}$/.test(ext)) return ext;
78
+ }
79
+ return "jpg"; // Telegram photos are JPEG
80
+ }
81
+
82
+ /** Outcome of materializePhoto: saved path, or a degraded file_id + error. */
83
+ export type MaterializePhotoResult =
84
+ | { ok: true; fileId: string; filePath: string; filePathOnTelegram: string }
85
+ | { ok: false; fileId: string; error: string };
86
+
87
+ /**
88
+ * Download one photo variant and save it to `<mediaDir>/<updateId>.<ext>`.
89
+ * Never throws: a getFile / download / write failure returns
90
+ * `{ ok: false, fileId, error }` so the caller can degrade instead of drop.
91
+ */
92
+ export async function materializePhoto(opts: {
93
+ token: string;
94
+ updateId: number;
95
+ photo: TelegramPhotoSize;
96
+ fetchFn?: TelegramFetch;
97
+ baseUrl?: string;
98
+ mediaDir?: string;
99
+ }): Promise<MaterializePhotoResult> {
100
+ const { token, updateId, photo, fetchFn, baseUrl } = opts;
101
+ const fileId = photo.file_id;
102
+ const mediaDir = opts.mediaDir ?? defaultMediaDir();
103
+ if (mediaDir === null) {
104
+ warnUnderivableRoot("photo download refused");
105
+ return {
106
+ ok: false,
107
+ fileId,
108
+ error:
109
+ "braid media directory is underivable (BRAID_HOME unset and no absolute home); photo not saved",
110
+ };
111
+ }
112
+
113
+ const fileInfo = await getFile(token, fileId, { fetch: fetchFn, baseUrl });
114
+ const filePathOnTelegram = fileInfo.ok ? fileInfo.file?.file_path : undefined;
115
+ if (!fileInfo.ok || !filePathOnTelegram) {
116
+ return {
117
+ ok: false,
118
+ fileId,
119
+ error: fileInfo.error ?? "getFile returned no file_path",
120
+ };
121
+ }
122
+
123
+ const fileDownload = await downloadFile(token, filePathOnTelegram, {
124
+ fetch: fetchFn,
125
+ baseUrl,
126
+ });
127
+ if (!fileDownload.ok || !fileDownload.bytes) {
128
+ return {
129
+ ok: false,
130
+ fileId,
131
+ error: fileDownload.error ?? "file download failed",
132
+ };
133
+ }
134
+
135
+ const dest = join(
136
+ mediaDir,
137
+ `${updateId}.${extFromFilePath(filePathOnTelegram)}`,
138
+ );
139
+ try {
140
+ await mkdir(mediaDir, { recursive: true });
141
+ await writeFile(dest, fileDownload.bytes);
142
+ } catch (error) {
143
+ return {
144
+ ok: false,
145
+ fileId,
146
+ error: `failed to save ${dest}: ${error instanceof Error ? error.message : String(error)}`,
147
+ };
148
+ }
149
+ return { ok: true, fileId, filePath: dest, filePathOnTelegram };
150
+ }
151
+
152
+ /** Options for formatPhotoText — the agent-facing delivery string. */
153
+ export interface PhotoDeliveryTextOptions {
154
+ /** Sender label, e.g. "@owner" or "id 123" (matches the whisper header). */
155
+ senderLabel: string;
156
+ /** Absolute path of the saved file (when the download succeeded). */
157
+ filePath?: string;
158
+ /** Telegram file_id (always present for a photo message). */
159
+ fileId?: string;
160
+ /** Message caption (trimmed), if any. */
161
+ caption?: string;
162
+ /** Download/save failure note (degraded delivery). */
163
+ error?: string;
164
+ }
165
+
166
+ /**
167
+ * Build the delivered text the receiving agent can act on. Downloaded form:
168
+ * 📷 Photo from @owner: /abs/path.jpg (caption: <caption or none>)
169
+ * Degraded (download failed) form — never dropped:
170
+ * 📷 Photo from @owner: file_id <id> (caption: <caption or none>) (download failed: <error>)
171
+ */
172
+ export function formatPhotoText(opts: PhotoDeliveryTextOptions): string {
173
+ const captionPart = opts.caption
174
+ ? ` (caption: ${opts.caption})`
175
+ : " (caption: none)";
176
+ if (opts.filePath) {
177
+ return `📷 Photo from ${opts.senderLabel}: ${opts.filePath}${captionPart}`;
178
+ }
179
+ const filePart = opts.fileId ? `file_id ${opts.fileId}` : "file_id unknown";
180
+ const errorPart = opts.error ? ` (download failed: ${opts.error})` : "";
181
+ return `📷 Photo from ${opts.senderLabel}: ${filePart}${errorPart}${captionPart}`;
182
+ }