@llblab/pi-telegram 0.22.1 → 0.23.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/lib/bus.ts CHANGED
@@ -158,28 +158,22 @@ export function getTelegramFollowerTargetOwnership(input: {
158
158
  target: TelegramTarget;
159
159
  }[];
160
160
  currentInstanceId?: string;
161
- }): { instanceId: string } | undefined {
161
+ }): { instanceId: string; ownerGeneration?: string } | undefined {
162
162
  const liveFollower = input.followers.find((follower) => {
163
163
  return (
164
164
  follower.target?.chatId === input.target.chatId &&
165
165
  follower.target.threadId === input.target.threadId
166
166
  );
167
167
  });
168
- if (liveFollower) return { instanceId: liveFollower.instanceId };
169
- const record = input.activeThreadRecords?.find((candidate) => {
170
- const isFollowerRecord = candidate.owner?.kind
171
- ? candidate.owner.kind === "manual-follower"
172
- : candidate.profileKey?.startsWith("manual:") === true;
173
- return (
174
- isFollowerRecord &&
175
- candidate.status === "active" &&
176
- candidate.instanceId &&
177
- candidate.instanceId !== input.currentInstanceId &&
178
- candidate.target.chatId === input.target.chatId &&
179
- candidate.target.threadId === input.target.threadId
180
- );
181
- });
182
- return record?.instanceId ? { instanceId: record.instanceId } : undefined;
168
+ if (liveFollower) {
169
+ return {
170
+ instanceId: liveFollower.instanceId,
171
+ ownerGeneration: liveFollower.registrationGeneration,
172
+ };
173
+ }
174
+ // Persisted records are restart hints, not live routing authority. Only an
175
+ // authenticated current follower registration may receive forwarded work.
176
+ return undefined;
183
177
  }
184
178
 
185
179
  const TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD = "__piTelegramAggregateDelivery";
@@ -237,6 +231,7 @@ export function isTelegramFollowerApiCallAllowed(input: {
237
231
  "sendDocument",
238
232
  "sendMediaGroup",
239
233
  "sendPhoto",
234
+ "sendRichMessage",
240
235
  "sendVoice",
241
236
  ]);
242
237
  const target = input.follower.target;
@@ -363,6 +358,13 @@ export type TelegramBusEnvelope = (
363
358
  registrationGeneration?: string;
364
359
  sentAtMs: number;
365
360
  }
361
+ | {
362
+ kind: "follower.disconnect";
363
+ requestId: string;
364
+ instanceId: string;
365
+ registrationGeneration?: string;
366
+ sentAtMs: number;
367
+ }
366
368
  | {
367
369
  kind: "leader.forwardCallback";
368
370
  requestId: string;
@@ -385,6 +387,7 @@ export type TelegramBusEnvelope = (
385
387
  recipientInstanceId: string;
386
388
  recipientRegistrationGeneration?: string;
387
389
  message: unknown;
390
+ forwardCommentBatchPosition?: "comment" | "forward";
388
391
  sentAtMs: number;
389
392
  }
390
393
  | {
@@ -473,6 +476,9 @@ export function parseTelegramBusEnvelope(
473
476
  case "follower.heartbeat":
474
477
  envelope = parseHeartbeatEnvelope(value, requestId);
475
478
  break;
479
+ case "follower.disconnect":
480
+ envelope = parseDisconnectEnvelope(value, requestId);
481
+ break;
476
482
  case "leader.forwardCallback":
477
483
  envelope = parseForwardCallbackEnvelope(value, requestId);
478
484
  break;
@@ -548,12 +554,20 @@ export interface TelegramBusLocalClientOptions {
548
554
  recordTransportEvent?: TelegramBusTransportEventRecorder;
549
555
  }
550
556
 
551
- export interface TelegramBusForeignOwnedForwarderDeps {
557
+ export interface TelegramBusForeignOwnedForwarderDeps<TMessage = unknown> {
552
558
  socketPath: TelegramBusSocketPathSource;
553
559
  createRequestId: () => string;
554
560
  getNowMs?: () => number;
555
561
  timeoutMs?: number;
556
562
  getAuthSecret?: () => string | undefined;
563
+ getForwardCommentBatchPosition?: (
564
+ message: TMessage,
565
+ ) => "comment" | "forward" | undefined;
566
+ recordRuntimeEvent?: (
567
+ category: string,
568
+ error: unknown,
569
+ details?: Record<string, unknown>,
570
+ ) => void;
557
571
  }
558
572
 
559
573
  export function createTelegramBusForeignOwnedUpdateForwarder<
@@ -562,7 +576,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
562
576
  TCallbackQuery,
563
577
  TMessage = unknown,
564
578
  >(
565
- deps: TelegramBusForeignOwnedForwarderDeps,
579
+ deps: TelegramBusForeignOwnedForwarderDeps<TMessage>,
566
580
  ): {
567
581
  forwardCallback: (input: {
568
582
  query: TCallbackQuery;
@@ -598,7 +612,24 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
598
612
  operation: "operation",
599
613
  }),
600
614
  });
601
- return response?.kind === "bus.ack" && response.ok;
615
+ const accepted = response?.kind === "bus.ack" && response.ok;
616
+ if (!accepted) {
617
+ deps.recordRuntimeEvent?.(
618
+ "bus",
619
+ response?.kind === "bus.ack"
620
+ ? response.message ?? "Follower rejected forwarded Telegram update."
621
+ : "Follower returned no forwarding acknowledgement.",
622
+ {
623
+ phase: "foreign-update-forward-rejected",
624
+ envelopeKind: envelope.kind,
625
+ recipientInstanceId:
626
+ "recipientInstanceId" in envelope
627
+ ? envelope.recipientInstanceId
628
+ : undefined,
629
+ },
630
+ );
631
+ }
632
+ return accepted;
602
633
  };
603
634
  return {
604
635
  forwardCallback: ({ query, ownership }) =>
@@ -632,6 +663,12 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
632
663
  ? { recipientRegistrationGeneration: ownership.ownerGeneration }
633
664
  : {}),
634
665
  message,
666
+ ...(deps.getForwardCommentBatchPosition?.(message) !== undefined
667
+ ? {
668
+ forwardCommentBatchPosition:
669
+ deps.getForwardCommentBatchPosition(message),
670
+ }
671
+ : {}),
635
672
  sentAtMs: getNowMs(),
636
673
  }),
637
674
  forwardEditedMessage: ({ message, ownership }) =>
@@ -1287,6 +1324,24 @@ function parseHeartbeatEnvelope(
1287
1324
  : undefined;
1288
1325
  }
1289
1326
 
1327
+ function parseDisconnectEnvelope(
1328
+ value: Record<string, unknown>,
1329
+ requestId: string,
1330
+ ): TelegramBusEnvelope | undefined {
1331
+ return typeof value.instanceId === "string" &&
1332
+ typeof value.sentAtMs === "number"
1333
+ ? {
1334
+ kind: "follower.disconnect",
1335
+ requestId,
1336
+ instanceId: value.instanceId,
1337
+ ...(typeof value.registrationGeneration === "string"
1338
+ ? { registrationGeneration: value.registrationGeneration }
1339
+ : {}),
1340
+ sentAtMs: value.sentAtMs,
1341
+ }
1342
+ : undefined;
1343
+ }
1344
+
1290
1345
  function parseForwardCallbackEnvelope(
1291
1346
  value: Record<string, unknown>,
1292
1347
  requestId: string,
@@ -1349,6 +1404,14 @@ function parseForwardMessageEnvelope(
1349
1404
  }
1350
1405
  : {}),
1351
1406
  message: value.message,
1407
+ ...(kind === "leader.forwardMessage" &&
1408
+ (value.forwardCommentBatchPosition === "comment" ||
1409
+ value.forwardCommentBatchPosition === "forward")
1410
+ ? {
1411
+ forwardCommentBatchPosition:
1412
+ value.forwardCommentBatchPosition,
1413
+ }
1414
+ : {}),
1352
1415
  sentAtMs: value.sentAtMs,
1353
1416
  }
1354
1417
  : undefined;
package/lib/commands.ts CHANGED
@@ -307,6 +307,7 @@ export interface TelegramBridgeCommandRegistrationDeps {
307
307
  | Promise<void | TelegramBridgeCommandStartPollingResult>
308
308
  | TelegramBridgeCommandStartPollingResult;
309
309
  stopPolling: () => Promise<void | string>;
310
+ getDisconnectThreadName?: () => string | undefined;
310
311
  updateStatus: (ctx: ExtensionCommandContext) => void;
311
312
  getProfileNames?: () => string[];
312
313
  activateDefaultProfileConfig?: (ctx: ExtensionCommandContext) => Promise<void>;
@@ -411,11 +412,34 @@ export function registerTelegramBridgeCommands(
411
412
  },
412
413
  });
413
414
  pi.registerCommand("telegram-disconnect", {
414
- description: "Stop the Telegram bridge in this Pi session",
415
+ description:
416
+ "Stop Telegram; in Threaded Mode, delete this instance's current thread",
415
417
  handler: async (_args, ctx) => {
416
- const message = await deps.stopPolling();
417
- if (message) ctx.ui.notify(message, "info");
418
- deps.updateStatus(ctx);
418
+ const threadName = deps.getDisconnectThreadName?.();
419
+ if (threadName) {
420
+ const confirmed = await ctx.ui.confirm(
421
+ ctx.ui.theme.fg("accent", "pi-telegram"),
422
+ `Delete Telegram thread ${ctx.ui.theme.fg("warning", threadName)} and disconnect this Pi session?`,
423
+ );
424
+ if (!confirmed) {
425
+ ctx.ui.notify("Telegram disconnect cancelled.", "info");
426
+ deps.updateStatus(ctx);
427
+ return;
428
+ }
429
+ }
430
+ try {
431
+ const message = await deps.stopPolling();
432
+ if (message) ctx.ui.notify(message, "info");
433
+ } catch (error) {
434
+ const detail = error instanceof Error ? error.message : String(error);
435
+ ctx.ui.notify(
436
+ `Telegram disconnect did not complete: ${detail} Keep this Pi session open, restore leader connectivity, inspect /telegram-status --debug, and retry /telegram-disconnect.`,
437
+ "warning",
438
+ );
439
+ throw error;
440
+ } finally {
441
+ deps.updateStatus(ctx);
442
+ }
419
443
  },
420
444
  });
421
445
  }
package/lib/config.ts CHANGED
@@ -60,10 +60,10 @@ export interface TelegramConfig {
60
60
  inboundHandlers?: TelegramInboundHandlerConfig[];
61
61
  attachmentHandlers?: TelegramInboundHandlerConfig[];
62
62
  outboundHandlers?: TelegramOutboundHandlerConfig[];
63
- proactivePush?: boolean;
64
63
  assistant?: {
65
64
  draftPreviews?: boolean;
66
65
  rendering?: TelegramAssistantRenderingMode;
66
+ proactivePush?: boolean;
67
67
  };
68
68
  /** @deprecated use assistant.draftPreviews */
69
69
  draftPreviews?: boolean;
@@ -85,7 +85,7 @@ export interface TelegramConfig {
85
85
  * Per-profile bot/session identity fields.
86
86
  * Stored under `profiles.<name>` in telegram.json.
87
87
  * Shared bridge settings (inboundHandlers, outboundHandlers, voice, time,
88
- * assistant, proactivePush) stay at the top level.
88
+ * assistant) stay at the top level.
89
89
  */
90
90
  export interface TelegramBotProfile {
91
91
  botToken: string;
@@ -259,31 +259,39 @@ export async function readTelegramConfig(
259
259
  onInvalidConfig?: (recovery: TelegramInvalidConfigRecovery) => void;
260
260
  } = {},
261
261
  ): Promise<TelegramConfig> {
262
- return withTelegramFileTransaction(`${configPath}.transaction`, () => {
263
- if (!existsSync(configPath)) return {};
264
- const identity = statSync(configPath);
265
- const content = readFileSync(configPath, "utf8");
266
- try {
267
- return JSON.parse(content) as TelegramConfig;
268
- } catch (error) {
269
- const currentIdentity = statSync(configPath);
270
- if (
271
- currentIdentity.dev !== identity.dev ||
272
- currentIdentity.ino !== identity.ino ||
273
- currentIdentity.size !== identity.size ||
274
- currentIdentity.mtimeMs !== identity.mtimeMs
275
- ) {
276
- throw new Error(
277
- `Telegram config changed while validating invalid content: ${configPath}`,
278
- { cause: error },
279
- );
262
+ if (!existsSync(configPath)) return {};
263
+ const content = readFileSync(configPath, "utf8");
264
+ try {
265
+ return JSON.parse(content) as TelegramConfig;
266
+ } catch {
267
+ // Atomic config publication makes ordinary reads safe without serialization.
268
+ // Acquire the transaction only before destructive invalid-file recovery.
269
+ return withTelegramFileTransaction(`${configPath}.transaction`, () => {
270
+ if (!existsSync(configPath)) return {};
271
+ const identity = statSync(configPath);
272
+ const currentContent = readFileSync(configPath, "utf8");
273
+ try {
274
+ return JSON.parse(currentContent) as TelegramConfig;
275
+ } catch (error) {
276
+ const currentIdentity = statSync(configPath);
277
+ if (
278
+ currentIdentity.dev !== identity.dev ||
279
+ currentIdentity.ino !== identity.ino ||
280
+ currentIdentity.size !== identity.size ||
281
+ currentIdentity.mtimeMs !== identity.mtimeMs
282
+ ) {
283
+ throw new Error(
284
+ `Telegram config changed while validating invalid content: ${configPath}`,
285
+ { cause: error },
286
+ );
287
+ }
288
+ const recoveryPath = getInvalidTelegramConfigRecoveryPath(configPath);
289
+ renameSync(configPath, recoveryPath);
290
+ options.onInvalidConfig?.({ configPath, recoveryPath, error });
291
+ return {};
280
292
  }
281
- const recoveryPath = getInvalidTelegramConfigRecoveryPath(configPath);
282
- renameSync(configPath, recoveryPath);
283
- options.onInvalidConfig?.({ configPath, recoveryPath, error });
284
- return {};
285
- }
286
- });
293
+ });
294
+ }
287
295
  }
288
296
 
289
297
  export async function writeTelegramConfig(
@@ -538,7 +546,7 @@ export function createTelegramConfigStore(
538
546
  export function createTelegramProactivePushChecker(
539
547
  configStore: Pick<TelegramConfigStore, "get">,
540
548
  ): () => boolean {
541
- return () => configStore.get().proactivePush ?? false;
549
+ return () => configStore.get().assistant?.proactivePush ?? true;
542
550
  }
543
551
 
544
552
  export function createTelegramProactivePushSetter(
@@ -546,7 +554,11 @@ export function createTelegramProactivePushSetter(
546
554
  ): (enabled: boolean) => Promise<void> {
547
555
  return async (enabled) => {
548
556
  await loadLatestTelegramConfig(configStore);
549
- const config = { ...configStore.get(), proactivePush: enabled };
557
+ const current = configStore.get();
558
+ const config = {
559
+ ...current,
560
+ assistant: { ...current.assistant, proactivePush: enabled },
561
+ };
550
562
  configStore.set(config);
551
563
  await configStore.persist(config);
552
564
  };
package/lib/media.ts CHANGED
@@ -682,11 +682,107 @@ export async function downloadTelegramMessageFiles(
682
682
  return downloaded;
683
683
  }
684
684
 
685
+ function collectTelegramRichBlockFileInfos(
686
+ blocks: unknown,
687
+ messageId: number,
688
+ ): TelegramFileInfo[] {
689
+ if (!Array.isArray(blocks)) return [];
690
+ const files: TelegramFileInfo[] = [];
691
+ let mediaIndex = 0;
692
+ const visit = (entries: unknown): void => {
693
+ if (!Array.isArray(entries)) return;
694
+ for (const entry of entries) {
695
+ if (typeof entry !== "object" || entry === null) continue;
696
+ const type = getObjectField(entry, "type");
697
+ if (
698
+ type === "photo" ||
699
+ type === "animation" ||
700
+ type === "audio" ||
701
+ type === "video" ||
702
+ type === "voice_note"
703
+ ) {
704
+ mediaIndex += 1;
705
+ }
706
+ if (type === "photo") {
707
+ const photos = getObjectField(entry, "photo");
708
+ if (Array.isArray(photos)) {
709
+ const photo = photos
710
+ .filter(
711
+ (value): value is TelegramPhotoSize =>
712
+ typeof value === "object" &&
713
+ value !== null &&
714
+ typeof getObjectField(value, "file_id") === "string",
715
+ )
716
+ .sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
717
+ .at(-1);
718
+ if (photo) {
719
+ files.push({
720
+ file_id: photo.file_id,
721
+ fileName: `photo-${messageId}-${mediaIndex}.jpg`,
722
+ mimeType: "image/jpeg",
723
+ kind: "photo",
724
+ isImage: true,
725
+ });
726
+ }
727
+ }
728
+ }
729
+ const fileField =
730
+ type === "animation"
731
+ ? "animation"
732
+ : type === "audio"
733
+ ? "audio"
734
+ : type === "video"
735
+ ? "video"
736
+ : type === "voice_note"
737
+ ? "voice_note"
738
+ : undefined;
739
+ if (fileField) {
740
+ const media = getObjectField(entry, fileField);
741
+ const fileId = getObjectField(media, "file_id");
742
+ const mimeType = getObjectField(media, "mime_type");
743
+ const fileName = getObjectField(media, "file_name");
744
+ if (typeof fileId === "string") {
745
+ const kind =
746
+ type === "voice_note" ? "voice" : (type as TelegramAttachmentKind);
747
+ const fallbackExtension =
748
+ kind === "voice" ? ".ogg" : kind === "audio" ? ".mp3" : ".mp4";
749
+ files.push({
750
+ file_id: fileId,
751
+ fileName:
752
+ typeof fileName === "string"
753
+ ? fileName
754
+ : `${kind}-${messageId}-${mediaIndex}${guessExtensionFromMime(
755
+ typeof mimeType === "string" ? mimeType : undefined,
756
+ fallbackExtension,
757
+ )}`,
758
+ mimeType: typeof mimeType === "string" ? mimeType : undefined,
759
+ kind,
760
+ isImage: false,
761
+ });
762
+ }
763
+ }
764
+ visit(getObjectField(entry, "blocks"));
765
+ const items = getObjectField(entry, "items");
766
+ if (Array.isArray(items)) {
767
+ for (const item of items) visit(getObjectField(item, "blocks"));
768
+ }
769
+ }
770
+ };
771
+ visit(blocks);
772
+ return files;
773
+ }
774
+
685
775
  export function collectTelegramFileInfos(
686
776
  messages: TelegramMediaMessage[],
687
777
  ): TelegramFileInfo[] {
688
778
  const files: TelegramFileInfo[] = [];
689
779
  for (const message of messages) {
780
+ files.push(
781
+ ...collectTelegramRichBlockFileInfos(
782
+ message.rich_message?.blocks,
783
+ message.message_id,
784
+ ),
785
+ );
690
786
  if (Array.isArray(message.photo) && message.photo.length > 0) {
691
787
  const photo = [...message.photo]
692
788
  .sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
@@ -786,5 +882,10 @@ export function collectTelegramFileInfos(
786
882
  });
787
883
  }
788
884
  }
789
- return files;
885
+ const seenFileIds = new Set<string>();
886
+ return files.filter((file) => {
887
+ if (seenFileIds.has(file.file_id)) return false;
888
+ seenFileIds.add(file.file_id);
889
+ return true;
890
+ });
790
891
  }
@@ -159,7 +159,10 @@ export function buildProactivePushSettingsText(
159
159
  return [
160
160
  `${PROACTIVE_PUSH_SETTINGS_TITLE} <code>${proactivePushEnabled ? "on" : "off"}</code>`,
161
161
  "",
162
- "Send successful local Pi task results to Telegram when the bridge is connected.",
162
+ "Control whether public assistant output from local/autonomous work is projected to Telegram.",
163
+ "",
164
+ "<code>-</code> <code>on</code> (default): send each completed public block, including visible checkpoints and the final answer, while connected.",
165
+ "<code>-</code> <code>off</code>: keep local/autonomous assistant blocks in Pi; Telegram-originated replies still use their normal delivery path.",
163
166
  ].join("\n");
164
167
  }
165
168
 
@@ -10,7 +10,10 @@ import { basename } from "node:path";
10
10
  import { Type } from "@sinclair/typebox";
11
11
 
12
12
  import type { ExtensionAPI } from "./pi.ts";
13
- import { buildTelegramMultipartReplyParameters } from "./replies.ts";
13
+ import {
14
+ buildTelegramMultipartReplyParameters,
15
+ normalizeTelegramNativeMarkdown,
16
+ } from "./replies.ts";
14
17
  import {
15
18
  getTelegramTargetThreadParams,
16
19
  type TelegramTarget,
@@ -97,6 +100,152 @@ export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutbou
97
100
  target?: TelegramTarget;
98
101
  }
99
102
 
103
+ class TelegramRichAttachmentCommitUnknownError extends Error {
104
+ readonly kind = "commit-unknown" as const;
105
+
106
+ constructor(cause: unknown) {
107
+ super("Telegram Rich media upload may have committed without a message id.");
108
+ this.name = "TelegramRichAttachmentCommitUnknownError";
109
+ this.cause = cause;
110
+ }
111
+ }
112
+
113
+ function isTelegramRichAttachmentCommitUnknownError(error: unknown): boolean {
114
+ return (
115
+ error instanceof TelegramRichAttachmentCommitUnknownError ||
116
+ (typeof error === "object" &&
117
+ error !== null &&
118
+ (error as { kind?: unknown }).kind === "commit-unknown")
119
+ );
120
+ }
121
+
122
+ export interface TelegramRichOutboundAttachmentPlan {
123
+ method: "sendRichMessage";
124
+ fields: Record<string, string>;
125
+ fileField: "rich_media_upload";
126
+ filePath: string;
127
+ fileName: string;
128
+ }
129
+
130
+ export interface TelegramRichOutboundAttachmentSenderDeps extends TelegramOutboundAttachmentRuntimeEventRecorderPort {
131
+ sendMultipart: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
132
+ getRenderingMode: () => "rich" | "html";
133
+ recordOwnership?: (input: {
134
+ chatId: number;
135
+ messageId: number;
136
+ target?: TelegramTarget;
137
+ }) => void;
138
+ }
139
+
140
+ export function planTelegramRichOutboundAttachment(options: {
141
+ turn: TelegramQueuedOutboundAttachmentTurnView;
142
+ markdown: string;
143
+ renderingMode: "rich" | "html";
144
+ replyMarkup?: unknown;
145
+ }): TelegramRichOutboundAttachmentPlan | undefined {
146
+ if (options.renderingMode !== "rich") return undefined;
147
+ if (!options.markdown.trim()) return undefined;
148
+ if (options.turn.queuedAttachments.length !== 1) return undefined;
149
+ const attachment = options.turn.queuedAttachments[0]!;
150
+ const normalizedPath = attachment.path.toLowerCase();
151
+ const mediaType = normalizedPath.endsWith(".jpg") ||
152
+ normalizedPath.endsWith(".jpeg") ||
153
+ normalizedPath.endsWith(".png")
154
+ ? "photo"
155
+ : normalizedPath.endsWith(".mp4")
156
+ ? "video"
157
+ : normalizedPath.endsWith(".mp3")
158
+ ? "audio"
159
+ : undefined;
160
+ if (!mediaType) return undefined;
161
+ const mediaId = "artifact";
162
+ const richMessage = {
163
+ markdown: `${normalizeTelegramNativeMarkdown(options.markdown)}\n\n![](tg://${mediaType}?id=${mediaId})`,
164
+ media: [
165
+ {
166
+ id: mediaId,
167
+ media: {
168
+ type: mediaType,
169
+ media: "attach://rich_media_upload",
170
+ },
171
+ },
172
+ ],
173
+ skip_entity_detection: true,
174
+ };
175
+ const replyParameters =
176
+ options.turn.replyToMessageId > 0
177
+ ? JSON.stringify({
178
+ message_id: options.turn.replyToMessageId,
179
+ allow_sending_without_reply: true,
180
+ })
181
+ : undefined;
182
+ return {
183
+ method: "sendRichMessage",
184
+ fields: {
185
+ chat_id: String(options.turn.chatId),
186
+ ...(replyParameters ? { reply_parameters: replyParameters } : {}),
187
+ ...getTelegramMultipartTargetFields(options.turn.target),
188
+ rich_message: JSON.stringify(richMessage),
189
+ ...(options.replyMarkup
190
+ ? { reply_markup: JSON.stringify(options.replyMarkup) }
191
+ : {}),
192
+ },
193
+ fileField: "rich_media_upload",
194
+ filePath: attachment.path,
195
+ fileName: attachment.fileName,
196
+ };
197
+ }
198
+
199
+ export function createTelegramRichOutboundAttachmentSender(
200
+ deps: TelegramRichOutboundAttachmentSenderDeps,
201
+ ) {
202
+ return async (
203
+ turn: TelegramQueuedOutboundAttachmentTurnView,
204
+ markdown: string,
205
+ options?: { replyMarkup?: unknown },
206
+ ): Promise<boolean> => {
207
+ const plan = planTelegramRichOutboundAttachment({
208
+ turn,
209
+ markdown,
210
+ renderingMode: deps.getRenderingMode(),
211
+ replyMarkup: options?.replyMarkup,
212
+ });
213
+ if (!plan) return false;
214
+ try {
215
+ const result = await deps.sendMultipart(
216
+ plan.method,
217
+ plan.fields,
218
+ plan.fileField,
219
+ plan.filePath,
220
+ plan.fileName,
221
+ );
222
+ const messageId =
223
+ result && typeof result === "object" &&
224
+ Number.isInteger((result as { message_id?: unknown }).message_id)
225
+ ? (result as { message_id: number }).message_id
226
+ : undefined;
227
+ if (messageId === undefined) {
228
+ throw new TelegramRichAttachmentCommitUnknownError(
229
+ new Error("Successful Rich media upload omitted message_id."),
230
+ );
231
+ }
232
+ deps.recordOwnership?.({
233
+ chatId: turn.chatId,
234
+ messageId,
235
+ target: turn.target,
236
+ });
237
+ return true;
238
+ } catch (error) {
239
+ if (isTelegramRichAttachmentCommitUnknownError(error)) throw error;
240
+ deps.recordRuntimeEvent?.("attachment", error, {
241
+ phase: "rich-media-known-failure",
242
+ fileName: plan.fileName,
243
+ });
244
+ return false;
245
+ }
246
+ };
247
+ }
248
+
100
249
  export type TelegramGuestCachedAttachmentResult =
101
250
  | {
102
251
  type: "document";