@llblab/pi-telegram 0.18.5 → 0.19.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/AGENTS.md +5 -4
- package/BACKLOG.md +5 -10
- package/CHANGELOG.md +23 -0
- package/README.md +176 -226
- package/docs/architecture.md +3 -3
- package/docs/multi-instance-bus.md +23 -2
- package/index.ts +35 -3
- package/lib/bindings.ts +3 -14
- package/lib/bus-follower.ts +26 -7
- package/lib/bus-leader.ts +152 -27
- package/lib/bus.ts +44 -0
- package/lib/config.ts +22 -0
- package/lib/menu-settings.ts +88 -0
- package/lib/routing.ts +5 -11
- package/lib/runtime.ts +23 -7
- package/lib/status.ts +2 -2
- package/lib/threads.ts +56 -2
- package/lib/updates.ts +44 -3
- package/package.json +2 -2
- /package/{screenshot.png → banner.png} +0 -0
|
@@ -184,7 +184,7 @@ Manual smoke checklist:
|
|
|
184
184
|
|
|
185
185
|
1. Enable Telegram private-chat Threaded Mode for the paired bot.
|
|
186
186
|
2. Start Pi in one Windows terminal and run `/telegram-connect`; verify it becomes the leader and gets a named Telegram thread.
|
|
187
|
-
3. Start Pi in a second Windows terminal and run `/telegram-connect`; verify it registers as follower rather than offering takeover, creates/uses its assigned thread,
|
|
187
|
+
3. Start Pi in a second Windows terminal and run `/telegram-connect`; verify it registers as follower rather than offering takeover, creates/uses its assigned thread, terminal status shows `<ThreadName> Follower` while idle, and a follower prompt flips it to `<ThreadName> Active` while work is running.
|
|
188
188
|
4. From the follower thread, send a prompt that requests inline buttons; tap a button and verify the follow-up prompt queues in the follower instance.
|
|
189
189
|
5. From the follower thread, request a voice reply and/or attachment; verify upload routes through the leader transport into the follower thread.
|
|
190
190
|
6. Close the follower terminal; verify heartbeat pruning, disconnected notice, and cleanup behavior match Unix-like behavior.
|
|
@@ -283,6 +283,27 @@ This preserves one API bus and one set of rate-limit/retry diagnostics. The curr
|
|
|
283
283
|
|
|
284
284
|
Every outbound request carries its target. The leader injects `message_thread_id` when `target.threadId` exists.
|
|
285
285
|
|
|
286
|
+
## Leader/Follower Capability Parity Matrix
|
|
287
|
+
|
|
288
|
+
Threaded Mode should make follower threads behave like normal Telegram instance surfaces, with the leader acting only as transport owner. Any feature in the matrix below that works for the leader must either work for followers or have an explicit documented exception.
|
|
289
|
+
|
|
290
|
+
| Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
|
|
291
|
+
| --- | --- | --- | --- | --- |
|
|
292
|
+
| Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
|
|
293
|
+
| Queued-message removal reactions | 👎/👻/💔/💩/🗑 removes pending prompt/media turn | Same reaction on a queued follower prompt removes that follower's pending turn before dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
|
|
294
|
+
| Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
|
|
295
|
+
| Message edits | Edits update matching queued prompt text | Edits in a follower thread update that follower's queued prompt | Message target ownership forwards edits to the owning instance; stored message ownership is the fallback when Telegram edit payloads omit thread id | Update routing tests for foreign target and message-owned edited-message forwarding |
|
|
296
|
+
| Callbacks/buttons/menus | Callback handled by the owning instance/menu state | Follower callbacks are forwarded to the owning follower; follower menu sends/edits/deletes route through leader transport | Leader records ownership for follower-sent Bot API messages so callbacks can route by message id even when Telegram omits thread id; Bot API edit/delete lacks thread id, so follower bus allows validated same-chat message operations | Callback forwarding, generated-button target, bus follower-sent ownership, and bus edit/delete allowlist tests |
|
|
297
|
+
| Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
|
|
298
|
+
| Previews/Rich Drafts | Draft previews use the active thread target | Follower previews use the same native draft lifecycle through the leader | Preview transport preserves target and draft id | Preview thread-target tests |
|
|
299
|
+
| Attachments/voice | Files and voice upload in the instance thread | Follower uploads route through leader multipart transport | Multipart calls are target-scoped and follower-authorized | Bus allowlist and outbound delivery tests |
|
|
300
|
+
| Native activity status | `sendChatAction(typing)` shows thread Active, mirrors aggregate `All`, and terminal status flips from role to `active` during work | Follower work sends one thread action and one aggregate action through leader transport, and terminal status follows the same idle-role → active transition as leaders | Typing loop targets the active turn and avoids duplicate aggregate sends/rate-limit pressure; status rendering gives processing labels precedence over stable bus role labels | Runtime typing loop starter and status bar parity regressions |
|
|
301
|
+
| Leader election / promotion | Current leader keeps its thread across reload | A promoted follower keeps its existing thread, slot, and name when elected and after later reload | Promotion converts the current follower binding into the leader profile before forced lock acquisition, so leader startup reuses it instead of provisioning a new thread | Follower heartbeat recovery passes binding snapshot into promotion; own-topic provisioner reuses promoted bindings |
|
|
302
|
+
| `/start` command/menu bootstrap | Registers visible bot commands and opens the menu | Follower `/start` can refresh the bot command menu through the leader and open its local menu without warnings | Bot command registration is a validated global Bot API call allowed through trusted follower bus transport | Bus allowlist regression for `setMyCommands` |
|
|
303
|
+
| Follower reconnect | Existing leader binding is reused only when still usable | Follower reconnect that points at a closed/stale Telegram tab recreates a visible thread before reporting success, and session replacement replaces any old same-profile/same-target registry entry | Same-profile reuse is probed with a connected notice; stale Bot API errors mark the old target stale and provision a fresh target; live follower target ownership never falls back to leader records | Bus leader stale reused follower-thread provisioner and bus registry/ownership regressions |
|
|
304
|
+
| Unbound thread reroute/restore | New unbound thread can route to a live instance or replace the selected instance thread | Same chooser exposes all currently live bus leader/follower targets; restore is offered from concrete unbound threads, not historical snapshots | Live bus roster plus active target bindings define the selectable set; history/state snapshots are not authority | Routing chooser regressions for live target filtering and restore rows |
|
|
305
|
+
| Status/menu diagnostics | Status reflects leader role, queue, and target | Follower status reflects follower role, thread name, queue, and bus health | Status is local runtime truth plus bus registration state, not leader queue state | Status and bus diagnostics tests |
|
|
306
|
+
|
|
286
307
|
## Queue And State Scoping
|
|
287
308
|
|
|
288
309
|
Each instance owns its own queue and active turn state. The leader does not become a central queue scheduler for all agents; that would be a separate daemon-mode architecture.
|
|
@@ -312,7 +333,7 @@ Typical config remains just bot identity and authorization:
|
|
|
312
333
|
Rules:
|
|
313
334
|
|
|
314
335
|
- Classic mode is selected by Telegram capability: when private-chat threads are unavailable or disabled, the polling owner uses ordinary single-DM behavior and blocked instances do not register as followers. During a live downgrade from Threaded Mode, the current bus leader becomes the classic polling owner after two 2.5-second capability-monitor probes and followers disconnect; if classic polling restore fails transiently, later monitor ticks retry the restore instead of allowing a follower takeover. Followers must not turn the downgrade into a takeover while active thread bindings prove the singleton owner was already established by the bus leader.
|
|
315
|
-
- Telegram private-chat Threaded Mode enables local leader/follower behavior automatically. The leader owns `getUpdates`; registered followers route Telegram API work through the leader. `/telegram-connect` registers as follower when a live leader exists and does not offer manual takeover in that state. The TUI status bar reports `telegram leader` or `telegram follower` so transport role is visible without opening diagnostics.
|
|
336
|
+
- Telegram private-chat Threaded Mode enables local leader/follower behavior automatically. The leader owns `getUpdates`; registered followers route Telegram API work through the leader. `/telegram-connect` registers as follower when a live leader exists and does not offer manual takeover in that state. The TUI status bar reports `telegram leader` or `telegram follower` while idle so transport role is visible without opening diagnostics, and both roles switch to `active`/`compacting` processing labels during local Telegram work. Follower registration is unique by live profile/target: a reload or session replacement must replace stale registry entries rather than leaving multiple routable ids for one Telegram thread, and fallback target ownership must not classify leader records as followers.
|
|
316
337
|
- The thread chat is the owner's private bot DM (`allowedUserId`); no `topics.chatId` config is needed. Thread names are assigned by the bridge from a baked compact per-slot palette. There is no agent-facing `telegram_rename_thread` tool and no separate user-facing slash command for manual thread renames.
|
|
317
338
|
- Thread reuse is extension-owned through current live binding identity; there is no separate `topics` config surface in the active private-chat thread model. Manual followers use instance-scoped internal keys by default so multiple terminal processes in the same cwd can receive separate threads.
|
|
318
339
|
- Thread cleanup remains conservative and centralized: destructive close/delete actions are planned and applied through `thread-reconciler` with proof-before-delete checks, leader-epoch fencing, and retry-preserving failure semantics.
|
package/index.ts
CHANGED
|
@@ -487,6 +487,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
487
487
|
getMessageText: Replies.getAgentMessageText,
|
|
488
488
|
getDefaultReplyToMessageId: activeTurnRuntime.getReplyToMessageId,
|
|
489
489
|
sendDraft: nativeMarkdownDraftSender,
|
|
490
|
+
canSend: configControls.areRichDraftPreviewsEnabled,
|
|
490
491
|
sendMarkdownReply,
|
|
491
492
|
recordRuntimeEvent,
|
|
492
493
|
...replyTransport,
|
|
@@ -662,6 +663,9 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
662
663
|
return telegramInstanceId;
|
|
663
664
|
},
|
|
664
665
|
getMessageOwnership: messageOwnershipStore.get,
|
|
666
|
+
recordMessageOwnership(input) {
|
|
667
|
+
messageOwnershipStore.record(input);
|
|
668
|
+
},
|
|
665
669
|
getTargetOwnership(target) {
|
|
666
670
|
return Bus.getTelegramFollowerTargetOwnership({
|
|
667
671
|
target,
|
|
@@ -821,7 +825,29 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
821
825
|
telegramBusLifecycleOverridePhase = phase;
|
|
822
826
|
},
|
|
823
827
|
updateStatus,
|
|
824
|
-
async promoteToLeader(ctx) {
|
|
828
|
+
async promoteToLeader(ctx, binding) {
|
|
829
|
+
const promotedRecord =
|
|
830
|
+
await Threads.promoteTelegramFollowerBindingToLeader({
|
|
831
|
+
store: threadStore,
|
|
832
|
+
instanceId: telegramInstanceId,
|
|
833
|
+
cwd: ctx.cwd,
|
|
834
|
+
target: binding.target,
|
|
835
|
+
slot: binding.slot,
|
|
836
|
+
threadName: binding.threadName,
|
|
837
|
+
});
|
|
838
|
+
if (promotedRecord) {
|
|
839
|
+
recordRuntimeEvent(
|
|
840
|
+
"bus",
|
|
841
|
+
"Follower thread binding promoted to leader",
|
|
842
|
+
{
|
|
843
|
+
phase: "follower-promoted-binding",
|
|
844
|
+
chatId: promotedRecord.target.chatId,
|
|
845
|
+
threadId: promotedRecord.target.threadId,
|
|
846
|
+
slot: promotedRecord.slot,
|
|
847
|
+
threadName: promotedRecord.threadName,
|
|
848
|
+
},
|
|
849
|
+
);
|
|
850
|
+
}
|
|
825
851
|
await lockedPollingRuntime.start(ctx, { force: true });
|
|
826
852
|
},
|
|
827
853
|
sleep(ms) {
|
|
@@ -898,6 +924,14 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
898
924
|
startPolling: pollingRuntime.start,
|
|
899
925
|
stopPolling: pollingRuntime.stop,
|
|
900
926
|
authorizeFollowerApiCall: Bus.isTelegramFollowerApiCallAllowed,
|
|
927
|
+
recordFollowerMessageOwnership(record) {
|
|
928
|
+
messageOwnershipStore.record({
|
|
929
|
+
chatId: record.chatId,
|
|
930
|
+
messageId: record.messageId,
|
|
931
|
+
target: record.target,
|
|
932
|
+
instanceId: record.follower.instanceId,
|
|
933
|
+
});
|
|
934
|
+
},
|
|
901
935
|
provisionLeaderTarget:
|
|
902
936
|
BusLeader.createTelegramBusLeaderTargetProvisioner<Pi.ExtensionContext>({
|
|
903
937
|
getAllowedUserId: configStore.getAllowedUserId,
|
|
@@ -1273,8 +1307,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1273
1307
|
sendRecordVoiceAction,
|
|
1274
1308
|
sendMarkdownReply,
|
|
1275
1309
|
sendTextReply,
|
|
1276
|
-
editInteractiveMessage,
|
|
1277
|
-
deleteMessage: deleteTelegramMessage,
|
|
1278
1310
|
dispatchNextQueuedTelegramTurn,
|
|
1279
1311
|
answerGuestQuery,
|
|
1280
1312
|
sendGuestReply,
|
package/lib/bindings.ts
CHANGED
|
@@ -21,7 +21,6 @@ import * as Replies from "./replies.ts";
|
|
|
21
21
|
import * as Runtime from "./runtime.ts";
|
|
22
22
|
import * as Setup from "./setup.ts";
|
|
23
23
|
import * as Status from "./status.ts";
|
|
24
|
-
import * as Target from "./target.ts";
|
|
25
24
|
import * as TelegramApi from "./telegram-api.ts";
|
|
26
25
|
|
|
27
26
|
type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
|
|
@@ -166,14 +165,6 @@ interface TelegramLifecycleBindingDeps {
|
|
|
166
165
|
Keyboard.TelegramInlineKeyboardMarkup
|
|
167
166
|
>["sendTextReply"] &
|
|
168
167
|
NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendTextReply"]>;
|
|
169
|
-
editInteractiveMessage: (
|
|
170
|
-
chatId: number,
|
|
171
|
-
messageId: number,
|
|
172
|
-
text: string,
|
|
173
|
-
mode: "html" | "markdown",
|
|
174
|
-
replyMarkup: Keyboard.TelegramInlineKeyboardMarkup,
|
|
175
|
-
) => Promise<void>;
|
|
176
|
-
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
|
|
177
168
|
dispatchNextQueuedTelegramTurn: (ctx: Pi.ExtensionContext) => void;
|
|
178
169
|
answerGuestQuery: NonNullable<
|
|
179
170
|
Queue.TelegramAgentEndHookRuntimeDeps<
|
|
@@ -225,8 +216,6 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
225
216
|
sendRecordVoiceAction,
|
|
226
217
|
sendMarkdownReply,
|
|
227
218
|
sendTextReply,
|
|
228
|
-
editInteractiveMessage,
|
|
229
|
-
deleteMessage,
|
|
230
219
|
dispatchNextQueuedTelegramTurn,
|
|
231
220
|
answerGuestQuery,
|
|
232
221
|
sendGuestReply,
|
|
@@ -367,12 +356,12 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
367
356
|
onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
|
|
368
357
|
onSessionCompact: compactionObserver.onSessionCompact,
|
|
369
358
|
onAgentStart: agentStartWithDedupReset,
|
|
370
|
-
async onToolExecutionStart(
|
|
359
|
+
async onToolExecutionStart(_event, _ctx) {
|
|
371
360
|
agentLifecycleHooks.onToolExecutionStart();
|
|
372
361
|
},
|
|
373
362
|
onToolExecutionUpdate() {},
|
|
374
|
-
async onToolExecutionEnd(
|
|
375
|
-
agentLifecycleHooks.onToolExecutionEnd(
|
|
363
|
+
async onToolExecutionEnd(_event, ctx) {
|
|
364
|
+
agentLifecycleHooks.onToolExecutionEnd(_event, ctx);
|
|
376
365
|
},
|
|
377
366
|
onAgentEnd: agentLifecycleHooks.onAgentEnd,
|
|
378
367
|
onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
|
package/lib/bus-follower.ts
CHANGED
|
@@ -203,16 +203,25 @@ export interface TelegramBusFollowerLeaderLock {
|
|
|
203
203
|
busSecret?: string;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
export interface TelegramBusFollowerPromotedBinding {
|
|
207
|
+
target?: TelegramTarget;
|
|
208
|
+
slot?: string;
|
|
209
|
+
threadName?: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
206
212
|
export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
|
|
207
213
|
registrationState: Pick<
|
|
208
214
|
TelegramBusFollowerRegistrationState,
|
|
209
|
-
"setRegistered"
|
|
215
|
+
"getTarget" | "getSlot" | "getThreadName" | "setRegistered"
|
|
210
216
|
>;
|
|
211
217
|
getRegistrationRuntime: () => TelegramBusFollowerRegistrationRuntime<TContext>;
|
|
212
218
|
getLeaderState: () => TelegramBusFollowerLeaderState;
|
|
213
219
|
setLifecyclePhase: (phase: "electing" | undefined) => void;
|
|
214
220
|
updateStatus: (ctx: TContext) => void;
|
|
215
|
-
promoteToLeader: (
|
|
221
|
+
promoteToLeader: (
|
|
222
|
+
ctx: TContext,
|
|
223
|
+
binding: TelegramBusFollowerPromotedBinding,
|
|
224
|
+
) => Promise<void> | void;
|
|
216
225
|
sleep: (ms: number) => Promise<void>;
|
|
217
226
|
promotionGraceMs: number;
|
|
218
227
|
recordRuntimeEvent: (
|
|
@@ -511,7 +520,16 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
511
520
|
return false;
|
|
512
521
|
}
|
|
513
522
|
};
|
|
514
|
-
const
|
|
523
|
+
const snapshotBinding = (): TelegramBusFollowerPromotedBinding => ({
|
|
524
|
+
target: deps.registrationState.getTarget(),
|
|
525
|
+
slot: deps.registrationState.getSlot(),
|
|
526
|
+
threadName: deps.registrationState.getThreadName(),
|
|
527
|
+
});
|
|
528
|
+
const promoteToLeader = async (
|
|
529
|
+
reason: unknown,
|
|
530
|
+
ctx: TContext,
|
|
531
|
+
binding = snapshotBinding(),
|
|
532
|
+
) => {
|
|
515
533
|
deps.setLifecyclePhase("electing");
|
|
516
534
|
safeUpdateStatus(ctx);
|
|
517
535
|
deps.recordRuntimeEvent("bus", reason, {
|
|
@@ -523,7 +541,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
523
541
|
deps.recordRuntimeEvent("bus", "Telegram follower elected for promotion", {
|
|
524
542
|
phase: "follower-promotion-electing",
|
|
525
543
|
});
|
|
526
|
-
await deps.promoteToLeader(ctx);
|
|
544
|
+
await deps.promoteToLeader(ctx, binding);
|
|
527
545
|
deps.setLifecyclePhase(undefined);
|
|
528
546
|
safeUpdateStatus(ctx);
|
|
529
547
|
deps.recordRuntimeEvent("bus", "Telegram follower promotion completed", {
|
|
@@ -534,6 +552,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
534
552
|
if (promotionPending) return;
|
|
535
553
|
promotionPending = true;
|
|
536
554
|
try {
|
|
555
|
+
const initialBinding = snapshotBinding();
|
|
537
556
|
const state = deps.getLeaderState();
|
|
538
557
|
if (state.kind === "active-elsewhere") {
|
|
539
558
|
clearRegisteredState(ctx);
|
|
@@ -565,16 +584,16 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
565
584
|
) {
|
|
566
585
|
return;
|
|
567
586
|
}
|
|
568
|
-
await promoteToLeader(error, ctx);
|
|
587
|
+
await promoteToLeader(error, ctx, initialBinding);
|
|
569
588
|
return;
|
|
570
589
|
}
|
|
571
590
|
if (graceState.kind === "stale" || graceState.kind === "inactive") {
|
|
572
|
-
await promoteToLeader(error, ctx);
|
|
591
|
+
await promoteToLeader(error, ctx, initialBinding);
|
|
573
592
|
}
|
|
574
593
|
return;
|
|
575
594
|
}
|
|
576
595
|
if (state.kind === "stale" || state.kind === "inactive") {
|
|
577
|
-
await promoteToLeader(error, ctx);
|
|
596
|
+
await promoteToLeader(error, ctx, initialBinding);
|
|
578
597
|
}
|
|
579
598
|
} catch (promotionError) {
|
|
580
599
|
deps.setLifecyclePhase(undefined);
|
package/lib/bus-leader.ts
CHANGED
|
@@ -163,6 +163,17 @@ export interface TelegramBusFollowerRegistryRestoreDeps {
|
|
|
163
163
|
getNowMs?: () => number;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
export interface TelegramBusFollowerMessageOwnershipRecord {
|
|
167
|
+
follower: TelegramBusFollowerView;
|
|
168
|
+
chatId: number;
|
|
169
|
+
messageId: number;
|
|
170
|
+
target?: TelegramTarget;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type TelegramBusFollowerMessageOwnershipRecorder = (
|
|
174
|
+
record: TelegramBusFollowerMessageOwnershipRecord,
|
|
175
|
+
) => void;
|
|
176
|
+
|
|
166
177
|
export interface TelegramBusLeaderRuntimeDeps<TContext> {
|
|
167
178
|
socketPath: string;
|
|
168
179
|
followerRegistry: TelegramBusFollowerRegistry;
|
|
@@ -175,6 +186,7 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
|
|
|
175
186
|
method: string;
|
|
176
187
|
args: unknown[];
|
|
177
188
|
}) => boolean;
|
|
189
|
+
recordFollowerMessageOwnership?: TelegramBusFollowerMessageOwnershipRecorder;
|
|
178
190
|
provisionFollowerTarget?: (
|
|
179
191
|
registration: TelegramBusInstanceRegistration,
|
|
180
192
|
) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
|
|
@@ -325,30 +337,74 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
325
337
|
registration.profileKey ?? `manual:${registration.instanceId}`;
|
|
326
338
|
const followerOwner =
|
|
327
339
|
Threads.getTelegramThreadOwnerFromProfileKey(followerProfileKey);
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
340
|
+
const provisionTarget = async () => {
|
|
341
|
+
deps.onProvisioningStart?.();
|
|
342
|
+
try {
|
|
343
|
+
return await provision({
|
|
344
|
+
instanceId: registration.instanceId,
|
|
345
|
+
owner:
|
|
346
|
+
followerOwner.kind === "manual-follower"
|
|
347
|
+
? followerOwner
|
|
348
|
+
: {
|
|
349
|
+
kind: "manual-follower",
|
|
350
|
+
instanceId: registration.instanceId,
|
|
351
|
+
},
|
|
352
|
+
profileKey: followerProfileKey,
|
|
353
|
+
threadName: registration.threadName,
|
|
354
|
+
});
|
|
355
|
+
} finally {
|
|
356
|
+
deps.onProvisioningEnd?.();
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
let result = await provisionTarget();
|
|
346
360
|
deps.setSyncState(
|
|
347
361
|
Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
|
|
348
362
|
nowMs: getNowMs(),
|
|
349
363
|
action: "follower-register",
|
|
350
364
|
}),
|
|
351
365
|
);
|
|
366
|
+
let connectedAnnouncement =
|
|
367
|
+
createTelegramBusInstanceLifecycleAnnouncement({
|
|
368
|
+
target: result.target,
|
|
369
|
+
threadName: result.record.threadName,
|
|
370
|
+
slot: result.record.slot,
|
|
371
|
+
state: "connected",
|
|
372
|
+
});
|
|
373
|
+
let connectedAnnouncementSent = false;
|
|
374
|
+
if (result.reused && connectedAnnouncement) {
|
|
375
|
+
try {
|
|
376
|
+
await deps.callApi("sendMessage", {
|
|
377
|
+
chat_id: connectedAnnouncement.target.chatId,
|
|
378
|
+
message_thread_id: connectedAnnouncement.target.threadId,
|
|
379
|
+
text: connectedAnnouncement.text,
|
|
380
|
+
parse_mode: connectedAnnouncement.parseMode,
|
|
381
|
+
});
|
|
382
|
+
connectedAnnouncementSent = true;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
deps.recordRuntimeEvent("telegram", error, {
|
|
385
|
+
phase: "follower-topic-reuse-probe",
|
|
386
|
+
instanceId: registration.instanceId,
|
|
387
|
+
chatId: result.target.chatId,
|
|
388
|
+
threadId: result.target.threadId,
|
|
389
|
+
});
|
|
390
|
+
if (Threads.isTelegramTopicTargetStaleError(error)) {
|
|
391
|
+
deps.topicTargetStore.markStaleByTarget(
|
|
392
|
+
result.target,
|
|
393
|
+
"deleted",
|
|
394
|
+
"Follower registration found the reusable thread target stale.",
|
|
395
|
+
);
|
|
396
|
+
await deps.topicTargetStore.persist();
|
|
397
|
+
result = await provisionTarget();
|
|
398
|
+
connectedAnnouncement = createTelegramBusInstanceLifecycleAnnouncement({
|
|
399
|
+
target: result.target,
|
|
400
|
+
threadName: result.record.threadName,
|
|
401
|
+
slot: result.record.slot,
|
|
402
|
+
state: "connected",
|
|
403
|
+
});
|
|
404
|
+
connectedAnnouncementSent = false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
352
408
|
recordSlowTelegramBusFollowerRegistrationStep(deps, {
|
|
353
409
|
phase: "follower-register-critical",
|
|
354
410
|
elapsedMs: Date.now() - registrationStartedAtMs,
|
|
@@ -356,16 +412,9 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
356
412
|
target: result.target,
|
|
357
413
|
reused: result.reused,
|
|
358
414
|
});
|
|
359
|
-
const connectedAnnouncement =
|
|
360
|
-
createTelegramBusInstanceLifecycleAnnouncement({
|
|
361
|
-
target: result.target,
|
|
362
|
-
threadName: result.record.threadName,
|
|
363
|
-
slot: result.record.slot,
|
|
364
|
-
state: "connected",
|
|
365
|
-
});
|
|
366
415
|
scheduleTelegramBusLeaderBackgroundTask(async () => {
|
|
367
416
|
const backgroundStartedAtMs = Date.now();
|
|
368
|
-
if (connectedAnnouncement) {
|
|
417
|
+
if (connectedAnnouncement && !connectedAnnouncementSent) {
|
|
369
418
|
try {
|
|
370
419
|
await deps.callApi("sendMessage", {
|
|
371
420
|
chat_id: connectedAnnouncement.target.chatId,
|
|
@@ -579,6 +628,7 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
|
|
|
579
628
|
method: string;
|
|
580
629
|
args: unknown[];
|
|
581
630
|
}) => boolean;
|
|
631
|
+
recordFollowerMessageOwnership?: TelegramBusFollowerMessageOwnershipRecorder;
|
|
582
632
|
provisionFollowerTarget?: (
|
|
583
633
|
registration: TelegramBusInstanceRegistration,
|
|
584
634
|
) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
|
|
@@ -706,6 +756,72 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
|
|
|
706
756
|
};
|
|
707
757
|
}
|
|
708
758
|
|
|
759
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
760
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
761
|
+
? (value as Record<string, unknown>)
|
|
762
|
+
: undefined;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function asInteger(value: unknown): number | undefined {
|
|
766
|
+
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
767
|
+
if (typeof value !== "string" || value.trim() === "") return undefined;
|
|
768
|
+
const parsed = Number(value);
|
|
769
|
+
return Number.isInteger(parsed) ? parsed : undefined;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function getFollowerApiMethodAndBody(envelope: Extract<TelegramBusEnvelope, { kind: "follower.callApi" }>): {
|
|
773
|
+
apiMethod: string;
|
|
774
|
+
body?: Record<string, unknown>;
|
|
775
|
+
} {
|
|
776
|
+
if (envelope.method === "call" || envelope.method === "callMultipart") {
|
|
777
|
+
return {
|
|
778
|
+
apiMethod:
|
|
779
|
+
typeof envelope.args[0] === "string" ? envelope.args[0] : "",
|
|
780
|
+
body: asRecord(envelope.args[1]),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
return { apiMethod: envelope.method, body: asRecord(envelope.args[0]) };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function getSentMessageIds(result: unknown): number[] {
|
|
787
|
+
const values = Array.isArray(result) ? result : [result];
|
|
788
|
+
return values
|
|
789
|
+
.map((value) => asInteger(asRecord(value)?.message_id))
|
|
790
|
+
.filter((messageId): messageId is number => messageId !== undefined);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function recordFollowerApiMessageOwnership(input: {
|
|
794
|
+
envelope: Extract<TelegramBusEnvelope, { kind: "follower.callApi" }>;
|
|
795
|
+
follower: TelegramBusFollowerView;
|
|
796
|
+
result: unknown;
|
|
797
|
+
record?: TelegramBusFollowerMessageOwnershipRecorder;
|
|
798
|
+
}): void {
|
|
799
|
+
if (!input.record) return;
|
|
800
|
+
const { apiMethod, body } = getFollowerApiMethodAndBody(input.envelope);
|
|
801
|
+
if (
|
|
802
|
+
apiMethod !== "sendMessage" &&
|
|
803
|
+
apiMethod !== "sendRichMessage" &&
|
|
804
|
+
apiMethod !== "sendPhoto" &&
|
|
805
|
+
apiMethod !== "sendDocument" &&
|
|
806
|
+
apiMethod !== "sendVoice" &&
|
|
807
|
+
apiMethod !== "sendMediaGroup"
|
|
808
|
+
) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const chatId = asInteger(body?.chat_id) ?? input.follower.target?.chatId;
|
|
812
|
+
if (chatId === undefined) return;
|
|
813
|
+
const threadId = asInteger(body?.message_thread_id) ?? input.follower.target?.threadId;
|
|
814
|
+
const target = threadId !== undefined ? { chatId, threadId } : { chatId };
|
|
815
|
+
for (const messageId of getSentMessageIds(input.result)) {
|
|
816
|
+
input.record({
|
|
817
|
+
follower: input.follower,
|
|
818
|
+
chatId,
|
|
819
|
+
messageId,
|
|
820
|
+
target,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
709
825
|
async function handleFollowerApiCall(
|
|
710
826
|
envelope: Extract<TelegramBusEnvelope, { kind: "follower.callApi" }>,
|
|
711
827
|
deps: {
|
|
@@ -717,6 +833,7 @@ async function handleFollowerApiCall(
|
|
|
717
833
|
method: string;
|
|
718
834
|
args: unknown[];
|
|
719
835
|
}) => boolean;
|
|
836
|
+
recordFollowerMessageOwnership?: TelegramBusFollowerMessageOwnershipRecorder;
|
|
720
837
|
},
|
|
721
838
|
): Promise<TelegramBusEnvelope> {
|
|
722
839
|
const follower = deps.followerRegistry.get(envelope.instanceId);
|
|
@@ -753,11 +870,18 @@ async function handleFollowerApiCall(
|
|
|
753
870
|
};
|
|
754
871
|
}
|
|
755
872
|
try {
|
|
873
|
+
const result = await deps.callApi(envelope.method, envelope.args);
|
|
874
|
+
recordFollowerApiMessageOwnership({
|
|
875
|
+
envelope,
|
|
876
|
+
follower,
|
|
877
|
+
result,
|
|
878
|
+
record: deps.recordFollowerMessageOwnership,
|
|
879
|
+
});
|
|
756
880
|
return {
|
|
757
881
|
kind: "bus.ack",
|
|
758
882
|
requestId: envelope.requestId,
|
|
759
883
|
ok: true,
|
|
760
|
-
result
|
|
884
|
+
result,
|
|
761
885
|
};
|
|
762
886
|
} catch (error) {
|
|
763
887
|
return {
|
|
@@ -882,6 +1006,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
|
|
|
882
1006
|
getNowMs,
|
|
883
1007
|
callApi: deps.callApi,
|
|
884
1008
|
authorizeFollowerApiCall: deps.authorizeFollowerApiCall,
|
|
1009
|
+
recordFollowerMessageOwnership: deps.recordFollowerMessageOwnership,
|
|
885
1010
|
provisionFollowerTarget: deps.provisionFollowerTarget,
|
|
886
1011
|
}),
|
|
887
1012
|
});
|
package/lib/bus.ts
CHANGED
|
@@ -79,6 +79,8 @@ export function getTelegramFollowerTargetOwnership(input: {
|
|
|
79
79
|
activeThreadRecords?: Array<{
|
|
80
80
|
status?: string;
|
|
81
81
|
instanceId?: string;
|
|
82
|
+
profileKey?: string;
|
|
83
|
+
owner?: { kind?: string };
|
|
82
84
|
target: TelegramTarget;
|
|
83
85
|
}>;
|
|
84
86
|
currentInstanceId?: string;
|
|
@@ -91,7 +93,11 @@ export function getTelegramFollowerTargetOwnership(input: {
|
|
|
91
93
|
});
|
|
92
94
|
if (liveFollower) return { instanceId: liveFollower.instanceId };
|
|
93
95
|
const record = input.activeThreadRecords?.find((candidate) => {
|
|
96
|
+
const isFollowerRecord = candidate.owner?.kind
|
|
97
|
+
? candidate.owner.kind === "manual-follower"
|
|
98
|
+
: candidate.profileKey?.startsWith("manual:") === true;
|
|
94
99
|
return (
|
|
100
|
+
isFollowerRecord &&
|
|
95
101
|
candidate.status === "active" &&
|
|
96
102
|
candidate.instanceId &&
|
|
97
103
|
candidate.instanceId !== input.currentInstanceId &&
|
|
@@ -144,6 +150,28 @@ export function isTelegramFollowerApiCallAllowed(input: {
|
|
|
144
150
|
const record = body as Record<string, unknown>;
|
|
145
151
|
return matchesId(record.chat_id, target.chatId);
|
|
146
152
|
};
|
|
153
|
+
const isTargetMessageScoped = (body: unknown): boolean => {
|
|
154
|
+
if (!isTargetChatScoped(body)) return false;
|
|
155
|
+
const messageId = (body as Record<string, unknown>).message_id;
|
|
156
|
+
const parsedMessageId =
|
|
157
|
+
typeof messageId === "number" ? messageId : Number(messageId);
|
|
158
|
+
return Number.isInteger(parsedMessageId) && matchesId(messageId, parsedMessageId);
|
|
159
|
+
};
|
|
160
|
+
const isBotCommandRegistration = (body: unknown): boolean => {
|
|
161
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
|
162
|
+
const commands = (body as Record<string, unknown>).commands;
|
|
163
|
+
return (
|
|
164
|
+
Array.isArray(commands) &&
|
|
165
|
+
commands.every(
|
|
166
|
+
(command) =>
|
|
167
|
+
command &&
|
|
168
|
+
typeof command === "object" &&
|
|
169
|
+
!Array.isArray(command) &&
|
|
170
|
+
typeof (command as Record<string, unknown>).command === "string" &&
|
|
171
|
+
typeof (command as Record<string, unknown>).description === "string",
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
};
|
|
147
175
|
if (input.method === "downloadFile") return true;
|
|
148
176
|
if (input.method === "call") {
|
|
149
177
|
const apiMethod = input.args[0];
|
|
@@ -155,7 +183,12 @@ export function isTelegramFollowerApiCallAllowed(input: {
|
|
|
155
183
|
return true;
|
|
156
184
|
}
|
|
157
185
|
if (apiMethod === "getMe") return true;
|
|
186
|
+
if (apiMethod === "setMyCommands")
|
|
187
|
+
return isBotCommandRegistration(input.args[1]);
|
|
158
188
|
if (apiMethod === "sendChatAction") return isTargetChatScoped(input.args[1]);
|
|
189
|
+
if (apiMethod === "deleteMessage" || apiMethod === "editMessageText") {
|
|
190
|
+
return isTargetMessageScoped(input.args[1]);
|
|
191
|
+
}
|
|
159
192
|
return allowedCallMethods.has(apiMethod) && isTargetScoped(input.args[1]);
|
|
160
193
|
}
|
|
161
194
|
if (input.method === "callMultipart") {
|
|
@@ -728,6 +761,17 @@ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry
|
|
|
728
761
|
return {
|
|
729
762
|
register: (registration) => {
|
|
730
763
|
const existing = followers.get(registration.instanceId);
|
|
764
|
+
for (const [instanceId, follower] of followers.entries()) {
|
|
765
|
+
if (instanceId === registration.instanceId) continue;
|
|
766
|
+
const sameProfile =
|
|
767
|
+
registration.profileKey !== undefined &&
|
|
768
|
+
registration.profileKey === follower.profileKey;
|
|
769
|
+
const sameTarget =
|
|
770
|
+
registration.target !== undefined &&
|
|
771
|
+
follower.target?.chatId === registration.target.chatId &&
|
|
772
|
+
follower.target.threadId === registration.target.threadId;
|
|
773
|
+
if (sameProfile || sameTarget) followers.delete(instanceId);
|
|
774
|
+
}
|
|
731
775
|
const next: TelegramBusFollowerView = {
|
|
732
776
|
...registration,
|
|
733
777
|
target: registration.target ? { ...registration.target } : undefined,
|
package/lib/config.ts
CHANGED
|
@@ -57,6 +57,7 @@ export interface TelegramConfig {
|
|
|
57
57
|
attachmentHandlers?: TelegramInboundHandlerConfig[];
|
|
58
58
|
outboundHandlers?: TelegramOutboundHandlerConfig[];
|
|
59
59
|
proactivePush?: boolean;
|
|
60
|
+
richDraftPreviews?: boolean;
|
|
60
61
|
voice?: {
|
|
61
62
|
replyMode?: "manual" | "mirror" | "always";
|
|
62
63
|
/** Whether to attach the provider's transcriptText as caption on voice messages */
|
|
@@ -259,6 +260,23 @@ export function createTelegramProactivePushSetter(
|
|
|
259
260
|
};
|
|
260
261
|
}
|
|
261
262
|
|
|
263
|
+
export function createTelegramRichDraftPreviewsChecker(
|
|
264
|
+
configStore: Pick<TelegramConfigStore, "get">,
|
|
265
|
+
): () => boolean {
|
|
266
|
+
return () => configStore.get().richDraftPreviews ?? false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function createTelegramRichDraftPreviewsSetter(
|
|
270
|
+
configStore: TelegramMutableConfigStore,
|
|
271
|
+
): (enabled: boolean) => Promise<void> {
|
|
272
|
+
return async (enabled) => {
|
|
273
|
+
await loadLatestTelegramConfig(configStore);
|
|
274
|
+
const config = { ...configStore.get(), richDraftPreviews: enabled };
|
|
275
|
+
configStore.set(config);
|
|
276
|
+
await configStore.persist(config);
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
262
280
|
export function createTelegramVoiceReplyModeGetter(
|
|
263
281
|
configStore: Pick<TelegramConfigStore, "get">,
|
|
264
282
|
): () => "manual" | "mirror" | "always" {
|
|
@@ -394,6 +412,10 @@ export function createTelegramConfigControls(
|
|
|
394
412
|
return {
|
|
395
413
|
isProactivePushEnabled: createTelegramProactivePushChecker(configStore),
|
|
396
414
|
setProactivePushEnabled: createTelegramProactivePushSetter(configStore),
|
|
415
|
+
areRichDraftPreviewsEnabled:
|
|
416
|
+
createTelegramRichDraftPreviewsChecker(configStore),
|
|
417
|
+
setRichDraftPreviewsEnabled:
|
|
418
|
+
createTelegramRichDraftPreviewsSetter(configStore),
|
|
397
419
|
getVoiceReplyMode: createTelegramVoiceReplyModeGetter(configStore),
|
|
398
420
|
isVoiceReplyModeConfigured:
|
|
399
421
|
createTelegramVoiceReplyModeConfiguredChecker(configStore),
|