@llblab/pi-telegram 0.27.3 → 0.27.5
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 +1 -1
- package/CHANGELOG.md +12 -0
- package/docs/multi-instance-bus.md +3 -1
- package/docs/outbound.md +1 -1
- package/index.ts +48 -0
- package/lib/agent-messages.ts +116 -0
- package/lib/bindings.ts +6 -0
- package/lib/bus-follower.ts +81 -0
- package/lib/bus-leader.ts +94 -0
- package/lib/bus.ts +114 -0
- package/lib/outbound-attachments.ts +63 -5
- package/lib/prompts.ts +2 -2
- package/lib/sync.ts +6 -6
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -82,7 +82,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
82
82
|
- `/telegram-connect` is the bounded liveness-recovery boundary for unclean-shutdown corruption in disposable `tmp/telegram` state. It may quarantine only classifier-approved malformed `owners.json`, active-profile `state*.json`, and `owners.json.transaction` after serializing recoverers and revalidating under ownership fencing; it must preserve `telegram.json`, diagnostics, unrelated artifacts, and every verifiable live owner/transaction holder. Stale heartbeats older than eight seconds do not gain protection through PID reuse. Require confirmed local polling suspension before quarantine mutation; a suspension failure blocks recovery, while an ownership-release failure may continue only into final guarded live-owner revalidation. Use the established bounded Windows sharing-violation rename retries. Retry polling exactly once after recovery, preserve unrelated startup errors, and replace blocked/repeated recovery failure with one explicit restart instruction rather than broad `tmp/` deletion or an automatic loop.
|
|
83
83
|
- Telegram profiles are parallel bot/runtime realities, not merely alternate credentials. Persist every bot/session field (`botToken`, `botUsername`, `botId`, `allowedUserId`, `lastUpdateId`) under `telegram.json` `profiles.<name>`, including `profiles.default`; keep shared handlers and assistant/voice/time/thread-lifecycle settings top-level. Bare and explicit `default` setup/connect commands address the same profile. Legacy root identity may normalize atomically only when unambiguous; conflicts fail closed without mutation. Any owner slot, IPC endpoint, state snapshot, diagnostics log, thread/bus binding, runtime cache, ownership map, or operator-visible status surface that could mix bot identities must be deliberately classified as global, session-local, or profile-scoped. Use the `default` owner slot for the default profile and validated profile names for named slots; give named profiles isolated observable surfaces unless there is an explicit top-level shared-config reason not to.
|
|
84
84
|
- Telegram private-chat Threaded Mode is the public switch for the local multi-instance bus. There is no separate public `telegram.json` bus switch: classic/private-chat singleton DM behavior is the base product mode, and when Telegram reports Threaded Mode available for the bot the bridge enables leader/follower bus behavior. If Telegram reports Threaded Mode unavailable or thread operations prove it unavailable, the bridge returns to classic singleton behavior as a first-class mode. During a live Threaded Mode downgrade, the current transport leader keeps the singleton classic polling role and followers disconnect instead of taking over or offering takeover while active thread bindings prove this is a downgrade from a live bus organism. Do not inject slot/thread rename guidance in classic mode. Blocked instances should not silently register as followers unless the effective runtime mode is Threaded Mode.
|
|
85
|
-
- In Threaded Mode, exactly one live leader owns `getUpdates` and direct Telegram Bot API calls for one bot token. `/telegram-connect` means join the local Telegram organism: if no live leader exists the instance becomes leader; if a live leader exists the terminal-visible instance registers as follower; ordinary connect must not offer manual takeover while the leader heartbeat is live. Leadership is an ephemeral transport role, not permanent ownership by the first terminal-visible Pi instance. Heartbeat acknowledgements carry the authenticated live follower-slot roster: on leader loss, the lowest observed live slot attempts the atomic lock first, while higher slots defer for one bounded grace and then re-check so a missing lower follower cannot block recovery indefinitely. Followers must be started by the operator as real Pi processes, register over the local bus, heartbeat, receive only their routed inbound updates, and send outbound replies/previews/files/chat-actions through the leader transport; Telegram must not spawn hidden Pi processes, launch follower subprocesses, or expose a slash command that creates a new local Pi instance. If a leader reloads while followers stay alive, followers must treat rejected/unknown/unauthorized heartbeats as a registration-loss signal and optimistically re-register with the currently active leader before considering promotion; after a short grace window without successful re-registration, a live follower may promote only through an atomic expected-owner transition after the exact observed leader lease becomes stale or inactive. An unreachable IPC endpoint does not authorize replacing a still-live lock owner; remain disconnected/retrying rather than creating split-brain polling. Terminal status must not remain `follower` while the leader registry can no longer route that follower's thread. Heartbeat pruning is liveness bookkeeping, not permission by itself to delete, mark offline, or send a Telegram-visible disconnected notice for a follower's thread; preserve active follower tabs/bindings across transient reload/reconnect incidents and reserve destructive teardown/offline transitions for confirmed manual disconnect, graceful Pi quit, or confirmed reconciliation cases. A stale follower may become a confirmed reconciliation case only when Thread cleanup is enabled, the OS reports the exact registered PID absent, and cleanup serializes ahead of replacement registration; live/unknown PID state remains non-destructive. If the leader dies, a live follower should eventually be able to take over through stale-leader election/recovery so the local multi-instance organism keeps running. Explicit `/telegram-disconnect` retains destructive confirmation; graceful Pi `quit` invokes the same teardown without prompting when top-level `threads.automaticCleanup` is enabled (default), while disabling it and `reload`, `new`, `resume`, or `fork` preserve the binding through safe handoff/suspension. A leader
|
|
85
|
+
- In Threaded Mode, exactly one live leader owns `getUpdates` and direct Telegram Bot API calls for one bot token. `/telegram-connect` means join the local Telegram organism: if no live leader exists the instance becomes leader; if a live leader exists the terminal-visible instance registers as follower; ordinary connect must not offer manual takeover while the leader heartbeat is live. Leadership is an ephemeral transport role, not permanent ownership by the first terminal-visible Pi instance. Heartbeat acknowledgements carry the authenticated live follower-slot roster: on leader loss, the lowest observed live slot attempts the atomic lock first, while higher slots defer for one bounded grace and then re-check so a missing lower follower cannot block recovery indefinitely. Followers must be started by the operator as real Pi processes, register over the local bus, heartbeat, receive only their routed inbound updates, and send outbound replies/previews/files/chat-actions through the leader transport; Telegram must not spawn hidden Pi processes, launch follower subprocesses, or expose a slash command that creates a new local Pi instance. If a leader reloads while followers stay alive, followers must treat rejected/unknown/unauthorized heartbeats as a registration-loss signal and optimistically re-register with the currently active leader before considering promotion; after a short grace window without successful re-registration, a live follower may promote only through an atomic expected-owner transition after the exact observed leader lease becomes stale or inactive. An unreachable IPC endpoint does not authorize replacing a still-live lock owner; remain disconnected/retrying rather than creating split-brain polling. Terminal status must not remain `follower` while the leader registry can no longer route that follower's thread. Heartbeat pruning is liveness bookkeeping, not permission by itself to delete, mark offline, or send a Telegram-visible disconnected notice for a follower's thread; preserve active follower tabs/bindings across transient reload/reconnect incidents and reserve destructive teardown/offline transitions for confirmed manual disconnect, graceful Pi quit, or confirmed reconciliation cases. A stale follower may become a confirmed reconciliation case only when Thread cleanup is enabled, the OS reports the exact registered PID absent, and cleanup serializes ahead of replacement registration; live/unknown PID state remains non-destructive. If the leader dies, a live follower should eventually be able to take over through stale-leader election/recovery so the local multi-instance organism keeps running. Explicit `/telegram-disconnect` retains destructive confirmation; graceful Pi `quit` invokes the same teardown without prompting when top-level `threads.automaticCleanup` is enabled (default), while disabling it and `reload`, `new`, `resume`, or `fork` preserve the binding through safe handoff/suspension. A leader persists cleanup intent and attempts to delete its own bound thread before releasing transport ownership, while a follower sends an authenticated exact-registration-generation disconnect envelope and waits for the leader to delete its assigned thread, mark the durable binding offline, and remove live routing authority before unregistering. Successful cleanup still requires confirmed deletion or explicit already-gone evidence, but an incomplete manual leader cleanup must not retain the transport lock: release leadership, preserve the binding plus intent, and let the successor replay it. Confirmed leader/follower teardown persists an exact target/runtime-generation cleanup intent before destructive API work; interruption preserves that intent for the current or successor leader to replay under its own epoch, while leader startup must first adopt a still-active same-profile binding and cancel its superseded cleanup before any destructive API call. Confirmation removes both the binding and intent atomically from the next persisted snapshot. Silence, heartbeat expiry, process absence, IPC failure, and auth mismatch create no cleanup intent by themselves; only stale heartbeat combined with enabled cleanup and OS-confirmed absence of the exact registered follower PID may authorize fallback intent. Incomplete manual follower cleanup preserves binding/routing state and returns actionable retry guidance; incomplete leader or promoted-leader cleanup preserves the durable intent but disconnects and releases leadership with a warning so cleanup failure cannot pin election. A promoted leader authorizes inherited follower-thread cleanup by its current leader epoch rather than the historical owner label. Status is reactive to the effective runtime role and bot capability: classic/non-thread mode shows ordinary `telegram connected`, active bus leadership shows `telegram leader`, and registered followers show `telegram follower`; stable multi-instance truth belongs in the bus role (`leader`/`follower`), while lifecycle is reserved for transient recovery state such as `electing` and must not duplicate stable roles with labels like `leader-active`. When leadership or Threaded Mode availability changes at runtime, the status line and `/telegram-status` must converge promptly instead of preserving a stale role label.
|
|
86
86
|
- Runtime routing binds to a live `instanceId` plus its exact follower registration generation; current thread identity should use explicit owner records (`leader`, `manual-follower`, `pending-topic`) rather than hiding ownership direction in legacy string keys. Persisted manual-follower records may act as stable restart hints, but only an authenticated live registry entry authorizes inbound forwarding or outbound transport; stale session records must not be treated as live owners. A live instance owns at most one active bound thread; provisioning a new target for the same `instanceId` must remove older current-state bindings and close duplicate Telegram threads when possible. A reload creates a new runtime `instanceId`; if the previous leader slot is still known/alive, keep it occupied in reservations and assign the new leader the next free slot instead of creating a duplicate same-letter tab or blocking startup on Telegram UI convergence.
|
|
87
87
|
- Follower registration readiness should stay on the smallest critical path that makes the follower routable: create or reuse the Telegram target, persist the active binding, mark target-bindings fresh, and return the target to the follower. Registration requires a present exact generation. Cross-session target reuse and any carried target absent from current bindings require one synchronous visibility probe: explicit stale evidence may provision a replacement, while ambiguous/non-stale failure must reject registration without replay, binding invalidation, or speculative replacement. Persist an ambiguously observed absent target only as non-routable `probe-required` restoration evidence so targetless retries and leader reloads must probe the exact target again before activation or replacement. Registration and explicit disconnect mutations for one durable follower profile must serialize across awaited cleanup so an old runtime's disconnect cannot delete or unregister a replacement generation. Manual follower identity must survive a Pi process reload in the same terminal so reload is not treated as a new follower/thread; derive stable process-birth identity from OS metadata where available, and carry a fresh authenticated previous-runtime-plus-exact-target handoff through initial registration so a changed identity migrates rather than provisions. Do not key durable follower ownership only by the short-lived Pi process id. Telegram-visible connected notices, duplicate/replaced thread cleanup, and other reconciliation work are important but non-critical; run them after registration and record slow/failing background steps as runtime events rather than delaying follower usability.
|
|
88
88
|
- Instance slots are extension-owned ring-order metadata. Allocate fresh slots from the `bot.lastSlot` cursor as `A` → `B` → ... → `Z` → `A`, allowing the cursor to move to lower letters after wraparound. Only current live/recovering bindings plus unexpired pending provisions and explicit reservations occupy slots; historical records must not block allocation indefinitely. Preserve a slot on ordinary current binding/thread reuse without advancing or rewinding the fresh-allocation cursor. Follower bindings survive process absence as stable restoration hints unless enabled Thread cleanup turns stale heartbeat plus exact-PID death confirmation into fenced cleanup authority; otherwise only explicit stale/deleted/offline evidence invalidates them. Successful target reuse must refresh the binding timestamp and replacement runtime identity; generic leader startup or heartbeat pruning alone must not erase the target merely because no follower registration is currently live. An authenticated live follower whose carried exact target is missing from current persisted bindings should recover that target instead of creating another Telegram thread; preserve a carried slot only when it does not collide with another current record. Preserve a cursor that belongs to a live or recoverable binding and continue skipping retained restart hints plus unexpired pending/reserved slots. Explicit stale/deleted/offline reconciliation may release that slot; generic process absence may not. The alphabet cap is a feature — compact single-letter tabs are scannable.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.27.5: Cross-Instance Agent Turns
|
|
4
|
+
|
|
5
|
+
- `Live Thread Addressing`: `telegram_message` now accepts `thread` as a case-insensitive live thread name or numeric id. Resolution uses leader-owned live registrations and rejects unknown, ambiguous, same-target, offline, and cross-chat destinations before visible Telegram mutation.
|
|
6
|
+
- `Agent Turn Routing`: A successful targeted send routes the same text through the authenticated, generation-fenced bus into the owning Pi instance as `[agent|from-thread:<name>]`, preserving one visible Telegram message without impersonating direct user input. Leader and follower targets share the ordinary inbound queue path, request deduplication, rendering, buttons, and authority fencing.
|
|
7
|
+
- `Compatibility and Coverage`: Existing default and `chat_id` / `thread_id` behavior remains compatible; active-turn cross-thread sends gain agent routing when available. Regressions cover protocol parsing, stale generations, name resolution, ambiguity, attribution, preflight ordering, and tool delivery.
|
|
8
|
+
|
|
9
|
+
## 0.27.4: Disconnect Leadership Release Hotfix
|
|
10
|
+
|
|
11
|
+
- `Leadership Release`: Manual disconnect now treats thread deletion as a durable cleanup side effect rather than a prerequisite for relinquishing transport leadership. A leader or promoted follower persists the exact cleanup intent, attempts close/delete under its epoch, and then stops polling and releases the owner lock even when Telegram rejects cleanup or the epoch changes. Impact: `Telegram Error` after `/telegram-disconnect` can no longer leave a live process pinning leadership and force newly connected instances to become followers.
|
|
12
|
+
- `Successor Recovery`: Incomplete leader cleanup remains recorded with its binding and exact generation for the next elected leader to replay; the disconnect returns a warning instead of throwing before lock release. Successful cleanup behavior remains unchanged, and manual followers still require authenticated live-leader teardown. Impact: thread safety and retryability survive without sacrificing leadership liveness.
|
|
13
|
+
- `Regression Coverage`: Added exact promoted-leader and epoch-loss disconnect regressions proving cleanup diagnostics remain visible while `stopPolling` still runs. Impact: future cleanup hardening cannot silently restore the lock-hostage failure mode.
|
|
14
|
+
|
|
3
15
|
## 0.27.3: Follower Cross-Thread Delivery Hotfix
|
|
4
16
|
|
|
5
17
|
- `Follower Cross-Thread Delivery`: The bus-aware Telegram API runtime now marks sends whose target differs from the follower's assigned thread, and the leader authorizes that internal marker only for a different target inside the same paired chat before stripping it from the Bot API request. Impact: `telegram_message` can fulfill explicit inter-thread delivery from follower instances without opening arbitrary cross-chat or generic follower API access.
|
|
@@ -281,6 +281,8 @@ Follower instances receive normalized events, not raw Telegram transport interna
|
|
|
281
281
|
|
|
282
282
|
Followers do not call Telegram Bot API directly for routed Telegram work. Instead, they call a leader-owned transport port:
|
|
283
283
|
|
|
284
|
+
Explicit `telegram_message(..., thread)` delivery also uses the bus as an agent-message plane. The leader resolves the case-insensitive name or numeric id against live leader/follower registrations, rejects ambiguous, stale, same-instance, cross-chat, and replayed requests, then coordinates visible Bot API delivery with one source-attributed synthetic turn routed through the destination instance's ordinary queue. The destination sees `[agent|from-thread:<name>]`, not an impersonated user message. Generation fencing and request-ledger deduplication apply to both resolution and routing.
|
|
285
|
+
|
|
284
286
|
```text
|
|
285
287
|
follower reply/preview/upload/chat-action/download/callback-answer -> leader IPC -> Telegram API
|
|
286
288
|
```
|
|
@@ -439,7 +441,7 @@ Live client and native Windows evidence gates are tracked in `BACKLOG.md`; this
|
|
|
439
441
|
- Local IPC is the default internal bus. Registered followers receive normalized inbound updates and send allowlisted, target-scoped Bot API calls through the leader.
|
|
440
442
|
- Thread targets are current-state bindings, not historical delivery addresses. Stable restart hints require a fresh authenticated follower registration before they become live routing authority; stale/offline/failed entries remain reconciliation evidence only.
|
|
441
443
|
- Failover promotes a remaining follower after dead or clean-disconnected leaders without creating competing pollers; follower heartbeat recovery owns re-register → grace → promotion while preserving thread bindings across transient leader reload gaps.
|
|
442
|
-
- Thread cleanup is centralized in `thread-reconciler`, fails closed without a leader epoch while leadership exists, revalidates that epoch immediately before every close/delete call and local cleanup-state mutation, and requires confirmed delete/stale evidence before state is marked deleted.
|
|
444
|
+
- Thread cleanup is centralized in `thread-reconciler`, fails closed without a leader epoch while leadership exists, revalidates that epoch immediately before every close/delete call and local cleanup-state mutation, and requires confirmed delete/stale evidence before state is marked deleted. Manual leader and promoted-leader disconnect persist cleanup intent first but always stop polling and release the owner lock even when Telegram cleanup is incomplete; the successor replays the retained intent, so a Bot API or epoch failure cannot pin leadership. Manual followers still require a live leader acknowledgement for destructive teardown.
|
|
443
445
|
- Stable docs/UI now describe classic mode, opt-in Threaded Mode, manual follower registration, status/diagnostics, unbound-thread reroute/restore UX, and operator recovery boundaries.
|
|
444
446
|
|
|
445
447
|
## Evidence Gates
|
package/docs/outbound.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`pi-telegram` maps hidden assistant-authored HTML comments to Telegram-native outbound actions.
|
|
4
4
|
|
|
5
|
-
Normal Telegram-turn replies are intentionally prompt-driven: the agent writes Markdown plus small hidden top-level blocks, and the bridge performs transport after `agent_end`. `telegram_voice` and `telegram_button` are not Pi tools. For local/TUI-initiated work where the user explicitly asks to send something to Telegram, the bridge also exposes direct tools: `telegram_message` for Markdown text and `telegram_attach` for file delivery when no Telegram turn is active. In classic mode, direct local/TUI delivery requires this Pi instance to own `/telegram-connect`; in Threaded Mode, a registered follower may route direct-tool sends through the leader-owned bus transport. During an active Telegram turn, `telegram_message` rejects an implicit or same-turn target so ordinary final delivery remains the sole current-target response
|
|
5
|
+
Normal Telegram-turn replies are intentionally prompt-driven: the agent writes Markdown plus small hidden top-level blocks, and the bridge performs transport after `agent_end`. `telegram_voice` and `telegram_button` are not Pi tools. For local/TUI-initiated work where the user explicitly asks to send something to Telegram, the bridge also exposes direct tools: `telegram_message` for Markdown text and `telegram_attach` for file delivery when no Telegram turn is active. In classic mode, direct local/TUI delivery requires this Pi instance to own `/telegram-connect`; in Threaded Mode, a registered follower may route direct-tool sends through the leader-owned bus transport. During an active Telegram turn, `telegram_message` rejects an implicit or same-turn target so ordinary final delivery remains the sole current-target response. Its `thread` argument accepts a case-insensitive live thread name or numeric id: the bridge preflights one live owner, sends visibly, then admits the text as a source-attributed turn in that instance. Unknown, ambiguous, same-target, offline, and cross-chat destinations fail before sending. Existing `chat_id` plus `thread_id` targeting stays compatible; registered followers use authenticated, generation-fenced bus routing. Outbound behavior combines assistant prompt markup, text command-template handlers, registered voice synthesis providers, generated artifacts, direct Telegram tools, and reply delivery. Direct `telegram_message` text is planned through the same reply markup path, so embedded top-level `telegram_button` comments become buttons attached to that text message.
|
|
6
6
|
|
|
7
7
|
Text handlers use the portable [Command Template Standard](./command-templates.md). Programmatic outbound handlers use `registerTelegramOutboundHandler(kind, handler)`. Voice replies can use configured command-template handlers or the provider API described in [Voice Integration](./voice.md).
|
|
8
8
|
|
package/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as Activity from "./lib/activity.ts";
|
|
8
|
+
import * as AgentMessages from "./lib/agent-messages.ts";
|
|
8
9
|
import * as ActivityVerbosity from "./lib/activity-verbosity.ts";
|
|
9
10
|
import * as Bindings from "./lib/bindings.ts";
|
|
10
11
|
import * as BusApi from "./lib/bus-api.ts";
|
|
@@ -811,6 +812,17 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
811
812
|
handleAuthorizedReactionUpdate:
|
|
812
813
|
inboundRouteRuntime.handleAuthorizedReactionUpdate,
|
|
813
814
|
});
|
|
815
|
+
const agentMessageRuntime = AgentMessages.createTelegramAgentMessageRuntime({
|
|
816
|
+
instanceId: telegramInstanceId,
|
|
817
|
+
getAllowedChatId: configStore.getAllowedUserId,
|
|
818
|
+
getLeaderTarget: telegramBusLeaderState.getTarget,
|
|
819
|
+
getLeaderThreadName() {
|
|
820
|
+
return findCurrentThreadRecord()?.threadName;
|
|
821
|
+
},
|
|
822
|
+
followerRegistry: telegramBusFollowerRegistry,
|
|
823
|
+
getContext: telegramSessionContextStore.get,
|
|
824
|
+
handleUpdate: inboundRouteRuntime.handleUpdate,
|
|
825
|
+
});
|
|
814
826
|
const telegramBusFollowerAssemblyDeps =
|
|
815
827
|
BusFollower.createTelegramBusFollowerRuntimeAssemblyDeps<
|
|
816
828
|
Pi.ExtensionContext,
|
|
@@ -898,6 +910,16 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
898
910
|
startPolling: pollingRuntime.start,
|
|
899
911
|
stopPolling: pollingRuntime.stop,
|
|
900
912
|
authorizeFollowerApiCall,
|
|
913
|
+
resolveAgentTarget(follower, selector) {
|
|
914
|
+
return agentMessageRuntime.resolveTarget(selector, follower.target);
|
|
915
|
+
},
|
|
916
|
+
routeAgentMessage(follower, message) {
|
|
917
|
+
return agentMessageRuntime.route({
|
|
918
|
+
sourceTarget: follower.target,
|
|
919
|
+
sourceThreadName: follower.threadName,
|
|
920
|
+
message,
|
|
921
|
+
});
|
|
922
|
+
},
|
|
901
923
|
isFollowerProcessAlive: Locks.isProcessAlive,
|
|
902
924
|
shouldCleanupConfirmedDeadFollower:
|
|
903
925
|
configControls.resolveAutomaticThreadCleanupEnabled,
|
|
@@ -1094,6 +1116,32 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1094
1116
|
callMultipart,
|
|
1095
1117
|
getDefaultChatId: proactivePushChatIdGetter,
|
|
1096
1118
|
getDefaultTarget: proactivePushTargetGetter,
|
|
1119
|
+
async resolveAgentTarget(selector) {
|
|
1120
|
+
if (lockRuntime.owns()) {
|
|
1121
|
+
const target = agentMessageRuntime.resolveTarget(
|
|
1122
|
+
selector,
|
|
1123
|
+
proactivePushTargetGetter(),
|
|
1124
|
+
);
|
|
1125
|
+
if (!target) {
|
|
1126
|
+
throw new Error(
|
|
1127
|
+
"Telegram agent target is unavailable, ambiguous, or not live.",
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
return target;
|
|
1131
|
+
}
|
|
1132
|
+
return telegramBusFollowerClients.agentMessages.resolveTarget(selector);
|
|
1133
|
+
},
|
|
1134
|
+
async routeAgentMessage(message) {
|
|
1135
|
+
if (lockRuntime.owns()) {
|
|
1136
|
+
await agentMessageRuntime.route({
|
|
1137
|
+
sourceTarget: proactivePushTargetGetter(),
|
|
1138
|
+
sourceThreadName: findCurrentThreadRecord()?.threadName,
|
|
1139
|
+
message,
|
|
1140
|
+
});
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
await telegramBusFollowerClients.agentMessages.routeMessage(message);
|
|
1144
|
+
},
|
|
1097
1145
|
canSendDirect() {
|
|
1098
1146
|
return (
|
|
1099
1147
|
ownsTelegramDirectDelivery() ||
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-instance Telegram agent message resolution and turn injection
|
|
3
|
+
* Zones: multi-instance bus, inbound routing, operational delivery
|
|
4
|
+
* Owns live target resolution, source attribution, same-target fencing, and synthetic turn construction; excludes Bot API delivery and bus transport.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
TelegramBusAgentMessage,
|
|
9
|
+
TelegramBusAgentTargetSelector,
|
|
10
|
+
TelegramBusFollowerRegistry,
|
|
11
|
+
} from "./bus.ts";
|
|
12
|
+
import type { TelegramRoutedMessage } from "./routing.ts";
|
|
13
|
+
import type { TelegramTarget } from "./target.ts";
|
|
14
|
+
|
|
15
|
+
export interface TelegramAgentMessageRuntimeDeps<TContext, TUpdate> {
|
|
16
|
+
instanceId: string;
|
|
17
|
+
getAllowedChatId: () => number | undefined;
|
|
18
|
+
getLeaderTarget: () => TelegramTarget | undefined;
|
|
19
|
+
getLeaderThreadName: () => string | undefined;
|
|
20
|
+
followerRegistry: TelegramBusFollowerRegistry;
|
|
21
|
+
getContext: () => TContext | undefined;
|
|
22
|
+
handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
|
|
23
|
+
getNowMs?: () => number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createTelegramAgentMessageRuntime<TContext, TUpdate>(
|
|
27
|
+
deps: TelegramAgentMessageRuntimeDeps<TContext, TUpdate>,
|
|
28
|
+
) {
|
|
29
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
30
|
+
const sameTarget = (left?: TelegramTarget, right?: TelegramTarget) =>
|
|
31
|
+
left?.chatId === right?.chatId && left?.threadId === right?.threadId;
|
|
32
|
+
const listLiveTargets = () => {
|
|
33
|
+
const targets: Array<{
|
|
34
|
+
target: TelegramTarget & { threadId: number };
|
|
35
|
+
threadName?: string;
|
|
36
|
+
}> = [];
|
|
37
|
+
const leaderTarget = deps.getLeaderTarget();
|
|
38
|
+
if (leaderTarget?.threadId) {
|
|
39
|
+
targets.push({
|
|
40
|
+
target: { chatId: leaderTarget.chatId, threadId: leaderTarget.threadId },
|
|
41
|
+
threadName: deps.getLeaderThreadName(),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
for (const follower of deps.followerRegistry.list()) {
|
|
45
|
+
if (!follower.target?.threadId) continue;
|
|
46
|
+
targets.push({
|
|
47
|
+
target: {
|
|
48
|
+
chatId: follower.target.chatId,
|
|
49
|
+
threadId: follower.target.threadId,
|
|
50
|
+
},
|
|
51
|
+
threadName: follower.threadName,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return targets;
|
|
55
|
+
};
|
|
56
|
+
const resolveTarget = (
|
|
57
|
+
selector: TelegramBusAgentTargetSelector,
|
|
58
|
+
sourceTarget?: TelegramTarget,
|
|
59
|
+
): (TelegramTarget & { threadId: number }) | undefined => {
|
|
60
|
+
const allowedChatId = deps.getAllowedChatId();
|
|
61
|
+
const chatId = selector.chatId ?? allowedChatId;
|
|
62
|
+
if (chatId === undefined || chatId !== allowedChatId) return undefined;
|
|
63
|
+
const matches = listLiveTargets().filter((candidate) => {
|
|
64
|
+
if (candidate.target.chatId !== chatId) return false;
|
|
65
|
+
if (selector.threadId !== undefined) {
|
|
66
|
+
return candidate.target.threadId === selector.threadId;
|
|
67
|
+
}
|
|
68
|
+
const requestedName = selector.threadName?.trim().toLocaleLowerCase();
|
|
69
|
+
return (
|
|
70
|
+
requestedName !== undefined &&
|
|
71
|
+
requestedName.length > 0 &&
|
|
72
|
+
candidate.threadName?.trim().toLocaleLowerCase() === requestedName
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
if (matches.length !== 1) return undefined;
|
|
76
|
+
return sameTarget(matches[0]!.target, sourceTarget)
|
|
77
|
+
? undefined
|
|
78
|
+
: matches[0]!.target;
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
resolveTarget,
|
|
82
|
+
async route(input: {
|
|
83
|
+
sourceTarget?: TelegramTarget;
|
|
84
|
+
sourceThreadName?: string;
|
|
85
|
+
message: TelegramBusAgentMessage;
|
|
86
|
+
}): Promise<void> {
|
|
87
|
+
const target = resolveTarget(
|
|
88
|
+
{
|
|
89
|
+
chatId: input.message.target.chatId,
|
|
90
|
+
threadId: input.message.target.threadId,
|
|
91
|
+
},
|
|
92
|
+
input.sourceTarget,
|
|
93
|
+
);
|
|
94
|
+
if (!target) throw new Error("Telegram agent target is no longer live.");
|
|
95
|
+
const allowedChatId = deps.getAllowedChatId();
|
|
96
|
+
const ctx = deps.getContext();
|
|
97
|
+
if (allowedChatId === undefined || !ctx) {
|
|
98
|
+
throw new Error("Telegram agent turn routing is unavailable.");
|
|
99
|
+
}
|
|
100
|
+
const sourceLabel =
|
|
101
|
+
input.sourceThreadName
|
|
102
|
+
?.replace(/[\r\n\[\]]+/g, " ")
|
|
103
|
+
.trim()
|
|
104
|
+
.slice(0, 64) || "Pi";
|
|
105
|
+
const message = {
|
|
106
|
+
message_id: input.message.messageId,
|
|
107
|
+
date: Math.floor(getNowMs() / 1000),
|
|
108
|
+
chat: { id: target.chatId, type: "private" as const },
|
|
109
|
+
from: { id: allowedChatId, is_bot: false, first_name: "Pi Agent" },
|
|
110
|
+
message_thread_id: target.threadId,
|
|
111
|
+
text: `[agent|from-thread:${sourceLabel}]\n\n${input.message.text}`,
|
|
112
|
+
} as TelegramRoutedMessage;
|
|
113
|
+
await deps.handleUpdate({ message } as TUpdate, ctx);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/lib/bindings.ts
CHANGED
|
@@ -121,6 +121,8 @@ interface TelegramCommandsAndToolsBindingDeps {
|
|
|
121
121
|
callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
|
|
122
122
|
getDefaultChatId: () => number | undefined;
|
|
123
123
|
getDefaultTarget?: () => OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"];
|
|
124
|
+
resolveAgentTarget?: OutboundAttachments.TelegramOutboundMessageToolRegistrationDeps["resolveAgentTarget"];
|
|
125
|
+
routeAgentMessage?: OutboundAttachments.TelegramOutboundMessageToolRegistrationDeps["routeAgentMessage"];
|
|
124
126
|
canSendDirect: () => boolean;
|
|
125
127
|
updateStatus: TelegramBridgeStatusUpdater;
|
|
126
128
|
recordRuntimeEvent: TelegramRuntimeEventRecorder;
|
|
@@ -143,6 +145,8 @@ export function registerTelegramCommandsAndTools({
|
|
|
143
145
|
callMultipart,
|
|
144
146
|
getDefaultChatId,
|
|
145
147
|
getDefaultTarget,
|
|
148
|
+
resolveAgentTarget,
|
|
149
|
+
routeAgentMessage,
|
|
146
150
|
canSendDirect,
|
|
147
151
|
recordRuntimeEvent,
|
|
148
152
|
updateStatus,
|
|
@@ -159,6 +163,8 @@ export function registerTelegramCommandsAndTools({
|
|
|
159
163
|
getDefaultChatId,
|
|
160
164
|
getDefaultTarget,
|
|
161
165
|
getActiveTurn: activeTurnRuntime.get,
|
|
166
|
+
resolveAgentTarget,
|
|
167
|
+
routeAgentMessage,
|
|
162
168
|
canSendDirect,
|
|
163
169
|
planMessage:
|
|
164
170
|
OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore),
|
package/lib/bus-follower.ts
CHANGED
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
getTelegramBusSocketPath,
|
|
26
26
|
resolveTelegramBusSocketPath,
|
|
27
27
|
sendTelegramBusLocalEnvelope,
|
|
28
|
+
type TelegramBusAgentMessage,
|
|
29
|
+
type TelegramBusAgentTargetSelector,
|
|
28
30
|
type TelegramBusEnvelope,
|
|
29
31
|
type TelegramBusSocketPathSource,
|
|
30
32
|
} from "./bus.ts";
|
|
@@ -736,6 +738,12 @@ export function createTelegramBusFollowerClientRuntime<
|
|
|
736
738
|
getAuthSecret: deps.getApiAuthSecret,
|
|
737
739
|
getRegistrationGeneration: deps.getRegistrationGeneration,
|
|
738
740
|
}),
|
|
741
|
+
agentMessages: createTelegramBusAgentMessageClient({
|
|
742
|
+
...sharedClientDeps,
|
|
743
|
+
instanceId: deps.instanceId,
|
|
744
|
+
getAuthSecret: deps.getApiAuthSecret,
|
|
745
|
+
getRegistrationGeneration: deps.getRegistrationGeneration,
|
|
746
|
+
}),
|
|
739
747
|
foreignOwnedUpdateForwarder: createTelegramBusForeignOwnedUpdateForwarder<
|
|
740
748
|
TContext,
|
|
741
749
|
TReactionUpdate,
|
|
@@ -755,6 +763,79 @@ export function createTelegramBusFollowerClientRuntime<
|
|
|
755
763
|
};
|
|
756
764
|
}
|
|
757
765
|
|
|
766
|
+
export function createTelegramBusAgentMessageClient(
|
|
767
|
+
deps: TelegramBusFollowerApiCallerDeps,
|
|
768
|
+
): {
|
|
769
|
+
resolveTarget: (
|
|
770
|
+
selector: TelegramBusAgentTargetSelector,
|
|
771
|
+
) => Promise<TelegramTarget & { threadId: number }>;
|
|
772
|
+
routeMessage: (message: TelegramBusAgentMessage) => Promise<void>;
|
|
773
|
+
} {
|
|
774
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
775
|
+
const timeoutMs =
|
|
776
|
+
deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
|
|
777
|
+
const request = async (
|
|
778
|
+
envelope:
|
|
779
|
+
| Extract<TelegramBusEnvelope, { kind: "follower.resolveAgentTarget" }>
|
|
780
|
+
| Extract<TelegramBusEnvelope, { kind: "follower.routeAgentMessage" }>,
|
|
781
|
+
): Promise<unknown> => {
|
|
782
|
+
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
783
|
+
const response = await sendTelegramBusLocalEnvelope({
|
|
784
|
+
socketPath,
|
|
785
|
+
timeoutMs,
|
|
786
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
787
|
+
endpoint: socketPath,
|
|
788
|
+
operation: "operation",
|
|
789
|
+
}),
|
|
790
|
+
envelope,
|
|
791
|
+
});
|
|
792
|
+
if (response?.kind === "bus.ack" && response.ok) return response.result;
|
|
793
|
+
throw new Error(
|
|
794
|
+
response?.kind === "bus.ack"
|
|
795
|
+
? response.message ?? "Telegram bus agent message failed."
|
|
796
|
+
: "Telegram bus agent message did not return an acknowledgement.",
|
|
797
|
+
);
|
|
798
|
+
};
|
|
799
|
+
const registrationFields = () => ({
|
|
800
|
+
auth: deps.getAuthSecret?.(),
|
|
801
|
+
instanceId: deps.instanceId,
|
|
802
|
+
...(deps.getRegistrationGeneration?.()
|
|
803
|
+
? { registrationGeneration: deps.getRegistrationGeneration?.() }
|
|
804
|
+
: {}),
|
|
805
|
+
});
|
|
806
|
+
return {
|
|
807
|
+
async resolveTarget(selector) {
|
|
808
|
+
const result = await request({
|
|
809
|
+
kind: "follower.resolveAgentTarget",
|
|
810
|
+
requestId: deps.createRequestId(),
|
|
811
|
+
...registrationFields(),
|
|
812
|
+
selector,
|
|
813
|
+
sentAtMs: getNowMs(),
|
|
814
|
+
});
|
|
815
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
816
|
+
throw new Error("Telegram bus returned an invalid agent target.");
|
|
817
|
+
}
|
|
818
|
+
const target = result as Record<string, unknown>;
|
|
819
|
+
if (
|
|
820
|
+
typeof target.chatId !== "number" ||
|
|
821
|
+
typeof target.threadId !== "number"
|
|
822
|
+
) {
|
|
823
|
+
throw new Error("Telegram bus returned an invalid agent target.");
|
|
824
|
+
}
|
|
825
|
+
return { chatId: target.chatId, threadId: target.threadId };
|
|
826
|
+
},
|
|
827
|
+
async routeMessage(message) {
|
|
828
|
+
await request({
|
|
829
|
+
kind: "follower.routeAgentMessage",
|
|
830
|
+
requestId: deps.createRequestId(),
|
|
831
|
+
...registrationFields(),
|
|
832
|
+
message,
|
|
833
|
+
sentAtMs: getNowMs(),
|
|
834
|
+
});
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
758
839
|
export function createTelegramBusFollowerApiCaller(
|
|
759
840
|
deps: TelegramBusFollowerApiCallerDeps,
|
|
760
841
|
): (method: string, args: unknown[]) => Promise<unknown> {
|
package/lib/bus-leader.ts
CHANGED
|
@@ -258,6 +258,20 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
|
|
|
258
258
|
args: unknown[];
|
|
259
259
|
}) => boolean;
|
|
260
260
|
recordFollowerMessageOwnership?: TelegramBusFollowerMessageOwnershipRecorder;
|
|
261
|
+
resolveAgentTarget?: (
|
|
262
|
+
follower: TelegramBusFollowerView,
|
|
263
|
+
selector: Extract<
|
|
264
|
+
TelegramBusEnvelope,
|
|
265
|
+
{ kind: "follower.resolveAgentTarget" }
|
|
266
|
+
>["selector"],
|
|
267
|
+
) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
|
|
268
|
+
routeAgentMessage?: (
|
|
269
|
+
follower: TelegramBusFollowerView,
|
|
270
|
+
message: Extract<
|
|
271
|
+
TelegramBusEnvelope,
|
|
272
|
+
{ kind: "follower.routeAgentMessage" }
|
|
273
|
+
>["message"],
|
|
274
|
+
) => Promise<void> | void;
|
|
261
275
|
provisionFollowerTarget?: (
|
|
262
276
|
registration: TelegramBusInstanceRegistration,
|
|
263
277
|
) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
|
|
@@ -1024,6 +1038,20 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
|
|
|
1024
1038
|
args: unknown[];
|
|
1025
1039
|
}) => boolean;
|
|
1026
1040
|
recordFollowerMessageOwnership?: TelegramBusFollowerMessageOwnershipRecorder;
|
|
1041
|
+
resolveAgentTarget?: (
|
|
1042
|
+
follower: TelegramBusFollowerView,
|
|
1043
|
+
selector: Extract<
|
|
1044
|
+
TelegramBusEnvelope,
|
|
1045
|
+
{ kind: "follower.resolveAgentTarget" }
|
|
1046
|
+
>["selector"],
|
|
1047
|
+
) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
|
|
1048
|
+
routeAgentMessage?: (
|
|
1049
|
+
follower: TelegramBusFollowerView,
|
|
1050
|
+
message: Extract<
|
|
1051
|
+
TelegramBusEnvelope,
|
|
1052
|
+
{ kind: "follower.routeAgentMessage" }
|
|
1053
|
+
>["message"],
|
|
1054
|
+
) => Promise<void> | void;
|
|
1027
1055
|
provisionFollowerTarget?: (
|
|
1028
1056
|
registration: TelegramBusInstanceRegistration,
|
|
1029
1057
|
) =>
|
|
@@ -1044,6 +1072,67 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
|
|
|
1044
1072
|
const getNowMs = deps.getNowMs ?? Date.now;
|
|
1045
1073
|
const runFollowerMutation =
|
|
1046
1074
|
deps.runFollowerMutation ?? createTelegramBusFollowerMutationRunner();
|
|
1075
|
+
const handleAgentRequest = async (
|
|
1076
|
+
envelope: Extract<
|
|
1077
|
+
TelegramBusEnvelope,
|
|
1078
|
+
{
|
|
1079
|
+
kind:
|
|
1080
|
+
| "follower.resolveAgentTarget"
|
|
1081
|
+
| "follower.routeAgentMessage";
|
|
1082
|
+
}
|
|
1083
|
+
>,
|
|
1084
|
+
): Promise<TelegramBusEnvelope> => {
|
|
1085
|
+
const follower = deps.followerRegistry.get(envelope.instanceId);
|
|
1086
|
+
if (!follower) {
|
|
1087
|
+
return {
|
|
1088
|
+
kind: "bus.ack",
|
|
1089
|
+
requestId: envelope.requestId,
|
|
1090
|
+
ok: false,
|
|
1091
|
+
message: "Unknown Telegram bus follower instance.",
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
if (
|
|
1095
|
+
follower.registrationGeneration &&
|
|
1096
|
+
envelope.registrationGeneration !== follower.registrationGeneration
|
|
1097
|
+
) {
|
|
1098
|
+
return {
|
|
1099
|
+
kind: "bus.ack",
|
|
1100
|
+
requestId: envelope.requestId,
|
|
1101
|
+
ok: false,
|
|
1102
|
+
message: "Stale Telegram bus follower registration generation.",
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
deps.followerRegistry.heartbeat(envelope.instanceId, getNowMs());
|
|
1106
|
+
if (envelope.kind === "follower.resolveAgentTarget") {
|
|
1107
|
+
const target = await deps.resolveAgentTarget?.(
|
|
1108
|
+
follower,
|
|
1109
|
+
envelope.selector,
|
|
1110
|
+
);
|
|
1111
|
+
return target
|
|
1112
|
+
? {
|
|
1113
|
+
kind: "bus.ack",
|
|
1114
|
+
requestId: envelope.requestId,
|
|
1115
|
+
ok: true,
|
|
1116
|
+
result: target,
|
|
1117
|
+
}
|
|
1118
|
+
: {
|
|
1119
|
+
kind: "bus.ack",
|
|
1120
|
+
requestId: envelope.requestId,
|
|
1121
|
+
ok: false,
|
|
1122
|
+
message: "Telegram agent target is unavailable or ambiguous.",
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
if (!deps.routeAgentMessage) {
|
|
1126
|
+
return {
|
|
1127
|
+
kind: "bus.ack",
|
|
1128
|
+
requestId: envelope.requestId,
|
|
1129
|
+
ok: false,
|
|
1130
|
+
message: "Telegram agent message routing is unavailable.",
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
await deps.routeAgentMessage(follower, envelope.message);
|
|
1134
|
+
return { kind: "bus.ack", requestId: envelope.requestId, ok: true };
|
|
1135
|
+
};
|
|
1047
1136
|
const forwardToFollower = async (
|
|
1048
1137
|
envelope: Extract<
|
|
1049
1138
|
TelegramBusEnvelope,
|
|
@@ -1251,6 +1340,9 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
|
|
|
1251
1340
|
message: "Unknown Telegram bus follower instance.",
|
|
1252
1341
|
};
|
|
1253
1342
|
}
|
|
1343
|
+
case "follower.resolveAgentTarget":
|
|
1344
|
+
case "follower.routeAgentMessage":
|
|
1345
|
+
return handleAgentRequest(envelope);
|
|
1254
1346
|
case "leader.forwardCallback":
|
|
1255
1347
|
case "leader.forwardReaction":
|
|
1256
1348
|
case "leader.forwardMessage":
|
|
@@ -1619,6 +1711,8 @@ export function createTelegramBusLeaderRuntime<TContext>(
|
|
|
1619
1711
|
callApi: deps.callApi,
|
|
1620
1712
|
authorizeFollowerApiCall: deps.authorizeFollowerApiCall,
|
|
1621
1713
|
recordFollowerMessageOwnership: deps.recordFollowerMessageOwnership,
|
|
1714
|
+
resolveAgentTarget: deps.resolveAgentTarget,
|
|
1715
|
+
routeAgentMessage: deps.routeAgentMessage,
|
|
1622
1716
|
provisionFollowerTarget: deps.provisionFollowerTarget,
|
|
1623
1717
|
onFollowerDisconnected: deps.onFollowerDisconnected,
|
|
1624
1718
|
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
package/lib/bus.ts
CHANGED
|
@@ -448,6 +448,18 @@ export function createTelegramFollowerApiCallAuthorizer(deps: {
|
|
|
448
448
|
});
|
|
449
449
|
}
|
|
450
450
|
|
|
451
|
+
export interface TelegramBusAgentTargetSelector {
|
|
452
|
+
chatId?: number;
|
|
453
|
+
threadId?: number;
|
|
454
|
+
threadName?: string;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export interface TelegramBusAgentMessage {
|
|
458
|
+
target: TelegramTarget & { threadId: number };
|
|
459
|
+
messageId: number;
|
|
460
|
+
text: string;
|
|
461
|
+
}
|
|
462
|
+
|
|
451
463
|
export type TelegramBusEnvelope = (
|
|
452
464
|
| {
|
|
453
465
|
kind: "follower.register";
|
|
@@ -511,6 +523,22 @@ export type TelegramBusEnvelope = (
|
|
|
511
523
|
reason: "thread-restore";
|
|
512
524
|
sentAtMs: number;
|
|
513
525
|
}
|
|
526
|
+
| {
|
|
527
|
+
kind: "follower.resolveAgentTarget";
|
|
528
|
+
requestId: string;
|
|
529
|
+
instanceId: string;
|
|
530
|
+
registrationGeneration?: string;
|
|
531
|
+
selector: TelegramBusAgentTargetSelector;
|
|
532
|
+
sentAtMs: number;
|
|
533
|
+
}
|
|
534
|
+
| {
|
|
535
|
+
kind: "follower.routeAgentMessage";
|
|
536
|
+
requestId: string;
|
|
537
|
+
instanceId: string;
|
|
538
|
+
registrationGeneration?: string;
|
|
539
|
+
message: TelegramBusAgentMessage;
|
|
540
|
+
sentAtMs: number;
|
|
541
|
+
}
|
|
514
542
|
| {
|
|
515
543
|
kind: "follower.callApi";
|
|
516
544
|
requestId: string;
|
|
@@ -605,6 +633,12 @@ export function parseTelegramBusEnvelope(
|
|
|
605
633
|
case "leader.replaceFollowerTarget":
|
|
606
634
|
envelope = parseReplaceFollowerTargetEnvelope(value, requestId);
|
|
607
635
|
break;
|
|
636
|
+
case "follower.resolveAgentTarget":
|
|
637
|
+
envelope = parseResolveAgentTargetEnvelope(value, requestId);
|
|
638
|
+
break;
|
|
639
|
+
case "follower.routeAgentMessage":
|
|
640
|
+
envelope = parseRouteAgentMessageEnvelope(value, requestId);
|
|
641
|
+
break;
|
|
608
642
|
case "follower.callApi":
|
|
609
643
|
envelope = parseCallApiEnvelope(value, requestId);
|
|
610
644
|
break;
|
|
@@ -1582,6 +1616,86 @@ function parseReplaceFollowerTargetEnvelope(
|
|
|
1582
1616
|
};
|
|
1583
1617
|
}
|
|
1584
1618
|
|
|
1619
|
+
function parseResolveAgentTargetEnvelope(
|
|
1620
|
+
value: Record<string, unknown>,
|
|
1621
|
+
requestId: string,
|
|
1622
|
+
): TelegramBusEnvelope | undefined {
|
|
1623
|
+
const selectorValue = isRecord(value.selector) ? value.selector : undefined;
|
|
1624
|
+
if (
|
|
1625
|
+
typeof value.instanceId !== "string" ||
|
|
1626
|
+
!selectorValue ||
|
|
1627
|
+
typeof value.sentAtMs !== "number"
|
|
1628
|
+
) {
|
|
1629
|
+
return undefined;
|
|
1630
|
+
}
|
|
1631
|
+
const chatId =
|
|
1632
|
+
typeof selectorValue.chatId === "number" &&
|
|
1633
|
+
Number.isInteger(selectorValue.chatId)
|
|
1634
|
+
? selectorValue.chatId
|
|
1635
|
+
: undefined;
|
|
1636
|
+
const threadId =
|
|
1637
|
+
typeof selectorValue.threadId === "number" &&
|
|
1638
|
+
Number.isInteger(selectorValue.threadId) &&
|
|
1639
|
+
selectorValue.threadId > 0
|
|
1640
|
+
? selectorValue.threadId
|
|
1641
|
+
: undefined;
|
|
1642
|
+
const threadName =
|
|
1643
|
+
typeof selectorValue.threadName === "string" &&
|
|
1644
|
+
selectorValue.threadName.trim()
|
|
1645
|
+
? selectorValue.threadName.trim()
|
|
1646
|
+
: undefined;
|
|
1647
|
+
if ((threadId === undefined) === (threadName === undefined)) return undefined;
|
|
1648
|
+
return {
|
|
1649
|
+
kind: "follower.resolveAgentTarget",
|
|
1650
|
+
requestId,
|
|
1651
|
+
instanceId: value.instanceId,
|
|
1652
|
+
...(typeof value.registrationGeneration === "string"
|
|
1653
|
+
? { registrationGeneration: value.registrationGeneration }
|
|
1654
|
+
: {}),
|
|
1655
|
+
selector: {
|
|
1656
|
+
...(chatId !== undefined ? { chatId } : {}),
|
|
1657
|
+
...(threadId !== undefined ? { threadId } : {}),
|
|
1658
|
+
...(threadName !== undefined ? { threadName } : {}),
|
|
1659
|
+
},
|
|
1660
|
+
sentAtMs: value.sentAtMs,
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function parseRouteAgentMessageEnvelope(
|
|
1665
|
+
value: Record<string, unknown>,
|
|
1666
|
+
requestId: string,
|
|
1667
|
+
): TelegramBusEnvelope | undefined {
|
|
1668
|
+
const messageValue = isRecord(value.message) ? value.message : undefined;
|
|
1669
|
+
const target = parseThreadTarget(messageValue?.target);
|
|
1670
|
+
if (
|
|
1671
|
+
typeof value.instanceId !== "string" ||
|
|
1672
|
+
!messageValue ||
|
|
1673
|
+
!target ||
|
|
1674
|
+
typeof messageValue.messageId !== "number" ||
|
|
1675
|
+
!Number.isInteger(messageValue.messageId) ||
|
|
1676
|
+
messageValue.messageId <= 0 ||
|
|
1677
|
+
typeof messageValue.text !== "string" ||
|
|
1678
|
+
!messageValue.text.trim() ||
|
|
1679
|
+
typeof value.sentAtMs !== "number"
|
|
1680
|
+
) {
|
|
1681
|
+
return undefined;
|
|
1682
|
+
}
|
|
1683
|
+
return {
|
|
1684
|
+
kind: "follower.routeAgentMessage",
|
|
1685
|
+
requestId,
|
|
1686
|
+
instanceId: value.instanceId,
|
|
1687
|
+
...(typeof value.registrationGeneration === "string"
|
|
1688
|
+
? { registrationGeneration: value.registrationGeneration }
|
|
1689
|
+
: {}),
|
|
1690
|
+
message: {
|
|
1691
|
+
target,
|
|
1692
|
+
messageId: messageValue.messageId,
|
|
1693
|
+
text: messageValue.text,
|
|
1694
|
+
},
|
|
1695
|
+
sentAtMs: value.sentAtMs,
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1585
1699
|
function parseCallApiEnvelope(
|
|
1586
1700
|
value: Record<string, unknown>,
|
|
1587
1701
|
requestId: string,
|
|
@@ -9,6 +9,10 @@ import { basename } from "node:path";
|
|
|
9
9
|
|
|
10
10
|
import { Type } from "@sinclair/typebox";
|
|
11
11
|
|
|
12
|
+
import type {
|
|
13
|
+
TelegramBusAgentMessage,
|
|
14
|
+
TelegramBusAgentTargetSelector,
|
|
15
|
+
} from "./bus.ts";
|
|
12
16
|
import type { ExtensionAPI } from "./pi.ts";
|
|
13
17
|
import {
|
|
14
18
|
TELEGRAM_ATTACH_PROMPT_GUIDELINES,
|
|
@@ -84,6 +88,10 @@ export interface TelegramOutboundMessageToolRegistrationDeps extends TelegramOut
|
|
|
84
88
|
getActiveTurn?: () =>
|
|
85
89
|
| { chatId: number; target?: TelegramTarget }
|
|
86
90
|
| undefined;
|
|
91
|
+
resolveAgentTarget?: (
|
|
92
|
+
selector: TelegramBusAgentTargetSelector,
|
|
93
|
+
) => Promise<TelegramTarget & { threadId: number }>;
|
|
94
|
+
routeAgentMessage?: (message: TelegramBusAgentMessage) => Promise<void>;
|
|
87
95
|
canSendDirect: () => boolean;
|
|
88
96
|
planMessage: (markdown: string) => TelegramOutboundMessagePlan;
|
|
89
97
|
sendMarkdownMessage: (
|
|
@@ -515,6 +523,12 @@ export function registerTelegramOutboundMessageTool(
|
|
|
515
523
|
description: "Optional Telegram topic thread id with chat_id",
|
|
516
524
|
}),
|
|
517
525
|
),
|
|
526
|
+
thread: Type.Optional(
|
|
527
|
+
Type.Union([Type.String(), Type.Number()], {
|
|
528
|
+
description:
|
|
529
|
+
"Optional live Pi thread name or numeric id for visible delivery plus a target-agent turn",
|
|
530
|
+
}),
|
|
531
|
+
),
|
|
518
532
|
}),
|
|
519
533
|
async execute(_toolCallId, params) {
|
|
520
534
|
try {
|
|
@@ -522,9 +536,12 @@ export function registerTelegramOutboundMessageTool(
|
|
|
522
536
|
text: params.text,
|
|
523
537
|
chatId: params.chat_id,
|
|
524
538
|
threadId: params.thread_id,
|
|
539
|
+
agentThread: params.thread,
|
|
525
540
|
getDefaultChatId: deps.getDefaultChatId,
|
|
526
541
|
getDefaultTarget: deps.getDefaultTarget,
|
|
527
542
|
getActiveTurn: deps.getActiveTurn,
|
|
543
|
+
resolveAgentTarget: deps.resolveAgentTarget,
|
|
544
|
+
routeAgentMessage: deps.routeAgentMessage,
|
|
528
545
|
canSendDirect: deps.canSendDirect,
|
|
529
546
|
planMessage: deps.planMessage,
|
|
530
547
|
sendMarkdownMessage: deps.sendMarkdownMessage,
|
|
@@ -736,12 +753,17 @@ export async function sendTelegramOutboundMessage(options: {
|
|
|
736
753
|
text: string;
|
|
737
754
|
chatId?: number;
|
|
738
755
|
threadId?: number;
|
|
756
|
+
agentThread?: string | number;
|
|
739
757
|
target?: TelegramTarget;
|
|
740
758
|
getDefaultChatId?: () => number | undefined;
|
|
741
759
|
getDefaultTarget?: () => TelegramTarget | undefined;
|
|
742
760
|
getActiveTurn?: () =>
|
|
743
761
|
| { chatId: number; target?: TelegramTarget }
|
|
744
762
|
| undefined;
|
|
763
|
+
resolveAgentTarget?: (
|
|
764
|
+
selector: TelegramBusAgentTargetSelector,
|
|
765
|
+
) => Promise<TelegramTarget & { threadId: number }>;
|
|
766
|
+
routeAgentMessage?: (message: TelegramBusAgentMessage) => Promise<void>;
|
|
745
767
|
canSendDirect: () => boolean;
|
|
746
768
|
planMessage: (markdown: string) => TelegramOutboundMessagePlan;
|
|
747
769
|
sendMarkdownMessage: (
|
|
@@ -754,17 +776,41 @@ export async function sendTelegramOutboundMessage(options: {
|
|
|
754
776
|
details: { chatId: number; messageId?: number };
|
|
755
777
|
}> {
|
|
756
778
|
assertTelegramDirectDeliveryAllowed(options.canSendDirect);
|
|
779
|
+
const activeTurn = options.getActiveTurn?.();
|
|
780
|
+
const requestedAgentSelector: TelegramBusAgentTargetSelector | undefined =
|
|
781
|
+
options.agentThread !== undefined
|
|
782
|
+
? typeof options.agentThread === "number"
|
|
783
|
+
? { chatId: options.chatId, threadId: options.agentThread }
|
|
784
|
+
: { chatId: options.chatId, threadName: options.agentThread }
|
|
785
|
+
: activeTurn &&
|
|
786
|
+
options.resolveAgentTarget &&
|
|
787
|
+
options.routeAgentMessage &&
|
|
788
|
+
((options.target?.threadId !== undefined) ||
|
|
789
|
+
(options.chatId !== undefined && options.threadId !== undefined))
|
|
790
|
+
? {
|
|
791
|
+
chatId: options.target?.chatId ?? options.chatId,
|
|
792
|
+
threadId: options.target?.threadId ?? options.threadId,
|
|
793
|
+
}
|
|
794
|
+
: undefined;
|
|
795
|
+
let agentTarget: (TelegramTarget & { threadId: number }) | undefined;
|
|
796
|
+
if (requestedAgentSelector) {
|
|
797
|
+
if (!options.resolveAgentTarget || !options.routeAgentMessage) {
|
|
798
|
+
throw new Error("Telegram agent turn routing is unavailable.");
|
|
799
|
+
}
|
|
800
|
+
agentTarget = await options.resolveAgentTarget(requestedAgentSelector);
|
|
801
|
+
}
|
|
757
802
|
const { chatId, target } = resolveTelegramOutboundTarget({
|
|
758
|
-
chatId: options.chatId,
|
|
759
|
-
threadId: options.threadId,
|
|
760
|
-
target: options.target,
|
|
803
|
+
chatId: agentTarget?.chatId ?? options.chatId,
|
|
804
|
+
threadId: agentTarget?.threadId ?? options.threadId,
|
|
805
|
+
target: agentTarget ?? options.target,
|
|
761
806
|
getDefaultChatId: options.getDefaultChatId,
|
|
762
807
|
getDefaultTarget: options.getDefaultTarget,
|
|
763
808
|
});
|
|
764
|
-
const activeTurn = options.getActiveTurn?.();
|
|
765
809
|
if (activeTurn) {
|
|
766
810
|
const hasExplicitTarget =
|
|
767
|
-
options.chatId !== undefined ||
|
|
811
|
+
options.chatId !== undefined ||
|
|
812
|
+
options.target !== undefined ||
|
|
813
|
+
options.agentThread !== undefined;
|
|
768
814
|
const activeTarget = activeTurn.target ?? { chatId: activeTurn.chatId };
|
|
769
815
|
const requestedTarget = target ?? { chatId };
|
|
770
816
|
const targetsMatch =
|
|
@@ -781,6 +827,18 @@ export async function sendTelegramOutboundMessage(options: {
|
|
|
781
827
|
replyMarkup: plan.replyMarkup,
|
|
782
828
|
target,
|
|
783
829
|
});
|
|
830
|
+
if (agentTarget) {
|
|
831
|
+
if (messageId === undefined) {
|
|
832
|
+
throw new Error(
|
|
833
|
+
"Telegram message was sent but no message id was returned for agent routing.",
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
await options.routeAgentMessage!({
|
|
837
|
+
target: agentTarget,
|
|
838
|
+
messageId,
|
|
839
|
+
text: options.text,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
784
842
|
return {
|
|
785
843
|
content: [
|
|
786
844
|
{
|
package/lib/prompts.ts
CHANGED
|
@@ -29,9 +29,9 @@ export const TELEGRAM_MESSAGE_PROMPT_SNIPPET =
|
|
|
29
29
|
"Send direct Telegram Markdown text when the user explicitly asks for Telegram delivery outside the normal reply flow.";
|
|
30
30
|
export const TELEGRAM_MESSAGE_PROMPT_GUIDELINES = [
|
|
31
31
|
"Use telegram_message only when the user explicitly asks to send a message to Telegram from the local/TUI side, or names a concrete Telegram delivery target.",
|
|
32
|
-
"For
|
|
32
|
+
"For a live Pi thread target, provide thread as its case-insensitive name or numeric id; the bridge sends visibly and admits one attributed turn to that live instance. Unknown, ambiguous, same, or offline targets fail before sending.",
|
|
33
33
|
"Add buttons by embedding the same top-level telegram_button HTML comments used in normal Telegram replies; Telegram does not support standalone buttons.",
|
|
34
|
-
"During an active Telegram turn, omit telegram_message for the current target and answer normally; use
|
|
34
|
+
"During an active Telegram turn, omit telegram_message for the current target and answer normally; use thread only when the user requests delivery to a different live Pi thread.",
|
|
35
35
|
] as const;
|
|
36
36
|
|
|
37
37
|
const TELEGRAM_MODEL_CONTEXT_TOOL_NAMES = new Set([
|
package/lib/sync.ts
CHANGED
|
@@ -167,6 +167,7 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
167
167
|
>(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string> {
|
|
168
168
|
return async () => {
|
|
169
169
|
const currentRecord = deps.getCurrentThreadRecord();
|
|
170
|
+
let cleanupPending = false;
|
|
170
171
|
if (currentRecord?.target.threadId) {
|
|
171
172
|
const isManualFollower = currentRecord.owner?.kind === "manual-follower";
|
|
172
173
|
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
@@ -228,11 +229,7 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
228
229
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
229
230
|
},
|
|
230
231
|
);
|
|
231
|
-
|
|
232
|
-
throw new Error(
|
|
233
|
-
"Telegram thread deletion was not confirmed; inspect /telegram-status --debug and retry /telegram-disconnect.",
|
|
234
|
-
);
|
|
235
|
-
}
|
|
232
|
+
cleanupPending = Boolean(cleanup.incompleteActions?.length);
|
|
236
233
|
}
|
|
237
234
|
const leaderTarget = deps.getLeaderTarget();
|
|
238
235
|
if (
|
|
@@ -248,7 +245,10 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
248
245
|
}) as TSyncState,
|
|
249
246
|
);
|
|
250
247
|
}
|
|
251
|
-
|
|
248
|
+
const stopped = await deps.stopPolling();
|
|
249
|
+
return cleanupPending
|
|
250
|
+
? `${stopped} Telegram thread cleanup remains pending for the next leader.`
|
|
251
|
+
: stopped;
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
254
|
|