@llblab/pi-telegram 0.17.4 → 0.18.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.
Files changed (62) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -14
  3. package/CHANGELOG.md +40 -15
  4. package/README.md +63 -35
  5. package/docs/README.md +3 -1
  6. package/docs/architecture.md +55 -23
  7. package/docs/callback-namespaces.md +1 -1
  8. package/docs/inbound.md +1 -1
  9. package/docs/locks.md +0 -2
  10. package/docs/multi-instance-bus.md +483 -0
  11. package/docs/outbound.md +4 -3
  12. package/docs/public-api.md +12 -10
  13. package/docs/sections.md +2 -2
  14. package/docs/ui-style.md +76 -0
  15. package/index.ts +789 -32
  16. package/lib/bindings.ts +68 -12
  17. package/lib/bus-api.ts +314 -0
  18. package/lib/bus-follower.ts +853 -0
  19. package/lib/bus-leader.ts +915 -0
  20. package/lib/bus.ts +866 -0
  21. package/lib/command-templates.ts +9 -11
  22. package/lib/commands.ts +133 -47
  23. package/lib/config.ts +53 -5
  24. package/lib/lifecycle.ts +23 -7
  25. package/lib/locks.ts +230 -66
  26. package/lib/media.ts +30 -2
  27. package/lib/menu-model.ts +48 -17
  28. package/lib/menu-queue.ts +51 -20
  29. package/lib/menu-settings.ts +9 -5
  30. package/lib/menu-status.ts +3 -0
  31. package/lib/menu-thinking.ts +3 -0
  32. package/lib/menu.ts +67 -26
  33. package/lib/outbound-attachments.ts +102 -17
  34. package/lib/outbound-buttons.ts +6 -2
  35. package/lib/outbound-voice.ts +31 -11
  36. package/lib/outbound.ts +6 -4
  37. package/lib/ownership.ts +119 -0
  38. package/lib/pi.ts +26 -3
  39. package/lib/polling.ts +477 -7
  40. package/lib/preview.ts +141 -88
  41. package/lib/prompt-templates.ts +3 -3
  42. package/lib/prompts.ts +80 -30
  43. package/lib/queue.ts +193 -91
  44. package/lib/rendering.ts +0 -25
  45. package/lib/replies.ts +187 -55
  46. package/lib/routing.ts +1673 -9
  47. package/lib/runtime-log.ts +123 -0
  48. package/lib/runtime.ts +84 -12
  49. package/lib/sections.ts +28 -21
  50. package/lib/setup.ts +1 -1
  51. package/lib/status.ts +532 -9
  52. package/lib/sync.ts +618 -0
  53. package/lib/target.ts +49 -0
  54. package/lib/telegram-api.ts +405 -40
  55. package/lib/text-groups.ts +5 -1
  56. package/lib/thread-reconciler.ts +915 -0
  57. package/lib/threads.ts +2205 -0
  58. package/lib/turns.ts +48 -3
  59. package/lib/updates.ts +355 -32
  60. package/package.json +24 -2
  61. package/screenshot.png +0 -0
  62. package/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/updates.ts CHANGED
@@ -4,6 +4,11 @@
4
4
  * Owns update extraction, authorization, classification, execution planning, runtime execution, and the public update-handler registry
5
5
  */
6
6
 
7
+ import {
8
+ createTelegramPrivateTarget,
9
+ createTelegramThreadTarget,
10
+ type TelegramTarget,
11
+ } from "./target.ts";
7
12
  import {
8
13
  createTelegramUserPairingRuntime,
9
14
  getTelegramAuthorizationState,
@@ -122,6 +127,48 @@ export interface TelegramUpdateMessage {
122
127
  chat: TelegramChat;
123
128
  from?: TelegramUser;
124
129
  message_id?: number;
130
+ message_thread_id?: number;
131
+ forum_topic_created?: unknown;
132
+ forum_topic_closed?: unknown;
133
+ forum_topic_reopened?: unknown;
134
+ }
135
+
136
+ export type TelegramTopicLifecycleKind = "created" | "closed" | "reopened";
137
+
138
+ export interface TelegramTopicLifecycleUpdate<
139
+ TMessage = TelegramUpdateMessage,
140
+ > {
141
+ kind: TelegramTopicLifecycleKind;
142
+ message: TMessage;
143
+ target: TelegramTarget & { threadId: number };
144
+ }
145
+
146
+ export function getTelegramTopicLifecycleUpdate<
147
+ TMessage extends TelegramUpdateMessage,
148
+ >(
149
+ message: TMessage | undefined,
150
+ ): TelegramTopicLifecycleUpdate<TMessage> | undefined {
151
+ if (
152
+ !message ||
153
+ typeof message.chat.id !== "number" ||
154
+ typeof message.message_thread_id !== "number"
155
+ ) {
156
+ return undefined;
157
+ }
158
+ const target: TelegramTarget & { threadId: number } = {
159
+ ...createTelegramThreadTarget(message.chat.id, message.message_thread_id),
160
+ threadId: message.message_thread_id,
161
+ };
162
+ if (message.forum_topic_created !== undefined) {
163
+ return { kind: "created", message, target };
164
+ }
165
+ if (message.forum_topic_closed !== undefined) {
166
+ return { kind: "closed", message, target };
167
+ }
168
+ if (message.forum_topic_reopened !== undefined) {
169
+ return { kind: "reopened", message, target };
170
+ }
171
+ return undefined;
125
172
  }
126
173
 
127
174
  export interface TelegramCallbackQuery {
@@ -139,6 +186,15 @@ export interface TelegramGuestMessage {
139
186
  reply_to_message?: TelegramUpdateMessage;
140
187
  }
141
188
 
189
+ export function getTelegramMessageTarget(
190
+ message: TelegramUpdateMessage,
191
+ ): TelegramTarget | undefined {
192
+ if (typeof message.chat.id !== "number") return undefined;
193
+ return typeof message.message_thread_id === "number"
194
+ ? createTelegramThreadTarget(message.chat.id, message.message_thread_id)
195
+ : createTelegramPrivateTarget(message.chat.id);
196
+ }
197
+
142
198
  export interface TelegramUpdateRouting {
143
199
  message?: TelegramUpdateMessage;
144
200
  edited_message?: TelegramUpdateMessage;
@@ -148,44 +204,34 @@ export interface TelegramUpdateRouting {
148
204
 
149
205
  export function getAuthorizedTelegramCallbackQuery(
150
206
  update: TelegramUpdateRouting,
207
+ allowedUserId?: number,
151
208
  ): TelegramCallbackQuery | undefined {
152
209
  const query = update.callback_query;
153
- if (!query) return undefined;
210
+ if (!query || query.from.is_bot) return undefined;
154
211
  const message = query.message;
155
- if (!message || message.chat.type !== "private" || query.from.is_bot) {
156
- return undefined;
157
- }
158
- return query;
212
+ if (!message) return undefined;
213
+ if (message.chat.type === "private") return query;
214
+ return query.from.id === allowedUserId ? query : undefined;
159
215
  }
160
216
 
161
217
  export function getAuthorizedTelegramMessage(
162
218
  update: TelegramUpdateRouting,
219
+ allowedUserId?: number,
163
220
  ): TelegramUpdateMessage | undefined {
164
221
  const message = update.message;
165
- if (
166
- !message ||
167
- message.chat.type !== "private" ||
168
- !message.from ||
169
- message.from.is_bot
170
- ) {
171
- return undefined;
172
- }
173
- return message;
222
+ if (!message || !message.from || message.from.is_bot) return undefined;
223
+ if (message.chat.type === "private") return message;
224
+ return message.from.id === allowedUserId ? message : undefined;
174
225
  }
175
226
 
176
227
  export function getAuthorizedTelegramEditedMessage(
177
228
  update: TelegramUpdateRouting,
229
+ allowedUserId?: number,
178
230
  ): TelegramUpdateMessage | undefined {
179
231
  const message = update.edited_message;
180
- if (
181
- !message ||
182
- message.chat.type !== "private" ||
183
- !message.from ||
184
- message.from.is_bot
185
- ) {
186
- return undefined;
187
- }
188
- return message;
232
+ if (!message || !message.from || message.from.is_bot) return undefined;
233
+ if (message.chat.type === "private") return message;
234
+ return message.from.id === allowedUserId ? message : undefined;
189
235
  }
190
236
 
191
237
  export function getAuthorizedTelegramGuestMessage(
@@ -200,8 +246,54 @@ export function getAuthorizedTelegramGuestMessage(
200
246
 
201
247
  // --- Flow ---
202
248
 
249
+ export interface TelegramMessageOwnershipView {
250
+ instanceId: string;
251
+ }
252
+
253
+ export type TelegramMessageOwnershipLookup = (
254
+ chatId: number,
255
+ messageId: number,
256
+ ) => TelegramMessageOwnershipView | undefined;
257
+
258
+ export interface TelegramTargetOwnershipView {
259
+ instanceId: string;
260
+ }
261
+
262
+ export type TelegramTargetOwnershipLookup = (
263
+ target: TelegramTarget,
264
+ ) => TelegramTargetOwnershipView | undefined;
265
+
266
+ export interface TelegramForeignOwnedUpdateForwarder<
267
+ TContext,
268
+ TReactionUpdate extends TelegramMessageReactionUpdated =
269
+ TelegramMessageReactionUpdated,
270
+ TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
271
+ TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
272
+ > {
273
+ forwardCallback?: (input: {
274
+ query: TCallbackQuery;
275
+ ownership: TelegramMessageOwnershipView;
276
+ ctx: TContext;
277
+ }) => Promise<boolean> | boolean;
278
+ forwardReaction?: (input: {
279
+ reactionUpdate: TReactionUpdate;
280
+ ownership: TelegramMessageOwnershipView;
281
+ ctx: TContext;
282
+ }) => Promise<boolean> | boolean;
283
+ forwardMessage?: (input: {
284
+ message: TMessage;
285
+ ownership: TelegramTargetOwnershipView;
286
+ ctx: TContext;
287
+ }) => Promise<boolean> | boolean;
288
+ forwardEditedMessage?: (input: {
289
+ message: TMessage;
290
+ ownership: TelegramTargetOwnershipView;
291
+ ctx: TContext;
292
+ }) => Promise<boolean> | boolean;
293
+ }
294
+
203
295
  export interface TelegramMessageReactionUpdated {
204
- chat: { type: string };
296
+ chat: { id?: number; type: string };
205
297
  user?: TelegramUser;
206
298
  message_id: number;
207
299
  old_reaction: TelegramReactionType[];
@@ -223,6 +315,10 @@ export type TelegramUpdateFlowAction<
223
315
  | { kind: "ignore" }
224
316
  | { kind: "deleted"; messageIds: number[] }
225
317
  | { kind: "reaction"; reactionUpdate: TReactionUpdate }
318
+ | {
319
+ kind: "topic-lifecycle";
320
+ lifecycle: TelegramTopicLifecycleUpdate<TMessage>;
321
+ }
226
322
  | {
227
323
  kind: "callback";
228
324
  query: TCallbackQuery;
@@ -262,7 +358,11 @@ export function buildTelegramUpdateFlowAction<
262
358
  if (update.message_reaction) {
263
359
  return { kind: "reaction", reactionUpdate: update.message_reaction };
264
360
  }
265
- const query = getAuthorizedTelegramCallbackQuery(update);
361
+ const topicLifecycle = getTelegramTopicLifecycleUpdate(update.message);
362
+ if (topicLifecycle) {
363
+ return { kind: "topic-lifecycle", lifecycle: topicLifecycle };
364
+ }
365
+ const query = getAuthorizedTelegramCallbackQuery(update, allowedUserId);
266
366
  if (query) {
267
367
  return {
268
368
  kind: "callback",
@@ -273,7 +373,7 @@ export function buildTelegramUpdateFlowAction<
273
373
  ),
274
374
  };
275
375
  }
276
- const message = getAuthorizedTelegramMessage(update);
376
+ const message = getAuthorizedTelegramMessage(update, allowedUserId);
277
377
  if (message?.from) {
278
378
  return {
279
379
  kind: "message",
@@ -286,7 +386,10 @@ export function buildTelegramUpdateFlowAction<
286
386
  ),
287
387
  };
288
388
  }
289
- const editedMessage = getAuthorizedTelegramEditedMessage(update);
389
+ const editedMessage = getAuthorizedTelegramEditedMessage(
390
+ update,
391
+ allowedUserId,
392
+ );
290
393
  if (editedMessage?.from) {
291
394
  return {
292
395
  kind: "edited-message",
@@ -330,6 +433,10 @@ export type TelegramUpdateExecutionPlan<
330
433
  kind: "reaction";
331
434
  reactionUpdate: TReactionUpdate;
332
435
  }
436
+ | {
437
+ kind: "topic-lifecycle";
438
+ lifecycle: TelegramTopicLifecycleUpdate<TMessage>;
439
+ }
333
440
  | {
334
441
  kind: "callback";
335
442
  query: TCallbackQuery;
@@ -380,6 +487,8 @@ export function buildTelegramUpdateExecutionPlan<
380
487
  return { kind: "deleted", messageIds: action.messageIds };
381
488
  case "reaction":
382
489
  return { kind: "reaction", reactionUpdate: action.reactionUpdate };
490
+ case "topic-lifecycle":
491
+ return { kind: "topic-lifecycle", lifecycle: action.lifecycle };
383
492
  case "callback":
384
493
  return {
385
494
  kind: "callback",
@@ -437,6 +546,15 @@ export interface TelegramUpdateRuntimeDeps<
437
546
  TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
438
547
  > {
439
548
  ctx: TContext;
549
+ getCurrentInstanceId?: () => string | undefined;
550
+ getMessageOwnership?: TelegramMessageOwnershipLookup;
551
+ getTargetOwnership?: TelegramTargetOwnershipLookup;
552
+ foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<
553
+ TContext,
554
+ TReactionUpdate,
555
+ TCallbackQuery,
556
+ TMessage
557
+ >;
440
558
  removePendingMediaGroupMessages: (messageIds: number[]) => void;
441
559
  removeQueuedTelegramTurnsByMessageIds: (
442
560
  messageIds: number[],
@@ -446,6 +564,10 @@ export interface TelegramUpdateRuntimeDeps<
446
564
  reactionUpdate: TReactionUpdate,
447
565
  ctx: TContext,
448
566
  ) => Promise<void>;
567
+ handleTelegramTopicLifecycleUpdate?: (
568
+ lifecycle: TelegramTopicLifecycleUpdate<TMessage>,
569
+ ctx: TContext,
570
+ ) => Promise<void> | void;
449
571
  pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
450
572
  answerCallbackQuery: (
451
573
  callbackQueryId: string,
@@ -460,6 +582,7 @@ export interface TelegramUpdateRuntimeDeps<
460
582
  chatId: number,
461
583
  replyToMessageId: number,
462
584
  text: string,
585
+ options?: { target?: { chatId: number; threadId?: number } },
463
586
  ) => Promise<number | undefined>;
464
587
  handleAuthorizedTelegramMessage: (
465
588
  message: TMessage,
@@ -473,6 +596,11 @@ export interface TelegramUpdateRuntimeDeps<
473
596
  guestMessage: TelegramGuestMessage & { from: TelegramUser },
474
597
  ctx: TContext,
475
598
  ) => Promise<void>;
599
+ /** Called when the owner writes in an unbound thread no live instance owns. */
600
+ handleUnboundTelegramTopicMessage?: (
601
+ message: TMessage & { from: TelegramUser },
602
+ ctx: TContext,
603
+ ) => Promise<void>;
476
604
  }
477
605
 
478
606
  export interface TelegramUpdateRuntimeControllerDeps<
@@ -481,19 +609,31 @@ export interface TelegramUpdateRuntimeControllerDeps<
481
609
  TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
482
610
  > {
483
611
  getAllowedUserId: () => number | undefined;
612
+ getCurrentInstanceId?: () => string | undefined;
613
+ getMessageOwnership?: TelegramMessageOwnershipLookup;
614
+ getTargetOwnership?: TelegramTargetOwnershipLookup;
615
+ foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<
616
+ TContext,
617
+ TelegramMessageReactionUpdated,
618
+ TCallbackQuery,
619
+ TMessage
620
+ >;
484
621
  removePendingMediaGroupMessages: (messageIds: number[]) => void;
485
622
  removeQueuedTelegramTurnsByMessageIds: (
486
623
  messageIds: number[],
487
624
  ctx: TContext,
625
+ scope?: { chatId?: number; threadId?: number },
488
626
  ) => number;
489
627
  clearQueuedTelegramTurnPriorityByMessageId: (
490
628
  messageId: number,
491
629
  ctx: TContext,
630
+ scope?: { chatId?: number; threadId?: number },
492
631
  ) => boolean;
493
632
  prioritizeQueuedTelegramTurnByMessageId: (
494
633
  messageId: number,
495
634
  ctx: TContext,
496
635
  priorityEmoji?: string,
636
+ scope?: { chatId?: number; threadId?: number },
497
637
  ) => boolean;
498
638
  pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
499
639
  answerCallbackQuery: (
@@ -509,6 +649,7 @@ export interface TelegramUpdateRuntimeControllerDeps<
509
649
  chatId: number,
510
650
  replyToMessageId: number,
511
651
  text: string,
652
+ options?: { target?: { chatId: number; threadId?: number } },
512
653
  ) => Promise<number | undefined>;
513
654
  handleAuthorizedTelegramMessage: (
514
655
  message: TMessage,
@@ -522,6 +663,15 @@ export interface TelegramUpdateRuntimeControllerDeps<
522
663
  guestMessage: TelegramGuestMessage & { from: TelegramUser },
523
664
  ctx: TContext,
524
665
  ) => Promise<void>;
666
+ handleTelegramTopicLifecycleUpdate?: (
667
+ lifecycle: TelegramTopicLifecycleUpdate<TMessage>,
668
+ ctx: TContext,
669
+ ) => Promise<void> | void;
670
+ /** Called when the owner writes in an unbound thread no live instance owns. */
671
+ handleUnboundTelegramTopicMessage?: (
672
+ message: TMessage & { from: TelegramUser },
673
+ ctx: TContext,
674
+ ) => Promise<void>;
525
675
  }
526
676
 
527
677
  export interface TelegramUpdateRuntimeController<
@@ -543,7 +693,7 @@ function getTelegramCallbackQueryId(
543
693
 
544
694
  function getTelegramMessageReplyTarget(
545
695
  message: TelegramUpdateMessage,
546
- ): { chatId: number; messageId: number } | undefined {
696
+ ): { chatId: number; messageId: number; threadId?: number } | undefined {
547
697
  if (
548
698
  typeof message.chat.id !== "number" ||
549
699
  typeof message.message_id !== "number"
@@ -553,9 +703,84 @@ function getTelegramMessageReplyTarget(
553
703
  return {
554
704
  chatId: message.chat.id,
555
705
  messageId: message.message_id,
706
+ ...(typeof message.message_thread_id === "number"
707
+ ? { threadId: message.message_thread_id }
708
+ : {}),
556
709
  };
557
710
  }
558
711
 
712
+ function getForeignTelegramMessageOwnership(
713
+ target: { chatId: number; messageId: number } | undefined,
714
+ deps: {
715
+ getCurrentInstanceId?: () => string | undefined;
716
+ getMessageOwnership?: TelegramMessageOwnershipLookup;
717
+ },
718
+ ): TelegramMessageOwnershipView | undefined {
719
+ if (!target || !deps.getMessageOwnership || !deps.getCurrentInstanceId) {
720
+ return undefined;
721
+ }
722
+ const currentInstanceId = deps.getCurrentInstanceId();
723
+ if (!currentInstanceId) return undefined;
724
+ const ownership = deps.getMessageOwnership(target.chatId, target.messageId);
725
+ return ownership && ownership.instanceId !== currentInstanceId
726
+ ? ownership
727
+ : undefined;
728
+ }
729
+
730
+ function getForeignTelegramCallbackOwnership(
731
+ query: TelegramCallbackQuery,
732
+ deps: {
733
+ getCurrentInstanceId?: () => string | undefined;
734
+ getMessageOwnership?: TelegramMessageOwnershipLookup;
735
+ getTargetOwnership?: TelegramTargetOwnershipLookup;
736
+ },
737
+ ): TelegramMessageOwnershipView | undefined {
738
+ return (
739
+ getForeignTelegramMessageOwnership(
740
+ getTelegramCallbackMessageTarget(query),
741
+ deps,
742
+ ) ??
743
+ getForeignTelegramTargetOwnership(
744
+ query.message ? getTelegramMessageTarget(query.message) : undefined,
745
+ deps,
746
+ )
747
+ );
748
+ }
749
+
750
+ function getTelegramCallbackMessageTarget(
751
+ query: TelegramCallbackQuery,
752
+ ): { chatId: number; messageId: number } | undefined {
753
+ return query.message
754
+ ? getTelegramMessageReplyTarget(query.message)
755
+ : undefined;
756
+ }
757
+
758
+ function getTelegramReactionMessageTarget(
759
+ reactionUpdate: TelegramMessageReactionUpdated,
760
+ ): { chatId: number; messageId: number } | undefined {
761
+ return typeof reactionUpdate.chat.id === "number"
762
+ ? { chatId: reactionUpdate.chat.id, messageId: reactionUpdate.message_id }
763
+ : undefined;
764
+ }
765
+
766
+ function getForeignTelegramTargetOwnership(
767
+ target: TelegramTarget | undefined,
768
+ deps: {
769
+ getCurrentInstanceId?: () => string | undefined;
770
+ getTargetOwnership?: TelegramTargetOwnershipLookup;
771
+ },
772
+ ): TelegramTargetOwnershipView | undefined {
773
+ if (!target || !deps.getTargetOwnership || !deps.getCurrentInstanceId) {
774
+ return undefined;
775
+ }
776
+ const currentInstanceId = deps.getCurrentInstanceId();
777
+ if (!currentInstanceId) return undefined;
778
+ const ownership = deps.getTargetOwnership(target);
779
+ return ownership && ownership.instanceId !== currentInstanceId
780
+ ? ownership
781
+ : undefined;
782
+ }
783
+
559
784
  export async function executeTelegramUpdate<
560
785
  TUpdate extends TelegramUpdateFlow,
561
786
  TContext = unknown,
@@ -596,6 +821,11 @@ export function createTelegramPairedUpdateRuntime<
596
821
  ): TelegramUpdateRuntimeController<TContext, TUpdate> {
597
822
  return createTelegramUpdateRuntime({
598
823
  getAllowedUserId: deps.getAllowedUserId,
824
+ getCurrentInstanceId: deps.getCurrentInstanceId,
825
+ getMessageOwnership: deps.getMessageOwnership,
826
+ getTargetOwnership: deps.getTargetOwnership,
827
+ handleTelegramTopicLifecycleUpdate: deps.handleTelegramTopicLifecycleUpdate,
828
+ foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
599
829
  removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
600
830
  removeQueuedTelegramTurnsByMessageIds:
601
831
  deps.removeQueuedTelegramTurnsByMessageIds,
@@ -619,6 +849,7 @@ export function createTelegramPairedUpdateRuntime<
619
849
  deps.handleAuthorizedTelegramEditedMessage,
620
850
  handleAuthorizedTelegramGuestMessage:
621
851
  deps.handleAuthorizedTelegramGuestMessage,
852
+ handleUnboundTelegramTopicMessage: deps.handleUnboundTelegramTopicMessage,
622
853
  });
623
854
  }
624
855
 
@@ -642,6 +873,9 @@ export function createTelegramUpdateRuntime<
642
873
  removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
643
874
  removeQueuedTelegramTurnsByMessageIds:
644
875
  deps.removeQueuedTelegramTurnsByMessageIds,
876
+ getCurrentInstanceId: deps.getCurrentInstanceId,
877
+ getMessageOwnership: deps.getMessageOwnership,
878
+ foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
645
879
  clearQueuedTelegramTurnPriorityByMessageId:
646
880
  deps.clearQueuedTelegramTurnPriorityByMessageId,
647
881
  prioritizeQueuedTelegramTurnByMessageId:
@@ -653,10 +887,16 @@ export function createTelegramUpdateRuntime<
653
887
  handleUpdate: (update, ctx) =>
654
888
  executeTelegramUpdate(update, deps.getAllowedUserId(), {
655
889
  ctx,
890
+ getCurrentInstanceId: deps.getCurrentInstanceId,
891
+ getMessageOwnership: deps.getMessageOwnership,
892
+ getTargetOwnership: deps.getTargetOwnership,
893
+ foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
656
894
  removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
657
895
  removeQueuedTelegramTurnsByMessageIds:
658
896
  deps.removeQueuedTelegramTurnsByMessageIds,
659
897
  handleAuthorizedTelegramReactionUpdate: handleAuthorizedReactionUpdate,
898
+ handleTelegramTopicLifecycleUpdate:
899
+ deps.handleTelegramTopicLifecycleUpdate,
660
900
  pairTelegramUserIfNeeded: deps.pairTelegramUserIfNeeded,
661
901
  answerCallbackQuery: deps.answerCallbackQuery,
662
902
  answerGuestQuery: deps.answerGuestQuery,
@@ -668,6 +908,8 @@ export function createTelegramUpdateRuntime<
668
908
  deps.handleAuthorizedTelegramEditedMessage,
669
909
  handleAuthorizedTelegramGuestMessage:
670
910
  deps.handleAuthorizedTelegramGuestMessage,
911
+ handleUnboundTelegramTopicMessage:
912
+ deps.handleUnboundTelegramTopicMessage,
671
913
  }),
672
914
  };
673
915
  }
@@ -675,19 +917,25 @@ export function createTelegramUpdateRuntime<
675
917
  export interface AuthorizedTelegramReactionUpdateDeps<TContext> {
676
918
  allowedUserId?: number;
677
919
  ctx: TContext;
920
+ getCurrentInstanceId?: () => string | undefined;
921
+ getMessageOwnership?: TelegramMessageOwnershipLookup;
922
+ foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<TContext>;
678
923
  removePendingMediaGroupMessages: (messageIds: number[]) => void;
679
924
  removeQueuedTelegramTurnsByMessageIds: (
680
925
  messageIds: number[],
681
926
  ctx: TContext,
927
+ scope?: { chatId?: number; threadId?: number },
682
928
  ) => number;
683
929
  clearQueuedTelegramTurnPriorityByMessageId: (
684
930
  messageId: number,
685
931
  ctx: TContext,
932
+ scope?: { chatId?: number; threadId?: number },
686
933
  ) => boolean;
687
934
  prioritizeQueuedTelegramTurnByMessageId: (
688
935
  messageId: number,
689
936
  ctx: TContext,
690
937
  priorityEmoji?: string,
938
+ scope?: { chatId?: number; threadId?: number },
691
939
  ) => boolean;
692
940
  }
693
941
 
@@ -695,15 +943,30 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
695
943
  reactionUpdate: TelegramMessageReactionUpdated,
696
944
  deps: AuthorizedTelegramReactionUpdateDeps<TContext>,
697
945
  ): Promise<void> {
946
+ const foreignOwnership = getForeignTelegramMessageOwnership(
947
+ getTelegramReactionMessageTarget(reactionUpdate),
948
+ deps,
949
+ );
950
+ if (foreignOwnership) {
951
+ await deps.foreignOwnedUpdateForwarder?.forwardReaction?.({
952
+ reactionUpdate,
953
+ ownership: foreignOwnership,
954
+ ctx: deps.ctx,
955
+ });
956
+ return;
957
+ }
698
958
  const reactionUser = reactionUpdate.user;
959
+ if (!reactionUser || reactionUser.is_bot) return;
699
960
  if (
700
- reactionUpdate.chat.type !== "private" ||
701
- !reactionUser ||
702
- reactionUser.is_bot ||
961
+ reactionUpdate.chat.type !== "private" &&
703
962
  reactionUser.id !== deps.allowedUserId
704
963
  ) {
705
964
  return;
706
965
  }
966
+ const reactionScope =
967
+ typeof reactionUpdate.chat.id === "number"
968
+ ? { chatId: reactionUpdate.chat.id }
969
+ : undefined;
707
970
  const oldEmojis = collectTelegramReactionEmojis(reactionUpdate.old_reaction);
708
971
  const newEmojis = collectTelegramReactionEmojis(reactionUpdate.new_reaction);
709
972
  if (
@@ -717,6 +980,7 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
717
980
  deps.removeQueuedTelegramTurnsByMessageIds(
718
981
  [reactionUpdate.message_id],
719
982
  deps.ctx,
983
+ reactionScope,
720
984
  );
721
985
  return;
722
986
  }
@@ -732,6 +996,7 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
732
996
  deps.clearQueuedTelegramTurnPriorityByMessageId(
733
997
  reactionUpdate.message_id,
734
998
  deps.ctx,
999
+ reactionScope,
735
1000
  );
736
1001
  }
737
1002
  const addedPriorityEmoji = getAddedTelegramReactionEmoji(
@@ -744,6 +1009,7 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
744
1009
  reactionUpdate.message_id,
745
1010
  deps.ctx,
746
1011
  addedPriorityEmoji,
1012
+ reactionScope,
747
1013
  );
748
1014
  }
749
1015
 
@@ -784,7 +1050,33 @@ export async function executeTelegramUpdatePlan<
784
1050
  );
785
1051
  return;
786
1052
  }
1053
+ if (plan.kind === "topic-lifecycle") {
1054
+ await deps.handleTelegramTopicLifecycleUpdate?.(plan.lifecycle, deps.ctx);
1055
+ return;
1056
+ }
787
1057
  if (plan.kind === "callback") {
1058
+ const foreignOwnership = getForeignTelegramCallbackOwnership(
1059
+ plan.query,
1060
+ deps,
1061
+ );
1062
+ if (foreignOwnership) {
1063
+ const forwarded =
1064
+ (await deps.foreignOwnedUpdateForwarder?.forwardCallback?.({
1065
+ query: plan.query,
1066
+ ownership: foreignOwnership,
1067
+ ctx: deps.ctx,
1068
+ })) ?? false;
1069
+ if (!forwarded) {
1070
+ const callbackQueryId = getTelegramCallbackQueryId(plan.query);
1071
+ if (callbackQueryId) {
1072
+ await deps.answerCallbackQuery(
1073
+ callbackQueryId,
1074
+ "This Telegram message belongs to another Pi instance.",
1075
+ );
1076
+ }
1077
+ }
1078
+ return;
1079
+ }
788
1080
  if (plan.shouldPair) {
789
1081
  await deps.pairTelegramUserIfNeeded(plan.query.from.id, deps.ctx);
790
1082
  }
@@ -817,6 +1109,35 @@ export async function executeTelegramUpdatePlan<
817
1109
  }
818
1110
  return;
819
1111
  }
1112
+ const foreignTargetOwnership = getForeignTelegramTargetOwnership(
1113
+ getTelegramMessageTarget(plan.message),
1114
+ deps,
1115
+ );
1116
+ if (foreignTargetOwnership) {
1117
+ if (plan.kind === "edited-message") {
1118
+ await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
1119
+ message: plan.message,
1120
+ ownership: foreignTargetOwnership,
1121
+ ctx: deps.ctx,
1122
+ });
1123
+ } else {
1124
+ await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
1125
+ message: plan.message,
1126
+ ownership: foreignTargetOwnership,
1127
+ ctx: deps.ctx,
1128
+ });
1129
+ }
1130
+ return;
1131
+ }
1132
+ const messageTarget = getTelegramMessageTarget(plan.message);
1133
+ if (
1134
+ plan.kind === "message" &&
1135
+ messageTarget?.threadId != null &&
1136
+ deps.handleUnboundTelegramTopicMessage
1137
+ ) {
1138
+ await deps.handleUnboundTelegramTopicMessage(plan.message, deps.ctx);
1139
+ return;
1140
+ }
820
1141
  const pairedNow = plan.shouldPair
821
1142
  ? await deps.pairTelegramUserIfNeeded(plan.message.from.id, deps.ctx)
822
1143
  : false;
@@ -831,6 +1152,7 @@ export async function executeTelegramUpdatePlan<
831
1152
  replyTarget.chatId,
832
1153
  replyTarget.messageId,
833
1154
  "Telegram bridge paired with this account.",
1155
+ { target: replyTarget },
834
1156
  );
835
1157
  }
836
1158
  if (plan.shouldDeny) {
@@ -839,6 +1161,7 @@ export async function executeTelegramUpdatePlan<
839
1161
  replyTarget.chatId,
840
1162
  replyTarget.messageId,
841
1163
  "This bot is not authorized for your account.",
1164
+ { target: replyTarget },
842
1165
  );
843
1166
  }
844
1167
  return;
@@ -953,7 +1276,7 @@ export function createTelegramUpdateHandle<TUpdate, TContext>(
953
1276
  ): (update: TUpdate, ctx: TContext) => Promise<void> {
954
1277
  const registry = deps.registry ?? getOrCreateUpdateHandlerRegistry();
955
1278
  const { defaultHandle } = deps;
956
- return async function handleTelegramUpdate(update, ctx) {
1279
+ return async (update, ctx) => {
957
1280
  const verdict = await registry.dispatch(update);
958
1281
  if (verdict === "consume") return;
959
1282
  await defaultHandle(update, ctx);