@llblab/pi-telegram 0.18.5 → 0.18.6
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 +3 -2
- package/BACKLOG.md +5 -10
- package/CHANGELOG.md +16 -0
- package/docs/multi-instance-bus.md +23 -2
- package/index.ts +34 -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/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 +43 -2
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -126,6 +126,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
126
126
|
- Keep globally reusable rules as independent bullets in the most general applicable section of `AGENTS.md`; do not hide a cross-cutting rule inside one local feature paragraph just because that feature revealed it.
|
|
127
127
|
- Local/domain bullets should state how a general rule applies there, or record domain-specific exceptions; they should not be the only source for a rule that applies to multiple tools, transports, domains, or docs surfaces.
|
|
128
128
|
- Pi-facing tool results shown in compact tool rows should start with exactly one leading newline (`\nResult`) so the result is visually separated from the tool-call header by one blank line without excessive vertical spacing.
|
|
129
|
+
- For non-trivial implementation, release, or architecture-audit work, run an `AGENTS.md` compliance pass: reread the durable principles and relevant project docs, map the changed behavior to the rules it touches, validate code/tests/docs against those rules, and explicitly surface any rule conflict, obsolete rule, or evidence-backed improvement. When the rules themselves drift from the product reality, update `AGENTS.md` or document a deliberate exception in the same pass instead of silently working around it.
|
|
129
130
|
|
|
130
131
|
## 6.2 Validation Hotspots
|
|
131
132
|
|
|
@@ -221,14 +222,14 @@ The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/ar
|
|
|
221
222
|
- `Section domain ownership`: `lib/sections.ts` owns the registry, token mapping, callback dispatch, and context building. `lib/menu.ts` dispatches `section:` callbacks before built-in handling. `lib/menu-status.ts` injects section rows. `lib/menu-settings.ts` injects settings rows and passes `sectionRegistry` through callback deps
|
|
222
223
|
- `Callback routing order`: button actions → compact confirmations → queue menu → settings menu → section callbacks → built-in menu handling → `[callback]` fallback. Settings menu callbacks always pass `sectionRegistry` to `updateTelegramSettingsMenuMessage` and `handleTelegramSettingsMenuCallbackAction`
|
|
223
224
|
|
|
224
|
-
##
|
|
225
|
+
## 10. Pre-Task Preparation Protocol
|
|
225
226
|
|
|
226
227
|
- Read `README.md` for current user-facing behavior and fork positioning
|
|
227
228
|
- Read `BACKLOG.md` before changing runtime behavior or documentation so open work stays truthful
|
|
228
229
|
- Read `/docs/architecture.md` before restructuring queue, preview, rendering, or command-handling logic
|
|
229
230
|
- Inspect the relevant `index.ts` section before editing because most bridge behavior is stateful and cross-linked
|
|
230
231
|
|
|
231
|
-
##
|
|
232
|
+
## 11. Task Completion Protocol
|
|
232
233
|
|
|
233
234
|
- Run the smallest meaningful validation for the touched area; `npm test` is the default regression suite once rendering or queue logic changes
|
|
234
235
|
- For rendering changes, ensure regressions still cover nested lists, code blocks, underscore-heavy text, and long-message chunking
|
package/BACKLOG.md
CHANGED
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
# Project Backlog
|
|
2
2
|
|
|
3
|
-
_Current deterministic status: Threaded Mode implementation, native typing/activity status, regression coverage, docs/context reconciliation, typecheck, full tests, pack check, audit, Domain DAG validation, context validation,
|
|
3
|
+
_Current deterministic status: Threaded Mode implementation, native typing/activity status, regression coverage, docs/context reconciliation, typecheck, full tests, pack check, audit, Domain DAG validation, context validation, native Windows classic↔Threaded Mode upgrade/downgrade smoke, and live post-reload leader/follower prompt routing, follower Active parity, and unbound reroute/restore smoke are green. This backlog intentionally tracks only release-relevant remaining work: evidence-gated Telegram client/runtime follow-ups and upstream Pi API blockers._
|
|
4
4
|
|
|
5
|
-
## P0 —
|
|
5
|
+
## P0 — Promoted Follower Reload Smoke
|
|
6
6
|
|
|
7
|
-
Context:
|
|
7
|
+
Context: deterministic coverage protects promoted follower thread preservation, and the latest live Linux smoke closed the reload routing, follower Active, and reroute/restore regressions. Keep one explicit live check for promoted-follower reload identity unless that exact path is covered in the release smoke pass.
|
|
8
8
|
|
|
9
9
|
Open work:
|
|
10
10
|
|
|
11
|
-
- [ ]
|
|
12
|
-
- [ ] dirty-state pass with old `state.json`/`logs.jsonl` present to prove live locks, bus registration, target ownership, and reconciliation override stale diagnostics;
|
|
13
|
-
- [ ] leader reload recovers without duplicate visible thread renames;
|
|
14
|
-
- [ ] prompts dispatch without a second command;
|
|
15
|
-
- [ ] status remains stable around thread name and role while active turns start/end.
|
|
16
|
-
- [ ] Add/keep proactive race protections so `state.json` never becomes authoritative over live lock ownership, bus registration, or current target identity.
|
|
11
|
+
- [ ] Close leader → follower promotes → `/reload` promoted leader preserves the same Telegram thread identity.
|
|
17
12
|
|
|
18
|
-
Done when:
|
|
13
|
+
Done when: promoted-follower reload identity has live Telegram evidence or is deliberately deferred out of the hotfix scope.
|
|
19
14
|
|
|
20
15
|
## P1 — Native Windows Threaded Mode Follow-Ups
|
|
21
16
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.18.6: Threaded Mode parity hotfix
|
|
6
|
+
|
|
7
|
+
- `[Threaded Mode]` Follower-routed prompts now record message ownership when the leader forwards them by thread target, and edited messages can route by stored message ownership when Telegram omits thread identity. Impact: later queue-control reactions and queued-prompt edits on the original Telegram message route back to the owning follower instead of being handled by the leader.
|
|
8
|
+
- `[Native Activity Status]` Follower typing/activity startup now sends one thread-scoped action plus one aggregate action, and the default keepalive cadence is back to 2.5 seconds. Impact: follower `...active` status avoids duplicate chat-action bursts that can hit Telegram rate limits.
|
|
9
|
+
- `[Threaded Mode]` Follower `/start` can now register the visible bot command menu through the leader bus without a warning before opening the local menu, follower-sent Bot API messages are recorded in the leader's ownership map, and follower message edit/delete calls used by menu surfaces are allowed as validated same-chat message operations. Impact: follower menu callbacks and interactive cleanup route back to the owning follower even when Telegram callback payloads omit thread identity, without false bus-allowlist failures.
|
|
10
|
+
- `[Threaded Mode]` Follower promotion now snapshots the current follower thread binding before clearing registration state and converts that binding into the leader profile before forced lock acquisition. Impact: an elected follower keeps its existing thread identity across promotion and later reload instead of becoming disconnected or provisioning a fresh leader thread.
|
|
11
|
+
- `[Threaded Mode]` Follower registration now probes a reused same-profile thread with the connected notice before reporting it as reusable, and recreates the thread when Telegram reports the old target as stale. Impact: reconnecting a follower after its Telegram tab was closed no longer leaves the terminal showing a follower thread name while no matching Telegram thread is visible.
|
|
12
|
+
- `[Threaded Mode]` Unbound-thread reroute controls now expose replace/restore for the current live bus roster instead of requiring prior leader reroute confirmation. Impact: when a new unbound Telegram thread appears, the operator can choose any currently live leader/follower instance to route to or replace/restore into that source thread.
|
|
13
|
+
- `[Status]` Follower terminal status now gives active/compacting processing labels precedence over the stable follower role, matching leader behavior. Impact: a follower shows its thread name plus `active` while processing a Telegram-originated prompt, then returns to `follower` when idle.
|
|
14
|
+
- `[Threaded Mode]` Reload-time target ownership now classifies only follower-owned records as follower targets and replaces stale same-profile/same-target follower registry entries on registration. Impact: after leader or follower reload, Telegram prompts and reroutes no longer get forwarded to dead instance ids or report a live thread as unavailable.
|
|
15
|
+
- `[Validation]` Live Linux Threaded Mode smoke now passes for post-reload leader/follower prompt routing, unbound reroute/restore, and leader/follower terminal Active status. Impact: the hotfix candidate has Telegram-client evidence for the regressions fixed after `0.18.5`.
|
|
16
|
+
- `[Docs]` Added a leader/follower capability parity matrix covering prompts, queue reactions, edits, callbacks, replies, previews, attachments, native activity, command/menu bootstrap, and diagnostics. Impact: follower behavior now has an explicit parity checklist instead of being inferred from leader behavior.
|
|
17
|
+
- `[Engineering]` Formalized an `AGENTS.md` compliance pass for non-trivial work and cleaned unused runtime/lifecycle test surfaces surfaced by strict no-unused diagnostics. Impact: future changes have an explicit project-rule audit step, and dead-code diagnostics stay actionable.
|
|
18
|
+
|
|
3
19
|
## 0.18.5: Windows Threaded Mode stabilization hotfix
|
|
4
20
|
|
|
5
21
|
- `[Bus Transport]` Introduced an explicit local bus transport boundary for endpoint derivation, socket-vs-pipe detection, operation-aware retry policy, timeout/transient IPC error classification, endpoint reachability probes, request-scoped server/client transport events, and handler-failure ACKs instead of silent client timeouts. Impact: Unix socket behavior remains the stable baseline while Windows named-pipe readiness and retry behavior are contained in the transport layer instead of leaking into routing.
|
|
@@ -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
|
@@ -662,6 +662,9 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
662
662
|
return telegramInstanceId;
|
|
663
663
|
},
|
|
664
664
|
getMessageOwnership: messageOwnershipStore.get,
|
|
665
|
+
recordMessageOwnership(input) {
|
|
666
|
+
messageOwnershipStore.record(input);
|
|
667
|
+
},
|
|
665
668
|
getTargetOwnership(target) {
|
|
666
669
|
return Bus.getTelegramFollowerTargetOwnership({
|
|
667
670
|
target,
|
|
@@ -821,7 +824,29 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
821
824
|
telegramBusLifecycleOverridePhase = phase;
|
|
822
825
|
},
|
|
823
826
|
updateStatus,
|
|
824
|
-
async promoteToLeader(ctx) {
|
|
827
|
+
async promoteToLeader(ctx, binding) {
|
|
828
|
+
const promotedRecord =
|
|
829
|
+
await Threads.promoteTelegramFollowerBindingToLeader({
|
|
830
|
+
store: threadStore,
|
|
831
|
+
instanceId: telegramInstanceId,
|
|
832
|
+
cwd: ctx.cwd,
|
|
833
|
+
target: binding.target,
|
|
834
|
+
slot: binding.slot,
|
|
835
|
+
threadName: binding.threadName,
|
|
836
|
+
});
|
|
837
|
+
if (promotedRecord) {
|
|
838
|
+
recordRuntimeEvent(
|
|
839
|
+
"bus",
|
|
840
|
+
"Follower thread binding promoted to leader",
|
|
841
|
+
{
|
|
842
|
+
phase: "follower-promoted-binding",
|
|
843
|
+
chatId: promotedRecord.target.chatId,
|
|
844
|
+
threadId: promotedRecord.target.threadId,
|
|
845
|
+
slot: promotedRecord.slot,
|
|
846
|
+
threadName: promotedRecord.threadName,
|
|
847
|
+
},
|
|
848
|
+
);
|
|
849
|
+
}
|
|
825
850
|
await lockedPollingRuntime.start(ctx, { force: true });
|
|
826
851
|
},
|
|
827
852
|
sleep(ms) {
|
|
@@ -898,6 +923,14 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
898
923
|
startPolling: pollingRuntime.start,
|
|
899
924
|
stopPolling: pollingRuntime.stop,
|
|
900
925
|
authorizeFollowerApiCall: Bus.isTelegramFollowerApiCallAllowed,
|
|
926
|
+
recordFollowerMessageOwnership(record) {
|
|
927
|
+
messageOwnershipStore.record({
|
|
928
|
+
chatId: record.chatId,
|
|
929
|
+
messageId: record.messageId,
|
|
930
|
+
target: record.target,
|
|
931
|
+
instanceId: record.follower.instanceId,
|
|
932
|
+
});
|
|
933
|
+
},
|
|
901
934
|
provisionLeaderTarget:
|
|
902
935
|
BusLeader.createTelegramBusLeaderTargetProvisioner<Pi.ExtensionContext>({
|
|
903
936
|
getAllowedUserId: configStore.getAllowedUserId,
|
|
@@ -1273,8 +1306,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1273
1306
|
sendRecordVoiceAction,
|
|
1274
1307
|
sendMarkdownReply,
|
|
1275
1308
|
sendTextReply,
|
|
1276
|
-
editInteractiveMessage,
|
|
1277
|
-
deleteMessage: deleteTelegramMessage,
|
|
1278
1309
|
dispatchNextQueuedTelegramTurn,
|
|
1279
1310
|
answerGuestQuery,
|
|
1280
1311
|
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/routing.ts
CHANGED
|
@@ -217,21 +217,13 @@ function formatTelegramAllTabMenuChooserText(command: string): string {
|
|
|
217
217
|
function buildTelegramUnboundRerouteChooserMarkup(
|
|
218
218
|
rerouteId: string,
|
|
219
219
|
records: readonly Threads.TelegramTopicTargetRecord[],
|
|
220
|
-
|
|
220
|
+
_options: {
|
|
221
221
|
currentLeaderProfileKey?: string;
|
|
222
222
|
currentInstanceId?: string;
|
|
223
223
|
} = {},
|
|
224
224
|
): Menu.TelegramReplyMarkup {
|
|
225
225
|
const activeRecords = records.filter((record) => record.status === "active");
|
|
226
|
-
const
|
|
227
|
-
(record) =>
|
|
228
|
-
typeof record.rerouteConfirmedAtMs === "number" &&
|
|
229
|
-
isCurrentLeaderTopicRecord(
|
|
230
|
-
record,
|
|
231
|
-
options.currentLeaderProfileKey,
|
|
232
|
-
options.currentInstanceId,
|
|
233
|
-
),
|
|
234
|
-
);
|
|
226
|
+
const canRestoreAnyLiveThread = activeRecords.length > 0;
|
|
235
227
|
const rows = activeRecords.map((record) => [
|
|
236
228
|
{
|
|
237
229
|
text: getTelegramRouteThreadButtonLabel(record),
|
|
@@ -242,7 +234,7 @@ function buildTelegramUnboundRerouteChooserMarkup(
|
|
|
242
234
|
},
|
|
243
235
|
]);
|
|
244
236
|
return {
|
|
245
|
-
inline_keyboard:
|
|
237
|
+
inline_keyboard: canRestoreAnyLiveThread
|
|
246
238
|
? [
|
|
247
239
|
...rows,
|
|
248
240
|
[
|
|
@@ -414,6 +406,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
414
406
|
getCurrentInstanceId?: () => string | undefined;
|
|
415
407
|
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
|
|
416
408
|
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
|
|
409
|
+
recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
|
|
417
410
|
getLiveThreadTargets?: () => Queue.TelegramQueueTarget[];
|
|
418
411
|
getLocalThreadLabelForTarget?: (
|
|
419
412
|
target: Queue.TelegramQueueTarget,
|
|
@@ -1861,6 +1854,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1861
1854
|
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
1862
1855
|
getMessageOwnership: deps.getMessageOwnership,
|
|
1863
1856
|
getTargetOwnership: deps.getTargetOwnership,
|
|
1857
|
+
recordMessageOwnership: deps.recordMessageOwnership,
|
|
1864
1858
|
handleTelegramTopicLifecycleUpdate,
|
|
1865
1859
|
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
|
|
1866
1860
|
setAllowedUserId: deps.configStore.setAllowedUserId,
|
package/lib/runtime.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const TELEGRAM_TYPING_ACTION_INTERVAL_MS =
|
|
7
|
+
const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 2500;
|
|
8
8
|
const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
|
|
9
9
|
|
|
10
10
|
export interface TelegramRuntimeQueueCounters {
|
|
@@ -398,13 +398,9 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
398
398
|
chatId: chatId ?? deps.getDefaultChatId(),
|
|
399
399
|
target: options?.target,
|
|
400
400
|
intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
|
|
401
|
-
sendTypingAction: async (targetChatId) => {
|
|
401
|
+
sendTypingAction: async (targetChatId, actionOptions) => {
|
|
402
402
|
try {
|
|
403
|
-
|
|
404
|
-
await deps.sendTypingAction(targetChatId, threadParams);
|
|
405
|
-
if (threadParams?.message_thread_id !== undefined) {
|
|
406
|
-
await deps.sendAggregateTypingAction?.(targetChatId);
|
|
407
|
-
}
|
|
403
|
+
await deps.sendTypingAction(targetChatId, actionOptions);
|
|
408
404
|
} catch (error) {
|
|
409
405
|
const message =
|
|
410
406
|
error instanceof Error ? error.message : String(error);
|
|
@@ -419,6 +415,26 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
419
415
|
});
|
|
420
416
|
}
|
|
421
417
|
},
|
|
418
|
+
sendAggregateTypingAction: deps.sendAggregateTypingAction
|
|
419
|
+
? async (targetChatId) => {
|
|
420
|
+
try {
|
|
421
|
+
await deps.sendAggregateTypingAction?.(targetChatId);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
const message =
|
|
424
|
+
error instanceof Error ? error.message : String(error);
|
|
425
|
+
updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
|
|
426
|
+
error: message,
|
|
427
|
+
category: "typing",
|
|
428
|
+
phase: "status-update",
|
|
429
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
430
|
+
});
|
|
431
|
+
deps.recordRuntimeEvent?.("typing", error, {
|
|
432
|
+
chatId: targetChatId,
|
|
433
|
+
aggregate: true,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
: undefined,
|
|
422
438
|
});
|
|
423
439
|
};
|
|
424
440
|
}
|
package/lib/status.ts
CHANGED
|
@@ -771,8 +771,6 @@ export function buildTelegramStatusBarText(
|
|
|
771
771
|
return `${label} ${theme.fg("warning", "electing")}${queued}`;
|
|
772
772
|
if (!state.pollingActive && state.busRole !== "follower")
|
|
773
773
|
return `${label} ${theme.fg("muted", "disconnected")}${queued}`;
|
|
774
|
-
if (state.busRole === "follower")
|
|
775
|
-
return `${label} ${theme.fg("success", "follower")}${queued}`;
|
|
776
774
|
if (state.compactionInProgress) {
|
|
777
775
|
return `${label} ${theme.fg("warning", "compacting")}${queued}`;
|
|
778
776
|
}
|
|
@@ -784,6 +782,8 @@ export function buildTelegramStatusBarText(
|
|
|
784
782
|
processingStatus === "active" ? "warning" : "accent";
|
|
785
783
|
return `${label} ${theme.fg(processingToken, processingStatus)}${queued}`;
|
|
786
784
|
}
|
|
785
|
+
if (state.busRole === "follower")
|
|
786
|
+
return `${label} ${theme.fg("success", "follower")}${queued}`;
|
|
787
787
|
if (state.busRole === "leader")
|
|
788
788
|
return `${label} ${theme.fg("success", "leader")}`;
|
|
789
789
|
return `${label} ${theme.fg("success", "connected")}`;
|
package/lib/threads.ts
CHANGED
|
@@ -1438,7 +1438,7 @@ function getTelegramThreadNameEntropyIndex(
|
|
|
1438
1438
|
|
|
1439
1439
|
export function getTelegramTopicThreadNameValidationError(
|
|
1440
1440
|
threadName: string,
|
|
1441
|
-
|
|
1441
|
+
_slot: string | undefined,
|
|
1442
1442
|
): string | undefined {
|
|
1443
1443
|
const identity = getTelegramTopicIdentityName(threadName);
|
|
1444
1444
|
const reasons: string[] = [];
|
|
@@ -1501,6 +1501,60 @@ function asInteger(value: unknown): number | undefined {
|
|
|
1501
1501
|
return Number.isInteger(parsed) ? parsed : undefined;
|
|
1502
1502
|
}
|
|
1503
1503
|
|
|
1504
|
+
export interface TelegramPromoteFollowerBindingToLeaderDeps {
|
|
1505
|
+
store: TelegramTopicTargetStore;
|
|
1506
|
+
instanceId: string;
|
|
1507
|
+
cwd?: string;
|
|
1508
|
+
target?: TelegramTarget;
|
|
1509
|
+
slot?: string;
|
|
1510
|
+
threadName?: string;
|
|
1511
|
+
nowMs?: number;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
export async function promoteTelegramFollowerBindingToLeader(
|
|
1515
|
+
deps: TelegramPromoteFollowerBindingToLeaderDeps,
|
|
1516
|
+
): Promise<TelegramTopicTargetRecord | undefined> {
|
|
1517
|
+
const target = deps.target;
|
|
1518
|
+
if (typeof target?.threadId !== "number") return undefined;
|
|
1519
|
+
await deps.store.load();
|
|
1520
|
+
const nowMs = deps.nowMs ?? Date.now();
|
|
1521
|
+
const existing = deps.store
|
|
1522
|
+
.list()
|
|
1523
|
+
.find(
|
|
1524
|
+
(record) =>
|
|
1525
|
+
record.target.chatId === target.chatId &&
|
|
1526
|
+
record.target.threadId === target.threadId,
|
|
1527
|
+
);
|
|
1528
|
+
const owner: TelegramThreadOwner = {
|
|
1529
|
+
kind: "leader",
|
|
1530
|
+
cwd: deps.cwd,
|
|
1531
|
+
instanceId: deps.instanceId,
|
|
1532
|
+
};
|
|
1533
|
+
const record = deps.store.upsert({
|
|
1534
|
+
profileKey: getTelegramThreadOwnerKey(owner),
|
|
1535
|
+
owner,
|
|
1536
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
1537
|
+
status: "active",
|
|
1538
|
+
createdAtMs: existing?.createdAtMs ?? nowMs,
|
|
1539
|
+
updatedAtMs: nowMs,
|
|
1540
|
+
...(existing?.threadName ?? deps.threadName
|
|
1541
|
+
? { threadName: existing?.threadName ?? deps.threadName }
|
|
1542
|
+
: {}),
|
|
1543
|
+
instanceId: deps.instanceId,
|
|
1544
|
+
...(existing?.slot ?? deps.slot ? { slot: existing?.slot ?? deps.slot } : {}),
|
|
1545
|
+
...(existing?.syncStatus ? { syncStatus: existing.syncStatus } : {}),
|
|
1546
|
+
...(existing?.lastSyncObservedAtMs !== undefined
|
|
1547
|
+
? { lastSyncObservedAtMs: existing.lastSyncObservedAtMs }
|
|
1548
|
+
: {}),
|
|
1549
|
+
lastReconcileAction: "follower-promoted-to-leader",
|
|
1550
|
+
...(existing?.rerouteConfirmedAtMs !== undefined
|
|
1551
|
+
? { rerouteConfirmedAtMs: existing.rerouteConfirmedAtMs }
|
|
1552
|
+
: {}),
|
|
1553
|
+
});
|
|
1554
|
+
await deps.store.persist();
|
|
1555
|
+
return record;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1504
1558
|
export interface TelegramOwnTopicProvisionDeps {
|
|
1505
1559
|
getAllowedUserId: () => number | undefined;
|
|
1506
1560
|
instanceId: string;
|
|
@@ -2077,7 +2131,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
2077
2131
|
existing.threadName ?? identityThreadName ?? bakedThreadName,
|
|
2078
2132
|
instanceId: request.instanceId,
|
|
2079
2133
|
slot,
|
|
2080
|
-
owner:
|
|
2134
|
+
owner: request.owner ?? existing.owner,
|
|
2081
2135
|
lastError: undefined,
|
|
2082
2136
|
});
|
|
2083
2137
|
return { target: record.target, reused: true, record };
|
package/lib/updates.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
createTelegramThreadTarget,
|
|
10
10
|
type TelegramTarget,
|
|
11
11
|
} from "./target.ts";
|
|
12
|
+
import type { TelegramMessageOwnershipStore } from "./ownership.ts";
|
|
12
13
|
import {
|
|
13
14
|
createTelegramUserPairingRuntime,
|
|
14
15
|
getTelegramAuthorizationState,
|
|
@@ -538,6 +539,14 @@ export function buildTelegramUpdateExecutionPlanFromUpdate<
|
|
|
538
539
|
|
|
539
540
|
// --- Runtime ---
|
|
540
541
|
|
|
542
|
+
export type TelegramMessageOwnershipRecorderInput = Parameters<
|
|
543
|
+
TelegramMessageOwnershipStore["record"]
|
|
544
|
+
>[0];
|
|
545
|
+
|
|
546
|
+
export type TelegramMessageOwnershipRecorder = (
|
|
547
|
+
input: TelegramMessageOwnershipRecorderInput,
|
|
548
|
+
) => void;
|
|
549
|
+
|
|
541
550
|
export interface TelegramUpdateRuntimeDeps<
|
|
542
551
|
TContext = unknown,
|
|
543
552
|
TReactionUpdate extends TelegramMessageReactionUpdated =
|
|
@@ -549,6 +558,7 @@ export interface TelegramUpdateRuntimeDeps<
|
|
|
549
558
|
getCurrentInstanceId?: () => string | undefined;
|
|
550
559
|
getMessageOwnership?: TelegramMessageOwnershipLookup;
|
|
551
560
|
getTargetOwnership?: TelegramTargetOwnershipLookup;
|
|
561
|
+
recordMessageOwnership?: TelegramMessageOwnershipRecorder;
|
|
552
562
|
foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<
|
|
553
563
|
TContext,
|
|
554
564
|
TReactionUpdate,
|
|
@@ -612,6 +622,7 @@ export interface TelegramUpdateRuntimeControllerDeps<
|
|
|
612
622
|
getCurrentInstanceId?: () => string | undefined;
|
|
613
623
|
getMessageOwnership?: TelegramMessageOwnershipLookup;
|
|
614
624
|
getTargetOwnership?: TelegramTargetOwnershipLookup;
|
|
625
|
+
recordMessageOwnership?: TelegramMessageOwnershipRecorder;
|
|
615
626
|
foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<
|
|
616
627
|
TContext,
|
|
617
628
|
TelegramMessageReactionUpdated,
|
|
@@ -824,6 +835,7 @@ export function createTelegramPairedUpdateRuntime<
|
|
|
824
835
|
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
825
836
|
getMessageOwnership: deps.getMessageOwnership,
|
|
826
837
|
getTargetOwnership: deps.getTargetOwnership,
|
|
838
|
+
recordMessageOwnership: deps.recordMessageOwnership,
|
|
827
839
|
handleTelegramTopicLifecycleUpdate: deps.handleTelegramTopicLifecycleUpdate,
|
|
828
840
|
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
|
|
829
841
|
removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
|
|
@@ -890,6 +902,7 @@ export function createTelegramUpdateRuntime<
|
|
|
890
902
|
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
891
903
|
getMessageOwnership: deps.getMessageOwnership,
|
|
892
904
|
getTargetOwnership: deps.getTargetOwnership,
|
|
905
|
+
recordMessageOwnership: deps.recordMessageOwnership,
|
|
893
906
|
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
|
|
894
907
|
removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
|
|
895
908
|
removeQueuedTelegramTurnsByMessageIds:
|
|
@@ -1109,11 +1122,40 @@ export async function executeTelegramUpdatePlan<
|
|
|
1109
1122
|
}
|
|
1110
1123
|
return;
|
|
1111
1124
|
}
|
|
1125
|
+
const foreignMessageOwnership = getForeignTelegramMessageOwnership(
|
|
1126
|
+
getTelegramMessageReplyTarget(plan.message),
|
|
1127
|
+
deps,
|
|
1128
|
+
);
|
|
1129
|
+
if (foreignMessageOwnership) {
|
|
1130
|
+
if (plan.kind === "edited-message") {
|
|
1131
|
+
await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
|
|
1132
|
+
message: plan.message,
|
|
1133
|
+
ownership: foreignMessageOwnership,
|
|
1134
|
+
ctx: deps.ctx,
|
|
1135
|
+
});
|
|
1136
|
+
} else {
|
|
1137
|
+
await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
|
|
1138
|
+
message: plan.message,
|
|
1139
|
+
ownership: foreignMessageOwnership,
|
|
1140
|
+
ctx: deps.ctx,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
const messageTarget = getTelegramMessageTarget(plan.message);
|
|
1112
1146
|
const foreignTargetOwnership = getForeignTelegramTargetOwnership(
|
|
1113
|
-
|
|
1147
|
+
messageTarget,
|
|
1114
1148
|
deps,
|
|
1115
1149
|
);
|
|
1116
1150
|
if (foreignTargetOwnership) {
|
|
1151
|
+
if (typeof plan.message.message_id === "number") {
|
|
1152
|
+
deps.recordMessageOwnership?.({
|
|
1153
|
+
chatId: messageTarget!.chatId,
|
|
1154
|
+
messageId: plan.message.message_id,
|
|
1155
|
+
target: messageTarget,
|
|
1156
|
+
instanceId: foreignTargetOwnership.instanceId,
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1117
1159
|
if (plan.kind === "edited-message") {
|
|
1118
1160
|
await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
|
|
1119
1161
|
message: plan.message,
|
|
@@ -1129,7 +1171,6 @@ export async function executeTelegramUpdatePlan<
|
|
|
1129
1171
|
}
|
|
1130
1172
|
return;
|
|
1131
1173
|
}
|
|
1132
|
-
const messageTarget = getTelegramMessageTarget(plan.message);
|
|
1133
1174
|
if (
|
|
1134
1175
|
plan.kind === "message" &&
|
|
1135
1176
|
messageTarget?.threadId != null &&
|