@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/outbound.ts CHANGED
@@ -1,14 +1,22 @@
1
1
  /**
2
2
  * Telegram outbound surface helpers
3
3
  * Zones: telegram outbound, command templates, voice delivery
4
- * Owns configured outbound handler execution, text transforms, voice-file generation/delivery, runtime-event bridge, and compatibility re-exports; assistant markup parsing lives in outbound-markup and button callback actions live in outbound-buttons
4
+ * Owns configured outbound handler execution, text transforms, public assistant-output reply composition and mutation fencing, voice-file generation/delivery, runtime-event bridge, and compatibility re-exports; assistant markup parsing lives in outbound-markup and button callback actions live in outbound-buttons
5
5
  */
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { mkdir } from "node:fs/promises";
9
9
  import { join } from "node:path";
10
10
 
11
+ import type { TelegramAssistantSegmentEvent } from "./activity.ts";
11
12
  import { resolveTelegramTempDir } from "./paths.ts";
13
+ import * as Replies from "./replies.ts";
14
+ import type {
15
+ TelegramEditMessageTextBody,
16
+ TelegramSendMessageBody,
17
+ TelegramSendRichMessageBody,
18
+ TelegramSentMessage,
19
+ } from "./telegram-api.ts";
12
20
 
13
21
  import {
14
22
  planTelegramButtonReply,
@@ -905,3 +913,100 @@ export function createTelegramOutboundReplyArtifactSender(
905
913
  }
906
914
  };
907
915
  }
916
+
917
+ // --- Public Assistant Output Delivery ---
918
+
919
+ export interface TelegramAssistantOutputMutationFence {
920
+ run: <TArgs extends unknown[], TResult>(
921
+ mutation: (...args: TArgs) => Promise<TResult>,
922
+ ...args: TArgs
923
+ ) => Promise<TResult>;
924
+ }
925
+
926
+ export interface TelegramAssistantOutputDeliveryAuthority<TTransportStamp> {
927
+ transportStamp: TTransportStamp;
928
+ route: "direct" | "follower" | "none";
929
+ directEpoch?: number | string;
930
+ followerGeneration?: string;
931
+ target?: TelegramTarget;
932
+ }
933
+
934
+ export function createTelegramAssistantOutputMutationFence(
935
+ isAuthorityActive: () => boolean,
936
+ ): TelegramAssistantOutputMutationFence {
937
+ return {
938
+ run(mutation, ...args) {
939
+ if (!isAuthorityActive()) {
940
+ return Promise.reject(
941
+ new Error(
942
+ "Assistant output lost admission authority before transport mutation.",
943
+ ),
944
+ );
945
+ }
946
+ return mutation(...args);
947
+ },
948
+ };
949
+ }
950
+
951
+ export function createTelegramAssistantOutputSender<
952
+ TTransportStamp,
953
+ TReplyMarkup = unknown,
954
+ >(deps: {
955
+ recordOwnership?: Replies.TelegramReplyOwnershipRecorder["record"];
956
+ sendMessage: (
957
+ body: TelegramSendMessageBody,
958
+ ) => Promise<TelegramSentMessage>;
959
+ sendRichMessage: (
960
+ body: TelegramSendRichMessageBody,
961
+ ) => Promise<TelegramSentMessage>;
962
+ editMessage: (
963
+ body: TelegramEditMessageTextBody,
964
+ ) => Promise<unknown>;
965
+ getAssistantRenderingMode: () => "rich" | "html";
966
+ execCommand: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>["execCommand"];
967
+ getHandlers?: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>["getHandlers"];
968
+ recordRuntimeEvent?: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>["recordRuntimeEvent"];
969
+ }): (
970
+ event: TelegramAssistantSegmentEvent,
971
+ authority: TelegramAssistantOutputDeliveryAuthority<TTransportStamp>,
972
+ isAuthorityActive: () => boolean,
973
+ ) => Promise<void> {
974
+ return async function sendAssistantOutput(
975
+ event,
976
+ authority,
977
+ isAuthorityActive,
978
+ ) {
979
+ const target = authority.target;
980
+ if (!target) {
981
+ throw new Error("Assistant output has no authorized Telegram target.");
982
+ }
983
+ const mutationFence =
984
+ createTelegramAssistantOutputMutationFence(isAuthorityActive);
985
+ const replyRuntime = Replies.createTelegramRenderedMessageDeliveryRuntime({
986
+ recordOwnership: deps.recordOwnership,
987
+ sendMessage(body) {
988
+ return mutationFence.run(deps.sendMessage, body);
989
+ },
990
+ sendRichMessage(body) {
991
+ return mutationFence.run(deps.sendRichMessage, body);
992
+ },
993
+ getAssistantRenderingMode: deps.getAssistantRenderingMode,
994
+ editMessage(body) {
995
+ return mutationFence.run(deps.editMessage, body);
996
+ },
997
+ });
998
+ const outboundRuntime = createTelegramOutboundTextReplyRuntime({
999
+ sendTextReply: replyRuntime.sendTextReply,
1000
+ sendMarkdownReply: replyRuntime.sendMarkdownReply,
1001
+ execCommand: deps.execCommand,
1002
+ getHandlers: deps.getHandlers,
1003
+ recordRuntimeEvent: deps.recordRuntimeEvent,
1004
+ });
1005
+ await outboundRuntime.sendMarkdownReply(
1006
+ target.chatId,
1007
+ undefined,
1008
+ event.text,
1009
+ { target },
1010
+ );
1011
+ };
1012
+ }
package/lib/polling.ts CHANGED
@@ -160,6 +160,7 @@ export function createTelegramPollingControllerRuntime<
160
160
  getUpdates: deps.getUpdates,
161
161
  persistConfig: deps.persistConfig,
162
162
  handleUpdate: deps.handleUpdate,
163
+ prepareUpdateBatch: deps.prepareUpdateBatch,
163
164
  updateStatus: deps.updateStatus,
164
165
  sleep: deps.sleep,
165
166
  maxUpdateFailures: deps.maxUpdateFailures,
@@ -909,6 +910,7 @@ export interface TelegramPollLoopDeps<
909
910
  ) => Promise<TUpdate[]>;
910
911
  persistConfig: (config: TelegramPollingConfig) => Promise<void>;
911
912
  handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
913
+ prepareUpdateBatch?: (updates: readonly TUpdate[]) => void;
912
914
  onErrorStatus: (message: string) => void;
913
915
  onStatusReset: () => void;
914
916
  sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
@@ -927,6 +929,7 @@ export interface TelegramPollLoopRunnerDeps<
927
929
  ) => Promise<TUpdate[]>;
928
930
  persistConfig: (config: TelegramPollingConfig) => Promise<void>;
929
931
  handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
932
+ prepareUpdateBatch?: (updates: readonly TUpdate[]) => void;
930
933
  updateStatus: (ctx: TContext, message?: string) => void;
931
934
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
932
935
  maxUpdateFailures?: number;
@@ -973,6 +976,7 @@ export function createTelegramPollLoopRunner<
973
976
  getUpdates: deps.getUpdates,
974
977
  persistConfig: deps.persistConfig,
975
978
  handleUpdate: deps.handleUpdate,
979
+ prepareUpdateBatch: deps.prepareUpdateBatch,
976
980
  onErrorStatus: (message) => {
977
981
  updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
978
982
  message,
@@ -1039,6 +1043,7 @@ export async function runTelegramPollLoop<
1039
1043
  buildTelegramLongPollRequest(deps.config.lastUpdateId),
1040
1044
  deps.signal,
1041
1045
  );
1046
+ deps.prepareUpdateBatch?.(updates);
1042
1047
  consecutiveGetUpdatesConflicts = 0;
1043
1048
  for (const update of updates) {
1044
1049
  if (admittedUpdates.has(update.update_id)) {
package/lib/queue.ts CHANGED
@@ -954,6 +954,11 @@ export interface TelegramAgentEndRuntimeDeps<
954
954
  options?: { target?: TelegramQueueTarget },
955
955
  ) => Promise<unknown>;
956
956
  sendQueuedAttachments: (turn: TTurn) => Promise<void>;
957
+ sendRichAttachmentReply?: (
958
+ turn: TTurn,
959
+ markdown: string,
960
+ options?: { replyMarkup?: TReplyMarkup },
961
+ ) => Promise<boolean>;
957
962
  answerGuestQuery?: (
958
963
  guestQueryId: string,
959
964
  text?: string,
@@ -978,10 +983,6 @@ export interface TelegramAgentEndRuntimeDeps<
978
983
  plan: TelegramAgentEndOutboundReplyPlan,
979
984
  options?: { replyToPrompt?: boolean },
980
985
  ) => Promise<void>;
981
- getDefaultChatId?: () => number | undefined;
982
- getDefaultTarget?: () => TelegramQueueTarget | undefined;
983
- isProactivePushEnabled?: () => boolean;
984
- canSendProactivePush?: () => boolean;
985
986
  recordRuntimeEvent?: (
986
987
  category: string,
987
988
  error: unknown,
@@ -1029,6 +1030,10 @@ export interface TelegramAgentEndHookRuntimeDeps<
1029
1030
  >["sendMarkdownReply"];
1030
1031
  sendTextReply: TelegramAgentEndRuntimeDeps<TTurn>["sendTextReply"];
1031
1032
  sendQueuedAttachments: (turn: TTurn) => Promise<void>;
1033
+ sendRichAttachmentReply?: TelegramAgentEndRuntimeDeps<
1034
+ TTurn,
1035
+ TReplyMarkup
1036
+ >["sendRichAttachmentReply"];
1032
1037
  answerGuestQuery?: TelegramAgentEndRuntimeDeps<TTurn>["answerGuestQuery"];
1033
1038
  sendGuestReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestReply"];
1034
1039
  sendGuestAttachment?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestAttachment"];
@@ -1038,10 +1043,6 @@ export interface TelegramAgentEndHookRuntimeDeps<
1038
1043
  TReplyMarkup
1039
1044
  >["planOutboundReply"];
1040
1045
  sendOutboundReplyArtifacts?: TelegramAgentEndRuntimeDeps<TTurn>["sendOutboundReplyArtifacts"];
1041
- getDefaultChatId?: TelegramAgentEndRuntimeDeps<TTurn>["getDefaultChatId"];
1042
- getDefaultTarget?: TelegramAgentEndRuntimeDeps<TTurn>["getDefaultTarget"];
1043
- isProactivePushEnabled?: TelegramAgentEndRuntimeDeps<TTurn>["isProactivePushEnabled"];
1044
- canSendProactivePush?: (ctx: TContext) => boolean;
1045
1046
  recordRuntimeEvent?: TelegramAgentEndRuntimeDeps<TTurn>["recordRuntimeEvent"];
1046
1047
  }
1047
1048
 
@@ -1135,12 +1136,9 @@ export function createTelegramAgentEndHook<
1135
1136
  await deps.loadConfig?.();
1136
1137
  if (deps.isSessionActive && !deps.isSessionActive(ctx)) return;
1137
1138
  const turn = deps.getActiveTurn();
1138
- const proactiveEnabled = deps.isProactivePushEnabled?.() ?? false;
1139
- const canProactivePush = deps.canSendProactivePush?.(ctx) ?? false;
1140
1139
  await handleTelegramAgentEndRuntime({
1141
1140
  turn,
1142
- assistant:
1143
- turn || proactiveEnabled ? deps.extractAssistant(event.messages) : {},
1141
+ assistant: turn ? deps.extractAssistant(event.messages) : {},
1144
1142
  foldQueuedPromptsIntoHistory: deps.getFoldQueuedPromptsIntoHistory(),
1145
1143
  resetRuntimeState: deps.resetRuntimeState,
1146
1144
  isSessionActive: () => deps.isSessionActive?.(ctx) ?? true,
@@ -1165,16 +1163,13 @@ export function createTelegramAgentEndHook<
1165
1163
  sendMarkdownReply: deps.sendMarkdownReply,
1166
1164
  sendTextReply: deps.sendTextReply,
1167
1165
  sendQueuedAttachments: deps.sendQueuedAttachments,
1166
+ sendRichAttachmentReply: deps.sendRichAttachmentReply,
1168
1167
  answerGuestQuery: deps.answerGuestQuery,
1169
1168
  sendGuestReply: deps.sendGuestReply,
1170
1169
  sendGuestAttachment: deps.sendGuestAttachment,
1171
1170
  sendGuestVoiceReply: deps.sendGuestVoiceReply,
1172
1171
  planOutboundReply: deps.planOutboundReply,
1173
1172
  sendOutboundReplyArtifacts: deps.sendOutboundReplyArtifacts,
1174
- getDefaultChatId: deps.getDefaultChatId,
1175
- getDefaultTarget: deps.getDefaultTarget,
1176
- isProactivePushEnabled: deps.isProactivePushEnabled,
1177
- canSendProactivePush: () => canProactivePush,
1178
1173
  recordRuntimeEvent: deps.recordRuntimeEvent,
1179
1174
  });
1180
1175
  };
@@ -1241,35 +1236,6 @@ export async function handleTelegramAgentEndRuntime<
1241
1236
  foldQueuedPromptsIntoHistory: deps.foldQueuedPromptsIntoHistory,
1242
1237
  });
1243
1238
  if (!turn) {
1244
- const proactiveEnabled = deps.isProactivePushEnabled?.() ?? false;
1245
- const canProactivePush = deps.canSendProactivePush?.() ?? false;
1246
- if (proactiveEnabled && finalText && !assistant.errorMessage) {
1247
- if (canProactivePush) {
1248
- const defaultTarget = deps.getDefaultTarget?.();
1249
- const defaultChatId =
1250
- defaultTarget?.chatId ?? deps.getDefaultChatId?.();
1251
- if (defaultChatId !== undefined) {
1252
- try {
1253
- await deps.sendMarkdownReply(defaultChatId, undefined, finalText, {
1254
- target: defaultTarget,
1255
- });
1256
- } catch (error) {
1257
- deps.recordRuntimeEvent?.("proactive-push", error, {
1258
- chatId: defaultChatId,
1259
- threadId: defaultTarget?.threadId,
1260
- });
1261
- }
1262
- }
1263
- } else {
1264
- deps.recordRuntimeEvent?.(
1265
- "proactive-push",
1266
- new Error(
1267
- "Proactive push skipped because this instance does not own Telegram polling.",
1268
- ),
1269
- { phase: "ownership" },
1270
- );
1271
- }
1272
- }
1273
1239
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
1274
1240
  return;
1275
1241
  }
@@ -1346,7 +1312,34 @@ export async function handleTelegramAgentEndRuntime<
1346
1312
  if (!finalText && hasOutboundArtifacts)
1347
1313
  await deps.clearPreview(turn.chatId, { target: turn.target });
1348
1314
  if (!isDeliveryActive()) return;
1349
- if (endPlan.kind === "text" && finalText) {
1315
+ let richAttachmentDelivered = false;
1316
+ if (
1317
+ endPlan.kind === "text" &&
1318
+ finalText &&
1319
+ !hasOutboundArtifacts &&
1320
+ deps.sendRichAttachmentReply
1321
+ ) {
1322
+ try {
1323
+ richAttachmentDelivered = await deps.sendRichAttachmentReply(
1324
+ turn,
1325
+ finalText,
1326
+ { replyMarkup },
1327
+ );
1328
+ if (!isDeliveryActive()) return;
1329
+ if (richAttachmentDelivered) {
1330
+ await deps.clearPreview(turn.chatId, { target: turn.target });
1331
+ }
1332
+ } catch (error) {
1333
+ deps.recordRuntimeEvent?.("delivery", error, {
1334
+ phase: "rich-attachment-commit-unknown",
1335
+ chatId: turn.chatId,
1336
+ });
1337
+ if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
1338
+ return;
1339
+ }
1340
+ }
1341
+ if (!isDeliveryActive()) return;
1342
+ if (!richAttachmentDelivered && endPlan.kind === "text" && finalText) {
1350
1343
  try {
1351
1344
  const finalized = await deps.finalizeMarkdownPreview(
1352
1345
  turn.chatId,
@@ -1414,7 +1407,7 @@ export async function handleTelegramAgentEndRuntime<
1414
1407
  }
1415
1408
  }
1416
1409
  if (!isDeliveryActive()) return;
1417
- if (endPlan.shouldSendAttachmentNotice) {
1410
+ if (!richAttachmentDelivered && endPlan.shouldSendAttachmentNotice) {
1418
1411
  await deps.sendTextReply(
1419
1412
  turn.chatId,
1420
1413
  turn.replyToMessageId,
@@ -1423,7 +1416,7 @@ export async function handleTelegramAgentEndRuntime<
1423
1416
  );
1424
1417
  }
1425
1418
  if (!isDeliveryActive()) return;
1426
- await deps.sendQueuedAttachments(turn);
1419
+ if (!richAttachmentDelivered) await deps.sendQueuedAttachments(turn);
1427
1420
  if (!isDeliveryActive()) return;
1428
1421
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
1429
1422
  };
package/lib/routing.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Telegram inbound routing composition
3
3
  * Zones: telegram inbound, orchestration, queue/menu/command composition
4
- * Wires authorized updates into menus, commands, media grouping, and prompt queueing
4
+ * Wires authorized updates into menus, commands, media grouping, and prompt queueing, and owns exact assistant-output target/route authority capture
5
5
  */
6
6
 
7
7
  import { readFile } from "node:fs/promises";
@@ -2471,3 +2471,90 @@ export function createTelegramInboundRouteRuntime<
2471
2471
  },
2472
2472
  });
2473
2473
  }
2474
+
2475
+ // --- Assistant Output Delivery Authority ---
2476
+
2477
+ export interface TelegramAssistantOutputAuthority<TTransportStamp> {
2478
+ transportStamp: TTransportStamp;
2479
+ route: "direct" | "follower" | "none";
2480
+ directEpoch?: number | string;
2481
+ followerGeneration?: string;
2482
+ target?: Queue.TelegramQueueTarget;
2483
+ }
2484
+
2485
+ export interface TelegramAssistantOutputAuthorityRuntime<TTransportStamp> {
2486
+ captureAuthority: () => TelegramAssistantOutputAuthority<TTransportStamp>;
2487
+ isAuthorityActive: (
2488
+ authority: TelegramAssistantOutputAuthority<TTransportStamp>,
2489
+ ) => boolean;
2490
+ canDeliver: () => boolean;
2491
+ }
2492
+
2493
+ export function createTelegramAssistantOutputAuthorityRuntime<TTransportStamp>(deps: {
2494
+ getPreferredTarget: () => Queue.TelegramQueueTarget | undefined;
2495
+ getFallbackChatId: () => number | undefined;
2496
+ getTransportStamp: () => TTransportStamp;
2497
+ isTransportStampActive: (stamp: TTransportStamp) => boolean;
2498
+ ownsDirect: () => boolean;
2499
+ getDirectEpoch: () => number | string | undefined;
2500
+ isFollowerRegistered: () => boolean;
2501
+ getFollowerGeneration: () => string | undefined;
2502
+ }): TelegramAssistantOutputAuthorityRuntime<TTransportStamp> {
2503
+ const getCurrentTarget = (): Queue.TelegramQueueTarget | undefined => {
2504
+ const preferred = deps.getPreferredTarget();
2505
+ if (preferred) return { ...preferred };
2506
+ const chatId = deps.getFallbackChatId();
2507
+ return chatId === undefined ? undefined : { chatId };
2508
+ };
2509
+ return {
2510
+ captureAuthority() {
2511
+ const target = getCurrentTarget();
2512
+ const directEpoch = deps.ownsDirect()
2513
+ ? deps.getDirectEpoch()
2514
+ : undefined;
2515
+ const followerGeneration = deps.isFollowerRegistered()
2516
+ ? deps.getFollowerGeneration()
2517
+ : undefined;
2518
+ return {
2519
+ transportStamp: deps.getTransportStamp(),
2520
+ route:
2521
+ directEpoch !== undefined
2522
+ ? "direct"
2523
+ : followerGeneration !== undefined
2524
+ ? "follower"
2525
+ : "none",
2526
+ directEpoch,
2527
+ followerGeneration,
2528
+ target,
2529
+ };
2530
+ },
2531
+ isAuthorityActive(authority) {
2532
+ if (!deps.isTransportStampActive(authority.transportStamp)) return false;
2533
+ const target = getCurrentTarget();
2534
+ if (
2535
+ authority.target === undefined ||
2536
+ target?.chatId !== authority.target.chatId ||
2537
+ target?.threadId !== authority.target.threadId
2538
+ ) {
2539
+ return false;
2540
+ }
2541
+ if (authority.route === "direct") {
2542
+ return (
2543
+ deps.ownsDirect() &&
2544
+ deps.getDirectEpoch() === authority.directEpoch
2545
+ );
2546
+ }
2547
+ if (authority.route === "follower") {
2548
+ return (
2549
+ !deps.ownsDirect() &&
2550
+ deps.isFollowerRegistered() &&
2551
+ deps.getFollowerGeneration() === authority.followerGeneration
2552
+ );
2553
+ }
2554
+ return false;
2555
+ },
2556
+ canDeliver() {
2557
+ return deps.ownsDirect() || deps.isFollowerRegistered();
2558
+ },
2559
+ };
2560
+ }
package/lib/sync.ts CHANGED
@@ -123,6 +123,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
123
123
  getLeaderTarget: () => TelegramTarget | undefined;
124
124
  getCurrentLeaderEpoch?: () => number | string | undefined;
125
125
  clearLeaderTarget: () => void;
126
+ disconnectFollowerThread?: () => Promise<boolean>;
126
127
  getSyncState: () => TSyncState;
127
128
  setSyncState: (state: TSyncState) => void;
128
129
  stopPolling: () => Promise<string>;
@@ -160,13 +161,25 @@ export function createTelegramManualThreadDisconnectHandler<
160
161
  const currentRecord = deps.getCurrentThreadRecord();
161
162
  if (currentRecord?.target.threadId) {
162
163
  const isManualFollower = currentRecord.owner?.kind === "manual-follower";
163
- if (!isManualFollower) {
164
- const leaderEpoch = deps.getCurrentLeaderEpoch?.();
164
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
165
+ const ownsLeader = deps.getCurrentLeaderEpoch
166
+ ? leaderEpoch !== undefined
167
+ : !isManualFollower;
168
+ if (isManualFollower && !ownsLeader) {
169
+ if (deps.disconnectFollowerThread) {
170
+ const disconnected = await deps.disconnectFollowerThread();
171
+ if (!disconnected) {
172
+ throw new Error(
173
+ "Telegram follower thread deletion requires a live leader registration.",
174
+ );
175
+ }
176
+ }
177
+ } else {
165
178
  const stillOwnsLeaderEpoch = () =>
166
179
  !deps.getCurrentLeaderEpoch ||
167
180
  (leaderEpoch !== undefined &&
168
181
  deps.getCurrentLeaderEpoch() === leaderEpoch);
169
- await ThreadReconciler.applyThreadReconciliationPlan(
182
+ const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
170
183
  ThreadReconciler.planDisconnectedInstanceThreadCleanup({
171
184
  target: currentRecord.target as TelegramTarget & {
172
185
  threadId: number;
@@ -185,6 +198,11 @@ export function createTelegramManualThreadDisconnectHandler<
185
198
  recordRuntimeEvent: deps.recordRuntimeEvent,
186
199
  },
187
200
  );
201
+ if (cleanup.incompleteActions?.length) {
202
+ throw new Error(
203
+ "Telegram thread deletion was not confirmed; inspect /telegram-status --debug and retry /telegram-disconnect.",
204
+ );
205
+ }
188
206
  if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
189
207
  const offlineChanged =
190
208
  deps.topicTargetStore.markOfflineByInstanceId(deps.instanceId) > 0;
@@ -278,6 +296,15 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
278
296
  getNowMs?: () => number;
279
297
  }
280
298
 
299
+ export function createTelegramStaleTopicApiErrorRecoveryRuntime<
300
+ TSyncState extends TelegramSyncState,
301
+ >(
302
+ deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState>,
303
+ ): (apiBody: unknown, error: unknown) => Promise<boolean> {
304
+ return (apiBody, error) =>
305
+ recoverStaleTelegramTopicApiError(apiBody, error, deps);
306
+ }
307
+
281
308
  export async function recoverStaleTelegramTopicApiError<
282
309
  TSyncState extends TelegramSyncState,
283
310
  >(
@@ -493,6 +520,16 @@ export interface TelegramSyncStateRuntime {
493
520
  ): void;
494
521
  }
495
522
 
523
+ export function createTelegramConfigSyncPersister<TConfig>(deps: {
524
+ persist: (config?: TConfig) => Promise<void>;
525
+ markConfigChange: (action: string) => void;
526
+ }): (config?: TConfig) => Promise<void> {
527
+ return async (config) => {
528
+ await deps.persist(config);
529
+ deps.markConfigChange("config-persist");
530
+ };
531
+ }
532
+
496
533
  export function createTelegramSyncStateRuntime(
497
534
  initialState = createUnknownTelegramSyncState(),
498
535
  ): TelegramSyncStateRuntime {
@@ -212,19 +212,83 @@ export type TelegramSendMessageBody = Record<string, unknown> & {
212
212
  reply_parameters?: TelegramReplyParameters;
213
213
  };
214
214
 
215
- export type TelegramInputRichMessage =
216
- | {
217
- markdown: string;
218
- html?: never;
219
- is_rtl?: boolean;
220
- skip_entity_detection?: boolean;
221
- }
222
- | {
223
- html: string;
224
- markdown?: never;
225
- is_rtl?: boolean;
226
- skip_entity_detection?: boolean;
227
- };
215
+ export interface TelegramInputMediaPhoto extends Record<string, unknown> {
216
+ type: "photo";
217
+ media: string;
218
+ has_spoiler?: boolean;
219
+ }
220
+
221
+ export interface TelegramInputMediaVideo extends Record<string, unknown> {
222
+ type: "video";
223
+ media: string;
224
+ thumbnail?: string;
225
+ width?: number;
226
+ height?: number;
227
+ duration?: number;
228
+ supports_streaming?: boolean;
229
+ has_spoiler?: boolean;
230
+ }
231
+
232
+ export interface TelegramInputMediaAnimation extends Record<string, unknown> {
233
+ type: "animation";
234
+ media: string;
235
+ thumbnail?: string;
236
+ width?: number;
237
+ height?: number;
238
+ duration?: number;
239
+ has_spoiler?: boolean;
240
+ }
241
+
242
+ export interface TelegramInputMediaAudio extends Record<string, unknown> {
243
+ type: "audio";
244
+ media: string;
245
+ thumbnail?: string;
246
+ duration?: number;
247
+ performer?: string;
248
+ title?: string;
249
+ }
250
+
251
+ export interface TelegramInputMediaVoiceNote extends Record<string, unknown> {
252
+ type: "voice_note";
253
+ media: string;
254
+ caption?: string;
255
+ parse_mode?: string;
256
+ caption_entities?: unknown[];
257
+ duration?: number;
258
+ }
259
+
260
+ export type TelegramInputRichMessageMediaValue =
261
+ | TelegramInputMediaAnimation
262
+ | TelegramInputMediaAudio
263
+ | TelegramInputMediaPhoto
264
+ | TelegramInputMediaVideo
265
+ | TelegramInputMediaVoiceNote;
266
+
267
+ export interface TelegramInputRichMessageMedia {
268
+ id: string;
269
+ media: TelegramInputRichMessageMediaValue;
270
+ }
271
+
272
+ type TelegramInputRichMessageCommon = {
273
+ is_rtl?: boolean;
274
+ skip_entity_detection?: boolean;
275
+ };
276
+
277
+ export type TelegramInputRichMessage = TelegramInputRichMessageCommon &
278
+ (
279
+ | {
280
+ markdown: string;
281
+ html?: never;
282
+ blocks?: never;
283
+ media?: TelegramInputRichMessageMedia[];
284
+ }
285
+ | {
286
+ html: string;
287
+ markdown?: never;
288
+ blocks?: never;
289
+ media?: TelegramInputRichMessageMedia[];
290
+ }
291
+ );
228
292
 
229
293
  export type TelegramSendRichMessageBody = Record<string, unknown> & {
230
294
  chat_id: number;