@llblab/pi-telegram 0.20.4 → 0.20.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/BACKLOG.md +64 -0
- package/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/docs/architecture.md +7 -7
- package/docs/multi-instance-bus.md +1 -1
- package/docs/outbound.md +6 -0
- package/index.ts +3 -0
- package/lib/bindings.ts +127 -34
- package/lib/bus-api.ts +5 -3
- package/lib/bus-leader.ts +7 -0
- package/lib/bus.ts +43 -1
- package/lib/config.ts +2 -2
- package/lib/lifecycle.ts +4 -4
- package/lib/locks.ts +12 -1
- package/lib/logs.ts +2 -2
- package/lib/outbound-attachments.ts +170 -0
- package/lib/paths.ts +2 -1
- package/lib/queue.ts +46 -1
- package/lib/setup.ts +7 -1
- package/lib/status.ts +15 -14
- package/lib/telegram-api.ts +44 -6
- package/package.json +1 -1
package/BACKLOG.md
CHANGED
|
@@ -2,6 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
_This backlog tracks only open release-relevant work: live promoted-follower verification, evidence-gated Telegram client/runtime follow-ups, and upstream Pi API blockers. Completed validation evidence belongs in `CHANGELOG.md`, not in this queue._
|
|
4
4
|
|
|
5
|
+
## P0 — Private Guest DM Peer Attribution
|
|
6
|
+
|
|
7
|
+
Evidence: live private Guest Mode produced `[telegram|guest:<owner>]` for an owner-authored DM turn even though `guest` must identify the remote conversation peer. Code inspection confirms that private guest routing falls back to `fromPeer` whenever an owner-authored message has no usable `reply_to_message`; because `from.id` then equals the configured `allowedUserId`, the owner is mislabeled as the guest. Existing coverage protects incoming guest messages and owner replies with explicit replied-guest metadata, but does not cover owner-authored private guest messages without reply context.
|
|
8
|
+
|
|
9
|
+
Planned work:
|
|
10
|
+
|
|
11
|
+
- [ ] Capture or minimize the raw private `guest_message` shape for owner-authored turns without reply context and identify the stable remote-peer fields supplied by Telegram (`chat` identity, username/name/id, or another explicit peer field) before choosing a resolver.
|
|
12
|
+
- [ ] Centralize Guest Mode peer attribution: group turns use the group title; private non-owner turns use the sender; private owner turns use the replied guest when present and otherwise the remote private-chat peer.
|
|
13
|
+
- [ ] Compare ownership by Telegram user id (`allowedUserId`), not display name or username. Never emit the configured owner as `guest`; if Telegram omits a username, fall back to the remote peer's stable name/id rather than the owner.
|
|
14
|
+
- [ ] Keep `[reply|from:...]` and `[attachments|from:...]` source attribution aligned with the same resolved peer without changing the current-turn/source-context distinction.
|
|
15
|
+
- [ ] Add regressions for incoming private guests, owner replies, owner-authored no-reply turns, missing usernames, username changes, and named-profile pairing identities.
|
|
16
|
+
- [ ] Update the prompt-context contract/docs only after the minimized Telegram fixture establishes the actual private Guest Mode field semantics.
|
|
17
|
+
|
|
18
|
+
Done when: `[telegram|guest:...]` always identifies the remote peer or group for private/group Guest Mode, never the paired owner, and reply/attachment provenance remains source-correct.
|
|
19
|
+
|
|
20
|
+
## P0 — Guest Reply File And Audio Delivery
|
|
21
|
+
|
|
22
|
+
Evidence: live Guest Mode accepted `telegram_attach` during an active guest turn and reported the file as queued, but delivered nothing. Code inspection confirms that guest turns use sentinel `chatId: 0`; the tool appends files to `queuedAttachments`, then the agent-end guest branch sends only `answerGuestQuery` text and returns before queued attachments or voice artifacts run. Telegram's `answerGuestQuery` accepts one `InlineQueryResult`, not ordinary `sendDocument`/`sendVoice` multipart delivery, so local artifacts require a guest-specific result plan rather than reuse of chat/thread attachment transport.
|
|
23
|
+
|
|
24
|
+
Planned work:
|
|
25
|
+
|
|
26
|
+
- [x] Fail closed immediately for unsupported guest attachments until guest delivery is available; never return `Queued` when the guest agent-end path cannot consume the artifact.
|
|
27
|
+
- [x] Map the current Bot API `InlineQueryResult` capabilities for document, photo, audio, and voice replies, including URL versus cached `file_id`, caption limits, supported formats, and the one-result-per-guest-query constraint. `answerGuestQuery` accepts exactly one result; local multipart uploads are not accepted there. URL results require public HTTP content (documents only PDF/ZIP, audio MP3, voice OGG/OPUS, photos JPEG up to 5 MB), while cached result variants accept Telegram `document_file_id`, `photo_file_id`, `audio_file_id`, or `voice_file_id`; media captions remain limited to 0–1024 characters after entity parsing.
|
|
28
|
+
- [x] Design one guest reply planner that chooses exactly one result: text article, one cached local file/media with answer text reduced to a caption, or one cached synthesized voice/audio result. Guest tool admission rejects a second attachment before mutation. A failure before the one-shot answer may degrade to one text article; an ambiguous/failing `answerGuestQuery` call must not issue a second answer that could duplicate delivery.
|
|
29
|
+
- [x] Determine an evidence-backed local-file staging path. Local media must upload through the existing leader-owned multipart transport to the paired owner's bot chat, extract the returned Telegram `file_id`, answer the guest query with the matching cached result, and delete the staging message in `finally`. The staging message can briefly appear or notify the owner; this unavoidable Bot API limitation must be documented, no external hosting is introduced, and cleanup failure must be diagnosed rather than hidden.
|
|
30
|
+
- [x] Extend `answerGuestQuery` and bus forwarding from hard-coded article input to the minimal typed result union required by confirmed file/audio/voice cases.
|
|
31
|
+
- [x] Route `telegram_attach`, queued outbound artifacts, and `telegram_voice` through the guest planner before the guest branch returns; never call ordinary multipart methods with sentinel `chatId: 0`.
|
|
32
|
+
- [x] Preserve follower operation by routing staging and `answerGuestQuery` through the transport leader without duplicate answers or leaked staging messages.
|
|
33
|
+
- [x] Add regressions for unsupported fail-closed behavior, document/image/audio/voice result construction, caption fallback, staging cleanup/failure, multiple-file rejection, guest query one-shot semantics, and text fallback after media failure.
|
|
34
|
+
- [ ] Capture live private and group Guest Mode evidence for one local document and one synthesized voice/audio reply before claiming support.
|
|
35
|
+
|
|
36
|
+
Done when: guest turns never silently lose queued artifacts, one supported local file or audio/voice result can be delivered through `answerGuestQuery` with clear constraints, and unsupported/multi-file cases fail visibly without sending to an unrelated thread.
|
|
37
|
+
|
|
38
|
+
## P1 — Compaction Status Ownership And Native Activity
|
|
39
|
+
|
|
40
|
+
Context: Pi already renders its own compaction lifecycle, while pi-telegram currently overrides its terminal status row with `compacting` whenever the shared compaction flag is set. This duplicates Pi-owned state and hides the distinction between Telegram-owned activity and unrelated automatic/session compaction. Manual `/compact` already calls the typing-loop port and automatic compaction starts typing only when an active Telegram turn exists, so the reported absence of Telegram `…typing` needs transport-level and live verification rather than an assumed rewrite.
|
|
41
|
+
|
|
42
|
+
Planned work:
|
|
43
|
+
|
|
44
|
+
- [x] Remove `compacting` as a pi-telegram terminal status label while retaining the internal compaction flag for queue/dispatch safety and explicit diagnostics.
|
|
45
|
+
- [x] Track compaction origin for status projection: confirmed Telegram `/compact` and auto-compaction inside a Telegram-owned turn render normal `Active`; local/autonomous/background compaction keeps the stable `connected`, `leader`, or `follower` role.
|
|
46
|
+
- [x] Define and verify the native activity matrix: Telegram-owned compaction targets the invoking/active thread plus `All`; non-Telegram compaction uses the connected instance target plus `All` without changing terminal role semantics.
|
|
47
|
+
- [x] Trace manual confirmation, `session_before_compact`, `session_compact`, completion, error, timeout, abort, and shutdown ordering to ensure one keyed typing loop remains active for the whole compaction window and always stops.
|
|
48
|
+
- [x] Add transport-level regressions that assert actual `sendChatAction(typing)` targets and keepalive lifecycle, not only invocation of a mocked `startTypingLoop` callback.
|
|
49
|
+
- [x] Replace status tests that currently require `compacting` with Telegram-owned `Active` and non-Telegram stable-role cases; preserve `/telegram-status` compaction diagnostics where operationally useful.
|
|
50
|
+
- [ ] Capture live evidence for manual Telegram compaction, auto-compaction during a Telegram turn, and non-Telegram auto-compaction before finalizing the activity contract.
|
|
51
|
+
|
|
52
|
+
Done when: Pi remains the only terminal owner of the `compacting` label, pi-telegram status reflects Telegram ownership rather than generic compaction, and Telegram native `…typing` remains visible and correctly targeted throughout every confirmed compaction class without leaking afterward.
|
|
53
|
+
|
|
54
|
+
## P1 — Leader Endpoint Loss Recovery
|
|
55
|
+
|
|
56
|
+
Context: live evidence showed a process retaining a fresh transport lock and active polling while its Threaded Mode Unix socket path was absent. The likely trigger was external removal of the shared Telegram temp directory while the owner process remained alive. The local server keeps listening on the unlinked Unix socket but `start()` treats its in-memory server handle as sufficient, leader health checks only Bot API transport, and a new instance therefore exhausts follower-registration retries with `ENOENT`. This is a real diagnosable recovery gap, but not yet evidence for a broad readiness protocol or automatic takeover; force-acquiring while the old owner may still run `getUpdates` would risk split-brain.
|
|
57
|
+
|
|
58
|
+
Planned work:
|
|
59
|
+
|
|
60
|
+
- [x] Reproduce deterministically by unlinking only the active Unix leader socket while its process, polling runtime, and in-memory server remain live. Native Windows named pipes have no equivalent filesystem path to unlink, so recovery remains Unix-specific unless separate named-pipe evidence appears.
|
|
61
|
+
- [x] Let the owning Threaded Mode runtime detect an externally missing Unix endpoint during its existing health/prune cadence and restart only the local bus server without changing lock ownership, leader epoch, polling, or thread bindings.
|
|
62
|
+
- [x] Make initial follower registration report `live owner / unreachable bus endpoint` after bounded retries, with direct operator guidance; do not add automatic or force takeover without separate evidence that the old owner cannot still poll.
|
|
63
|
+
- [x] Keep intentional classic ownership unchanged because classic mode does not require a bus endpoint.
|
|
64
|
+
- [x] Add focused regressions for Unix endpoint unlink/rebind, bounded follower diagnosis, leader reload overlap, and no duplicate `getUpdates` ownership; add Windows coverage only for behavior the named-pipe transport can reproduce.
|
|
65
|
+
- [ ] Capture live recovery evidence without deleting lock/state or creating a replacement Telegram thread.
|
|
66
|
+
|
|
67
|
+
Done when: the confirmed endpoint-loss scenario either self-recovers under the existing owner or produces precise safe remediation, while classic mode and single-owner polling remain unchanged.
|
|
68
|
+
|
|
5
69
|
## P1 — Promoted Follower Reload Evidence
|
|
6
70
|
|
|
7
71
|
Context: deterministic coverage protects promoted follower thread preservation, and the latest live Linux smoke closed reload routing, follower Active, and reroute/restore regressions. The exact promoted-leader reload path is deliberately outside the 0.20.1 profile IPC hotfix because it is unrelated to profile transport isolation; keep it as an evidence-gated follow-up rather than blocking that release.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.5: Guest Media And Runtime Recovery Hotfix
|
|
4
|
+
|
|
5
|
+
- `[Setup Persistence]` The setup prompt now applies the validated bot identity to its config store before invoking persistence, so adapters that serialize current store state cannot write an empty or stale `telegram.json` on first run. Persistence failure rolls the in-memory candidate back and remains ahead of success notifications and polling startup. Impact: setup from a missing file or `{}` durably writes `botToken`, `botId`, and `botUsername` on the first successful command for default and named profiles without reporting an unsaved connection.
|
|
6
|
+
- `[Validation]` Added file-backed setup regressions for missing and empty config files, environment-provided tokens, named-profile isolation, and persistence-failure rollback while preserving existing cancellation, validation, polling-failure, and atomic config-write coverage. Full validation passes with 1,098 tests and one platform-only skip; typecheck and npm audit remain clean.
|
|
7
|
+
- `[Guest Attachments]` `telegram_attach` now admits exactly one local file during an active Guest Mode turn and rejects a second before file inspection or queue mutation. Agent-end stages that file through the paired-owner chat, sends one cached-media guest result with final text as its caption, cleans up the staging message, and never follows an ambiguous media-answer failure with a second guest answer. Impact: a requested local file is no longer silently discarded, while Telegram's one-result constraint remains explicit.
|
|
8
|
+
- `[Guest Media Transport]` `answerGuestQuery` now accepts a typed cached document/photo/audio/voice result in addition to text and Rich Markdown articles, and the bus-aware API preserves that exact result when a follower routes it through the leader. Impact: guest delivery no longer hard-codes article construction at the direct or IPC boundary, providing the minimal one-shot transport needed for staged local media without widening the follower API allowlist.
|
|
9
|
+
- `[Guest Media Staging]` Added a bounded local-media staging primitive that selects document/photo/audio/voice multipart transport, extracts the returned Telegram `file_id`, emits one matching cached guest result, truncates captions safely to 1,024 code points, and deletes the staging message in `finally`. Extraction and answer failures still clean up; cleanup failures record diagnostics without retrying the one-shot guest answer. Impact: local Guest Mode artifacts have deterministic cleanup and no duplicate-answer path.
|
|
10
|
+
- `[Guest Voice]` Guest agent-end now routes one explicit or policy-intercepted voice reply through the existing synthesis/handler chain, captures the generated OGG/OPUS artifact instead of sending it to sentinel chat `0`, stages it through the leader-owned multipart transport, and answers with one cached voice result. Visible answer text becomes the media caption, and multiple voice blocks reduce to the first result under Telegram's one-query/one-result contract. Impact: ordinary Guest Mode responses can include synthesized audio without bypassing ownership or emitting a separate text answer.
|
|
11
|
+
- `[Leader Endpoint Recovery]` The active Threaded Mode leader now checks its Unix socket during the existing follower-health cadence. If an external cleanup unlinks the endpoint while the server and polling owner remain alive, it closes only the orphaned local server, recreates the socket and parent directory, and resumes follower reachability without restarting polling, changing leader epoch, or touching thread bindings. Named pipes and classic mode remain unchanged because they do not expose the same filesystem-loss condition. Impact: a live owner can self-heal the observed `ENOENT bus.sock` split between polling health and follower connectivity without unsafe lock takeover.
|
|
12
|
+
- `[Follower Diagnostics]` When bounded registration retries fail with `ENOENT`, `ECONNREFUSED`, or `ETIMEDOUT` behind a still-live lock owner, the connection result now identifies `live owner / unreachable bus endpoint`, asks the operator to wait briefly and retry `/telegram-connect`, and explicitly rejects force takeover while the owner remains live. Impact: transient endpoint recovery no longer looks like a generic registration failure or invite split-brain remediation.
|
|
13
|
+
- `[Profile Diagnostics]` Current logs use `logs.jsonl` for default and `logs.<profile>.jsonl` for named profiles; preserved logs use `logs._prev.jsonl` and `logs.<profile>._prev.jsonl`. Status and `telegram_help` resolve paths through the shared helper, old logs remain untouched as ephemeral evidence, and profile names allow only lowercase ASCII letters and digits. Impact: `_prev` remains an unmistakable lifecycle suffix because underscores and dots cannot occur in profile identifiers, while compact dotted profile filenames remain readable.
|
|
14
|
+
- `[Compaction Status]` Removed pi-telegram's terminal and status-summary `compacting` projection while retaining the compaction flag in explicit bridge diagnostics and dispatch safety. A Telegram-owned active turn continues to render `active`; unrelated compaction leaves the stable connected/leader/follower role visible. Impact: Pi remains the sole owner of compaction lifecycle UI, and pi-telegram reports Telegram ownership rather than duplicating generic session state.
|
|
15
|
+
- `[Compaction Activity]` Compaction now starts the same connected-instance native typing path used by agent activity instead of suppressing typing when no Telegram turn is active. Active Telegram compaction targets its thread plus `All`; local/autonomous compaction targets the instance's assigned thread plus `All`, with completion, timeout, and shutdown stopping the keyed loop. Impact: compaction remains visible in Telegram without taking ownership of Pi's terminal lifecycle label.
|
|
16
|
+
|
|
3
17
|
## 0.20.4: Thread State Ownership Hotfix
|
|
4
18
|
|
|
5
19
|
- `[State Ownership]` Made the active transport lock owner the only process allowed to persist the profile-shared `state.json`; followers remain readers and acquire write authority only after promotion. Status-only persistence now refreshes disk-backed bindings before serialization. Impact: a stale follower diagnostics snapshot cannot erase newer leader-owned bindings, produce duplicate slot occupancy, or make a live follower disappear from current thread state.
|
package/README.md
CHANGED
|
@@ -151,6 +151,8 @@ Run these inside Pi.
|
|
|
151
151
|
| `/telegram-disconnect` | Stop polling and release ownership |
|
|
152
152
|
| `/telegram-status` | Inspect connection, mode, queue, transport, and recent diagnostics |
|
|
153
153
|
|
|
154
|
+
Named profile identifiers contain only lowercase ASCII letters and digits (maximum 32 characters); `default`, `main`, and `active` remain reserved.
|
|
155
|
+
|
|
154
156
|
## Main Surfaces
|
|
155
157
|
|
|
156
158
|
### Operator Menu
|
package/docs/architecture.md
CHANGED
|
@@ -120,7 +120,7 @@ Deleting `locks.json` resets runtime ownership without deleting Telegram configu
|
|
|
120
120
|
|
|
121
121
|
Telegram private-chat Threaded Mode is the public switch for multi-instance Telegram operation. Classic single-DM polling is the base mode. When Telegram private-chat threads are available for the bot, the bridge enables the local leader/follower bus automatically; when threads are unavailable or later disabled, the bridge returns to classic single-DM polling as a first-class mode.
|
|
122
122
|
|
|
123
|
-
Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes singleton locks, diagnostics logs, state files, thread/bus owner keys, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the default profile. A different selected profile is a parallel bot runtime: its locks, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`,
|
|
123
|
+
Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes singleton locks, diagnostics logs, state files, thread/bus owner keys, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the default profile. A different selected profile is a parallel bot runtime: its locks, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and from other named profiles while shared bridge settings remain top-level/global. The default profile preserves legacy state, log, socket, and named-pipe paths for compatibility.
|
|
124
124
|
|
|
125
125
|
Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; profile bot/session fields and observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup is limited to stale scratch files and cannot redirect live traffic.
|
|
126
126
|
|
|
@@ -214,14 +214,14 @@ Queue and menu mutations are reachable through Telegram updates handled by the c
|
|
|
214
214
|
|
|
215
215
|
### Compaction And Typing Status
|
|
216
216
|
|
|
217
|
-
Manual `/compact` requires inline confirmation because accidental taps are disruptive. Confirmed manual compaction and auto-compaction both set the bridge compaction flag, block queued prompt dispatch,
|
|
217
|
+
Manual `/compact` requires inline confirmation because accidental taps are disruptive. Confirmed manual compaction and auto-compaction both set the bridge compaction flag, block queued prompt dispatch, retain that flag in explicit diagnostics, and clear it on compact completion, timeout fallback, or session shutdown. Pi owns its terminal compaction lifecycle; pi-telegram keeps `Active` scoped to Telegram-owned work and otherwise preserves the stable connected/leader/follower role.
|
|
218
218
|
|
|
219
|
-
Native typing during compaction
|
|
219
|
+
Native typing during compaction follows connected-instance activity rather than terminal status:
|
|
220
220
|
|
|
221
|
-
- Confirmed manual `/compact`
|
|
222
|
-
- Automatic/session compaction
|
|
223
|
-
-
|
|
224
|
-
- Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface.
|
|
221
|
+
- Confirmed manual `/compact` starts a native `typing` keepalive in the command target and stops it on completion/failure.
|
|
222
|
+
- Automatic/session compaction with an active Telegram turn reuses that turn's target.
|
|
223
|
+
- Automatic/session compaction without an active Telegram turn uses the connected instance's assigned target; an unconnected instance sends nothing.
|
|
224
|
+
- Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface; completion, timeout, and shutdown stop the keyed loop.
|
|
225
225
|
|
|
226
226
|
At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Assistant message start/update hooks still re-arm it during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
|
|
227
227
|
|
|
@@ -192,7 +192,7 @@ Manual smoke checklist:
|
|
|
192
192
|
6. Close the follower terminal; verify heartbeat pruning, disconnected notice, and cleanup behavior match Unix-like behavior.
|
|
193
193
|
7. Reload the leader and verify status/debug output does not expose raw pipe internals except in explicit diagnostics.
|
|
194
194
|
|
|
195
|
-
If any step fails, capture `telegram-status --debug`, `tmp/telegram/state.json`, `tmp/telegram/logs.jsonl`, and, after a reload, `tmp/telegram/logs.
|
|
195
|
+
If any step fails, capture `telegram-status --debug`, `tmp/telegram/state.json`, `tmp/telegram/logs.jsonl`, and, after a reload, `tmp/telegram/logs._prev.jsonl`. Debug status prints local leader/follower endpoints with their active transport kind (`pipe` or `socket`), while the runtime log records request-scoped transport failures with envelope kind, request id, retry attempt, endpoint, and classified IPC error. Reloads preserve the prior JSONL log as `logs._prev.jsonl` so the evidence that caused the reload is not immediately overwritten.
|
|
196
196
|
|
|
197
197
|
### Native Windows Assumption Audit
|
|
198
198
|
|
package/docs/outbound.md
CHANGED
|
@@ -18,6 +18,12 @@ An outbound handler is selected by `type`. Text replies and assistant markup map
|
|
|
18
18
|
|
|
19
19
|
The voice pipeline is detailed below: configured `type: "voice"` handlers first, then programmatic handlers, then registered synthesis providers.
|
|
20
20
|
|
|
21
|
+
### Guest Mode media boundary
|
|
22
|
+
|
|
23
|
+
A Guest Mode reply is one `answerGuestQuery` call carrying exactly one `InlineQueryResult`; it is not a normal chat target and cannot receive `sendDocument`/`sendVoice` multipart uploads through sentinel `chatId: 0`. `telegram_attach` therefore admits at most one file during a guest turn and rejects additional files before queue mutation.
|
|
24
|
+
|
|
25
|
+
Telegram accepts public URLs or existing Telegram `file_id` values for inline media results, but pi-telegram does not publish local artifacts to external hosting. A local guest document, photo, MP3 audio, or OGG/OPUS voice therefore uses a temporary upload to the paired owner's bot chat, extraction of the returned `file_id`, one cached-media guest answer, and best-effort deletion of the staging message. That message can briefly appear or notify the owner. One guest query can carry only one media item, and its answer text must fit the media caption limit rather than a separate full Rich Markdown message.
|
|
26
|
+
|
|
21
27
|
Configured text handlers provide `template`. A string is one command; an array is ordered composition. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values. The command-template default timeout applies automatically. Use `template: [...]` for composition; the old local `pipe` alias is removed in 0.13.0.
|
|
22
28
|
|
|
23
29
|
## Text Handler Config
|
package/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import * as Menu from "./lib/menu.ts";
|
|
|
24
24
|
import * as Model from "./lib/model.ts";
|
|
25
25
|
import * as Outbound from "./lib/outbound.ts";
|
|
26
26
|
import * as Ownership from "./lib/ownership.ts";
|
|
27
|
+
import * as Paths from "./lib/paths.ts";
|
|
27
28
|
import * as Pi from "./lib/pi.ts";
|
|
28
29
|
import * as Polling from "./lib/polling.ts";
|
|
29
30
|
import * as Preview from "./lib/preview.ts";
|
|
@@ -309,6 +310,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
309
310
|
>({
|
|
310
311
|
getConfig: configStore.get,
|
|
311
312
|
getActiveProfileName: configStore.getActiveProfileName,
|
|
313
|
+
getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths,
|
|
312
314
|
isPollingActive: Polling.createTelegramPollingActivityReader(
|
|
313
315
|
pollingControllerState,
|
|
314
316
|
),
|
|
@@ -1318,6 +1320,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1318
1320
|
sendTextReply,
|
|
1319
1321
|
dispatchNextQueuedTelegramTurn,
|
|
1320
1322
|
answerGuestQuery,
|
|
1323
|
+
deleteMessage: deleteTelegramMessage,
|
|
1321
1324
|
sendGuestReply,
|
|
1322
1325
|
finalizeMarkdownPreview,
|
|
1323
1326
|
proactivePushChatIdGetter,
|
package/lib/bindings.ts
CHANGED
|
@@ -127,22 +127,29 @@ export function registerTelegramCommandsAndTools({
|
|
|
127
127
|
});
|
|
128
128
|
setupConfigStore.activateProfile(profileName);
|
|
129
129
|
persistSetupConfig = async () => {
|
|
130
|
+
try {
|
|
131
|
+
configStore.activateProfile(undefined);
|
|
132
|
+
configStore.set({
|
|
133
|
+
...storedConfig,
|
|
134
|
+
profiles: {
|
|
135
|
+
...(storedConfig.profiles ?? {}),
|
|
136
|
+
[profileName]: storedConfig.profiles?.[profileName] ?? {
|
|
137
|
+
botToken: "",
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
configStore.activateProfile(profileName);
|
|
142
|
+
configStore.set(setupConfigStore.get());
|
|
143
|
+
await persistConfig();
|
|
144
|
+
} catch (error) {
|
|
145
|
+
configStore.activateProfile(undefined);
|
|
146
|
+
configStore.set(storedConfig);
|
|
147
|
+
configStore.activateProfile(previousProfileName);
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
130
150
|
if (previousProfileName !== profileName) {
|
|
131
151
|
await (stopPolling ?? lockedPollingRuntime.stop)();
|
|
132
152
|
}
|
|
133
|
-
configStore.activateProfile(undefined);
|
|
134
|
-
configStore.set({
|
|
135
|
-
...storedConfig,
|
|
136
|
-
profiles: {
|
|
137
|
-
...(storedConfig.profiles ?? {}),
|
|
138
|
-
[profileName]: storedConfig.profiles?.[profileName] ?? {
|
|
139
|
-
botToken: "",
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
|
-
configStore.activateProfile(profileName);
|
|
144
|
-
configStore.set(setupConfigStore.get());
|
|
145
|
-
await persistConfig();
|
|
146
153
|
};
|
|
147
154
|
}
|
|
148
155
|
const runSetup = Setup.createTelegramSetupPromptRuntime({
|
|
@@ -242,14 +249,8 @@ interface TelegramLifecycleBindingDeps {
|
|
|
242
249
|
>["sendTextReply"] &
|
|
243
250
|
NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendTextReply"]>;
|
|
244
251
|
dispatchNextQueuedTelegramTurn: (ctx: Pi.ExtensionContext) => void;
|
|
245
|
-
answerGuestQuery:
|
|
246
|
-
|
|
247
|
-
Queue.PendingTelegramTurn,
|
|
248
|
-
Pi.ExtensionContext,
|
|
249
|
-
Pi.AgentEndEvent["messages"][number],
|
|
250
|
-
Keyboard.TelegramInlineKeyboardMarkup
|
|
251
|
-
>["answerGuestQuery"]
|
|
252
|
-
>;
|
|
252
|
+
answerGuestQuery: TelegramApi.TelegramBridgeApiRuntime["answerGuestQuery"];
|
|
253
|
+
deleteMessage: TelegramApi.TelegramBridgeApiRuntime["deleteMessage"];
|
|
253
254
|
sendGuestReply: NonNullable<
|
|
254
255
|
Queue.TelegramAgentEndHookRuntimeDeps<
|
|
255
256
|
Queue.PendingTelegramTurn,
|
|
@@ -295,6 +296,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
295
296
|
sendTextReply,
|
|
296
297
|
dispatchNextQueuedTelegramTurn,
|
|
297
298
|
answerGuestQuery,
|
|
299
|
+
deleteMessage,
|
|
298
300
|
sendGuestReply,
|
|
299
301
|
finalizeMarkdownPreview,
|
|
300
302
|
proactivePushChatIdGetter,
|
|
@@ -319,18 +321,107 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
319
321
|
sendTextReply,
|
|
320
322
|
recordRuntimeEvent,
|
|
321
323
|
});
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
324
|
+
const sendGuestAttachment = async (
|
|
325
|
+
turn: Queue.PendingTelegramTurn,
|
|
326
|
+
attachment: Queue.QueuedAttachment,
|
|
327
|
+
caption?: string,
|
|
328
|
+
): Promise<void> => {
|
|
329
|
+
const stagingTarget = proactivePushTargetGetter();
|
|
330
|
+
const stagingChatId = stagingTarget?.chatId ?? proactivePushChatIdGetter();
|
|
331
|
+
if (stagingChatId === undefined) {
|
|
332
|
+
throw new Error("Guest attachment staging requires a paired Telegram chat");
|
|
333
|
+
}
|
|
334
|
+
await OutboundAttachments.deliverTelegramGuestCachedAttachment({
|
|
335
|
+
guestQueryId: turn.guestQueryId!,
|
|
336
|
+
stagingChatId,
|
|
337
|
+
stagingTarget,
|
|
338
|
+
attachment,
|
|
339
|
+
caption,
|
|
327
340
|
sendMultipart: callMultipart,
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
341
|
+
answerGuestQuery: (guestQueryId, result) =>
|
|
342
|
+
answerGuestQuery(guestQueryId, undefined, { result }),
|
|
343
|
+
answerGuestText: (guestQueryId, text) =>
|
|
344
|
+
answerGuestQuery(guestQueryId, text),
|
|
345
|
+
fallbackText:
|
|
346
|
+
caption || "Telegram bridge could not deliver the requested attachment.",
|
|
347
|
+
deleteMessage,
|
|
332
348
|
recordRuntimeEvent,
|
|
333
349
|
});
|
|
350
|
+
};
|
|
351
|
+
const outboundReplyPlanner =
|
|
352
|
+
OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore);
|
|
353
|
+
const voiceReplySenderDeps = {
|
|
354
|
+
execCommand: CommandTemplates.execCommandTemplate,
|
|
355
|
+
sendMultipart: callMultipart,
|
|
356
|
+
sendTextReply,
|
|
357
|
+
sendChatAction,
|
|
358
|
+
sendRecordVoiceAction,
|
|
359
|
+
getHandlers: configStore.getOutboundHandlers,
|
|
360
|
+
recordRuntimeEvent,
|
|
361
|
+
};
|
|
362
|
+
const outboundReplyArtifactSender =
|
|
363
|
+
OutboundHandlers.createTelegramOutboundReplyArtifactSender(
|
|
364
|
+
voiceReplySenderDeps,
|
|
365
|
+
);
|
|
366
|
+
const sendGuestVoiceReply = async (
|
|
367
|
+
turn: Queue.PendingTelegramTurn,
|
|
368
|
+
plan: OutboundHandlers.TelegramOutboundReplyPlan,
|
|
369
|
+
caption?: string,
|
|
370
|
+
): Promise<void> => {
|
|
371
|
+
const stagingTarget = proactivePushTargetGetter();
|
|
372
|
+
const stagingChatId = stagingTarget?.chatId ?? proactivePushChatIdGetter();
|
|
373
|
+
if (stagingChatId === undefined) {
|
|
374
|
+
throw new Error("Guest voice staging requires a paired Telegram chat");
|
|
375
|
+
}
|
|
376
|
+
const guestVoiceSender =
|
|
377
|
+
OutboundHandlers.createTelegramOutboundReplyArtifactSender({
|
|
378
|
+
...voiceReplySenderDeps,
|
|
379
|
+
sendChatAction: undefined,
|
|
380
|
+
sendRecordVoiceAction: undefined,
|
|
381
|
+
sendMultipart: async (
|
|
382
|
+
_method,
|
|
383
|
+
_fields,
|
|
384
|
+
_fileField,
|
|
385
|
+
filePath,
|
|
386
|
+
fileName,
|
|
387
|
+
) => {
|
|
388
|
+
try {
|
|
389
|
+
await OutboundAttachments.deliverTelegramGuestCachedAttachment({
|
|
390
|
+
guestQueryId: turn.guestQueryId!,
|
|
391
|
+
stagingChatId,
|
|
392
|
+
stagingTarget,
|
|
393
|
+
attachment: { path: filePath, fileName },
|
|
394
|
+
caption,
|
|
395
|
+
sendMultipart: callMultipart,
|
|
396
|
+
answerGuestQuery: (guestQueryId, result) =>
|
|
397
|
+
answerGuestQuery(guestQueryId, undefined, { result }),
|
|
398
|
+
answerGuestText: (guestQueryId, text) =>
|
|
399
|
+
answerGuestQuery(guestQueryId, text),
|
|
400
|
+
fallbackText:
|
|
401
|
+
caption || "Telegram bridge could not deliver the voice reply.",
|
|
402
|
+
deleteMessage,
|
|
403
|
+
recordRuntimeEvent,
|
|
404
|
+
});
|
|
405
|
+
} catch (error) {
|
|
406
|
+
recordRuntimeEvent("delivery", error, {
|
|
407
|
+
phase: "guest-voice-answer",
|
|
408
|
+
guestQueryId: turn.guestQueryId,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
return {};
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
await guestVoiceSender(
|
|
415
|
+
turn,
|
|
416
|
+
{
|
|
417
|
+
...plan,
|
|
418
|
+
...(plan.voiceReplies?.length
|
|
419
|
+
? { voiceReplies: [plan.voiceReplies[0]!] }
|
|
420
|
+
: {}),
|
|
421
|
+
},
|
|
422
|
+
{ replyToPrompt: false },
|
|
423
|
+
);
|
|
424
|
+
};
|
|
334
425
|
const agentLifecycleHooks = Queue.createTelegramAgentLifecycleHooks<
|
|
335
426
|
Queue.PendingTelegramTurn,
|
|
336
427
|
Pi.ExtensionContext,
|
|
@@ -383,6 +474,8 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
383
474
|
sendQueuedAttachments: queuedAttachmentSender,
|
|
384
475
|
answerGuestQuery,
|
|
385
476
|
sendGuestReply,
|
|
477
|
+
sendGuestAttachment,
|
|
478
|
+
sendGuestVoiceReply,
|
|
386
479
|
planOutboundReply: outboundReplyPlanner,
|
|
387
480
|
sendOutboundReplyArtifacts: outboundReplyArtifactSender,
|
|
388
481
|
getDefaultChatId: proactivePushChatIdGetter,
|
|
@@ -398,8 +491,8 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
398
491
|
const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
|
|
399
492
|
agentLifecycleHooks.onAgentStart,
|
|
400
493
|
);
|
|
401
|
-
const startAgentActivityTypingLoop = (ctx: Pi.ExtensionContext):
|
|
402
|
-
if (!canSendAgentActivity(ctx)) return;
|
|
494
|
+
const startAgentActivityTypingLoop = (ctx: Pi.ExtensionContext): boolean => {
|
|
495
|
+
if (!canSendAgentActivity(ctx)) return false;
|
|
403
496
|
const turn = activeTurnRuntime.get();
|
|
404
497
|
const target = turn?.target ?? proactivePushTargetGetter();
|
|
405
498
|
promptDispatchRuntime.startTypingLoop(
|
|
@@ -407,6 +500,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
407
500
|
turn?.chatId ?? target?.chatId ?? proactivePushChatIdGetter(),
|
|
408
501
|
{ target },
|
|
409
502
|
);
|
|
503
|
+
return true;
|
|
410
504
|
};
|
|
411
505
|
const startActiveTurnTypingLoop = (ctx: Pi.ExtensionContext): void => {
|
|
412
506
|
const turn = activeTurnRuntime.get();
|
|
@@ -417,9 +511,8 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
417
511
|
const compactionObserver = Lifecycle.createTelegramCompactionObserverRuntime({
|
|
418
512
|
setCompactionInProgress: lifecycle.setCompactionInProgress,
|
|
419
513
|
updateStatus,
|
|
420
|
-
startTypingLoop:
|
|
514
|
+
startTypingLoop: startAgentActivityTypingLoop,
|
|
421
515
|
stopTypingLoop: typing.stop,
|
|
422
|
-
shouldStartTypingLoop: activeTurnRuntime.has,
|
|
423
516
|
requestDeferredDispatchNextQueuedTelegramTurn:
|
|
424
517
|
deferredQueueDispatchRuntime.request,
|
|
425
518
|
dispatchNextQueuedTelegramTurn,
|
package/lib/bus-api.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
|
+
TelegramAnswerGuestQueryOptions,
|
|
8
9
|
TelegramApiCallOptions,
|
|
9
10
|
TelegramBridgeApiRuntime,
|
|
10
11
|
TelegramEditMessageTextBody,
|
|
11
|
-
TelegramInputRichMessage,
|
|
12
12
|
TelegramSendMessageBody,
|
|
13
13
|
TelegramSendMessageDraftBody,
|
|
14
14
|
TelegramSendRichMessageBody,
|
|
@@ -273,14 +273,16 @@ export function createTelegramBusAwareApiRuntime(
|
|
|
273
273
|
async answerGuestQuery(
|
|
274
274
|
guestQueryId: string,
|
|
275
275
|
text?: string,
|
|
276
|
-
options?:
|
|
276
|
+
options?: TelegramAnswerGuestQueryOptions,
|
|
277
277
|
): Promise<void> {
|
|
278
278
|
if (deps.ownsDirect()) {
|
|
279
279
|
await deps.directRuntime.answerGuestQuery(guestQueryId, text, options);
|
|
280
280
|
return;
|
|
281
281
|
}
|
|
282
282
|
const body: Record<string, unknown> = { guest_query_id: guestQueryId };
|
|
283
|
-
if (
|
|
283
|
+
if (options?.result) {
|
|
284
|
+
body.result = options.result;
|
|
285
|
+
} else if (text !== undefined || options?.richMessage) {
|
|
284
286
|
const inputContent: Record<string, unknown> = options?.richMessage
|
|
285
287
|
? { rich_message: options.richMessage }
|
|
286
288
|
: { message_text: text };
|
package/lib/bus-leader.ts
CHANGED
|
@@ -1199,6 +1199,13 @@ export function createTelegramBusLeaderRuntime<TContext>(
|
|
|
1199
1199
|
followerRealityTimer.unref?.();
|
|
1200
1200
|
};
|
|
1201
1201
|
const pruneFollowers = async () => {
|
|
1202
|
+
try {
|
|
1203
|
+
await localServer.ensureEndpoint();
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
deps.recordRuntimeEvent?.("bus", error, {
|
|
1206
|
+
phase: "leader-endpoint-recovery",
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1202
1209
|
const removed = deps.followerRegistry.pruneStale(
|
|
1203
1210
|
getNowMs(),
|
|
1204
1211
|
followerStaleAfterMs,
|
package/lib/bus.ts
CHANGED
|
@@ -165,6 +165,7 @@ export function isTelegramFollowerApiCallAllowed(input: {
|
|
|
165
165
|
"sendRichMessageDraft",
|
|
166
166
|
]);
|
|
167
167
|
const allowedMultipartMethods = new Set([
|
|
168
|
+
"sendAudio",
|
|
168
169
|
"sendDocument",
|
|
169
170
|
"sendMediaGroup",
|
|
170
171
|
"sendPhoto",
|
|
@@ -384,6 +385,7 @@ export function parseTelegramBusEnvelope(
|
|
|
384
385
|
export interface TelegramBusLocalServer {
|
|
385
386
|
start: () => Promise<void>;
|
|
386
387
|
stop: () => Promise<void>;
|
|
388
|
+
ensureEndpoint: () => Promise<boolean>;
|
|
387
389
|
}
|
|
388
390
|
|
|
389
391
|
export type TelegramBusSocketPathSource = string | (() => string);
|
|
@@ -610,12 +612,14 @@ export function createTelegramBusLocalServer(
|
|
|
610
612
|
): TelegramBusLocalServer {
|
|
611
613
|
let server: Server | undefined;
|
|
612
614
|
let activeSocketPath: string | undefined;
|
|
615
|
+
let endpointRecovery: Promise<boolean> | undefined;
|
|
616
|
+
let stopGeneration = 0;
|
|
613
617
|
const sockets = new Set<Socket>();
|
|
614
618
|
const closeSocket = (socket: Socket) => {
|
|
615
619
|
sockets.delete(socket);
|
|
616
620
|
socket.destroy();
|
|
617
621
|
};
|
|
618
|
-
|
|
622
|
+
const runtime: TelegramBusLocalServer = {
|
|
619
623
|
start: async () => {
|
|
620
624
|
if (server) return;
|
|
621
625
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
@@ -679,6 +683,7 @@ export function createTelegramBusLocalServer(
|
|
|
679
683
|
if (!usesWindowsPipe) chmodSync(socketPath, 0o600);
|
|
680
684
|
},
|
|
681
685
|
stop: async () => {
|
|
686
|
+
stopGeneration += 1;
|
|
682
687
|
const activeServer = server;
|
|
683
688
|
const socketPath = activeSocketPath;
|
|
684
689
|
server = undefined;
|
|
@@ -703,7 +708,44 @@ export function createTelegramBusLocalServer(
|
|
|
703
708
|
);
|
|
704
709
|
}
|
|
705
710
|
},
|
|
711
|
+
ensureEndpoint: async () => {
|
|
712
|
+
const socketPath = activeSocketPath;
|
|
713
|
+
if (
|
|
714
|
+
!server ||
|
|
715
|
+
!socketPath ||
|
|
716
|
+
isTelegramBusPipePath(socketPath) ||
|
|
717
|
+
existsSync(socketPath)
|
|
718
|
+
) {
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
if (endpointRecovery) return endpointRecovery;
|
|
722
|
+
endpointRecovery = (async () => {
|
|
723
|
+
deps.recordTransportEvent?.(
|
|
724
|
+
"server-endpoint-missing",
|
|
725
|
+
getTelegramBusEndpointDiagnostics(socketPath),
|
|
726
|
+
);
|
|
727
|
+
const recoveryStopGeneration = stopGeneration + 1;
|
|
728
|
+
await runtime.stop();
|
|
729
|
+
if (stopGeneration !== recoveryStopGeneration) return false;
|
|
730
|
+
await runtime.start();
|
|
731
|
+
if (stopGeneration !== recoveryStopGeneration) {
|
|
732
|
+
await runtime.stop();
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
deps.recordTransportEvent?.(
|
|
736
|
+
"server-endpoint-recovered",
|
|
737
|
+
getTelegramBusEndpointDiagnostics(socketPath),
|
|
738
|
+
);
|
|
739
|
+
return true;
|
|
740
|
+
})();
|
|
741
|
+
try {
|
|
742
|
+
return await endpointRecovery;
|
|
743
|
+
} finally {
|
|
744
|
+
endpointRecovery = undefined;
|
|
745
|
+
}
|
|
746
|
+
},
|
|
706
747
|
};
|
|
748
|
+
return runtime;
|
|
707
749
|
}
|
|
708
750
|
|
|
709
751
|
function getTelegramBusEnvelopeDiagnostics(
|
package/lib/config.ts
CHANGED
|
@@ -85,8 +85,8 @@ export interface TelegramBotProfile {
|
|
|
85
85
|
lastUpdateId?: number;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** Profile names must
|
|
89
|
-
const TELEGRAM_PROFILE_NAME_PATTERN = /^[a-z0-9]
|
|
88
|
+
/** Profile names must contain only lowercase ASCII letters and digits; max 32 chars. */
|
|
89
|
+
const TELEGRAM_PROFILE_NAME_PATTERN = /^[a-z0-9]{1,32}$/;
|
|
90
90
|
const TELEGRAM_RESERVED_PROFILE_NAMES: ReadonlySet<string> = new Set([
|
|
91
91
|
"default",
|
|
92
92
|
"main",
|
package/lib/lifecycle.ts
CHANGED
|
@@ -153,9 +153,8 @@ function unrefTelegramLifecycleTimer(timer: TelegramLifecycleTimer): void {
|
|
|
153
153
|
export interface TelegramCompactionObserverRuntimeDeps<TContext> {
|
|
154
154
|
setCompactionInProgress: (inProgress: boolean) => void;
|
|
155
155
|
updateStatus: (ctx: TContext) => void;
|
|
156
|
-
startTypingLoop?: (ctx: TContext) => void;
|
|
156
|
+
startTypingLoop?: (ctx: TContext) => boolean | void;
|
|
157
157
|
stopTypingLoop?: () => void;
|
|
158
|
-
shouldStartTypingLoop?: () => boolean;
|
|
159
158
|
requestDeferredDispatchNextQueuedTelegramTurn: (
|
|
160
159
|
dispatch: (ctx: TContext) => void,
|
|
161
160
|
) => void;
|
|
@@ -196,8 +195,9 @@ export function createTelegramCompactionObserverRuntime<TContext>(
|
|
|
196
195
|
return {
|
|
197
196
|
onSessionBeforeCompact: (_event, ctx) => {
|
|
198
197
|
deps.setCompactionInProgress(true);
|
|
199
|
-
|
|
200
|
-
|
|
198
|
+
const typingStartResult = deps.startTypingLoop?.(ctx);
|
|
199
|
+
typingStartedByObserver =
|
|
200
|
+
!!deps.startTypingLoop && typingStartResult !== false;
|
|
201
201
|
deps.updateStatus(ctx);
|
|
202
202
|
clearFallbackTimer();
|
|
203
203
|
fallbackTimer = setTimer(() => {
|
package/lib/locks.ts
CHANGED
|
@@ -203,6 +203,17 @@ export function formatTelegramLockEntry(lock: TelegramLockEntry): string {
|
|
|
203
203
|
return lock.cwd ? `pid ${lock.pid}, cwd ${lock.cwd}` : `pid ${lock.pid}`;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
function formatTelegramFollowerRegistrationFailure(message: string): string {
|
|
207
|
+
if (/\b(?:ENOENT|ECONNREFUSED|ETIMEDOUT)\b/u.test(message)) {
|
|
208
|
+
return (
|
|
209
|
+
`live owner / unreachable bus endpoint after bounded retries (${message}); ` +
|
|
210
|
+
"wait briefly for owner recovery, then retry /telegram-connect. " +
|
|
211
|
+
"Do not force takeover while the owner remains live"
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return message;
|
|
215
|
+
}
|
|
216
|
+
|
|
206
217
|
function getLockState(
|
|
207
218
|
lock: TelegramLockEntry | undefined,
|
|
208
219
|
pid: number,
|
|
@@ -509,7 +520,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
509
520
|
ok: false,
|
|
510
521
|
canTakeover: false,
|
|
511
522
|
owner,
|
|
512
|
-
message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${failureMessage}.`,
|
|
523
|
+
message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${formatTelegramFollowerRegistrationFailure(failureMessage)}.`,
|
|
513
524
|
};
|
|
514
525
|
}
|
|
515
526
|
}
|
package/lib/logs.ts
CHANGED
|
@@ -65,7 +65,7 @@ export function getTelegramPreviousRuntimeLogPath(
|
|
|
65
65
|
): string {
|
|
66
66
|
return resolveTelegramProfileTempFilePath(
|
|
67
67
|
"logs",
|
|
68
|
-
"
|
|
68
|
+
"_prev.jsonl",
|
|
69
69
|
agentDir,
|
|
70
70
|
profileName,
|
|
71
71
|
);
|
|
@@ -91,7 +91,7 @@ export function createTelegramRuntimeJsonlLog(
|
|
|
91
91
|
const resolvePreviousPath = () => {
|
|
92
92
|
if (typeof options.previousPath === "function") return options.previousPath();
|
|
93
93
|
if (options.previousPath) return options.previousPath;
|
|
94
|
-
return resolvePath().replace(/\.jsonl$/u, ".
|
|
94
|
+
return resolvePath().replace(/\.jsonl$/u, "._prev.jsonl");
|
|
95
95
|
};
|
|
96
96
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
|
|
97
97
|
const getNowMs = options.getNowMs ?? Date.now;
|
|
@@ -88,6 +88,7 @@ export interface TelegramQueuedOutboundAttachmentView {
|
|
|
88
88
|
|
|
89
89
|
export interface TelegramOutboundAttachmentQueueTargetView {
|
|
90
90
|
queuedAttachments: TelegramQueuedOutboundAttachmentView[];
|
|
91
|
+
guestQueryId?: string;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
|
|
@@ -96,6 +97,42 @@ export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutbou
|
|
|
96
97
|
target?: TelegramTarget;
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
export type TelegramGuestCachedAttachmentResult =
|
|
101
|
+
| {
|
|
102
|
+
type: "document";
|
|
103
|
+
id: string;
|
|
104
|
+
title: string;
|
|
105
|
+
document_file_id: string;
|
|
106
|
+
caption?: string;
|
|
107
|
+
}
|
|
108
|
+
| {
|
|
109
|
+
type: "photo";
|
|
110
|
+
id: string;
|
|
111
|
+
photo_file_id: string;
|
|
112
|
+
caption?: string;
|
|
113
|
+
}
|
|
114
|
+
| {
|
|
115
|
+
type: "audio";
|
|
116
|
+
id: string;
|
|
117
|
+
audio_file_id: string;
|
|
118
|
+
caption?: string;
|
|
119
|
+
}
|
|
120
|
+
| {
|
|
121
|
+
type: "voice";
|
|
122
|
+
id: string;
|
|
123
|
+
voice_file_id: string;
|
|
124
|
+
title: string;
|
|
125
|
+
caption?: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
interface TelegramGuestStagingMessage {
|
|
129
|
+
message_id?: number;
|
|
130
|
+
document?: { file_id?: string };
|
|
131
|
+
photo?: Array<{ file_id?: string; file_size?: number }>;
|
|
132
|
+
audio?: { file_id?: string };
|
|
133
|
+
voice?: { file_id?: string };
|
|
134
|
+
}
|
|
135
|
+
|
|
99
136
|
function isTelegramOutboundPhotoAttachmentPath(path: string): boolean {
|
|
100
137
|
const normalized = path.toLowerCase();
|
|
101
138
|
return (
|
|
@@ -107,6 +144,27 @@ function isTelegramOutboundPhotoAttachmentPath(path: string): boolean {
|
|
|
107
144
|
);
|
|
108
145
|
}
|
|
109
146
|
|
|
147
|
+
function getTelegramGuestAttachmentTransport(path: string): {
|
|
148
|
+
method: "sendDocument" | "sendPhoto" | "sendAudio" | "sendVoice";
|
|
149
|
+
fileField: "document" | "photo" | "audio" | "voice";
|
|
150
|
+
} {
|
|
151
|
+
const normalized = path.toLowerCase();
|
|
152
|
+
if (
|
|
153
|
+
normalized.endsWith(".jpg") ||
|
|
154
|
+
normalized.endsWith(".jpeg") ||
|
|
155
|
+
normalized.endsWith(".png")
|
|
156
|
+
) {
|
|
157
|
+
return { method: "sendPhoto", fileField: "photo" };
|
|
158
|
+
}
|
|
159
|
+
if (normalized.endsWith(".ogg") || normalized.endsWith(".opus")) {
|
|
160
|
+
return { method: "sendVoice", fileField: "voice" };
|
|
161
|
+
}
|
|
162
|
+
if (normalized.endsWith(".mp3")) {
|
|
163
|
+
return { method: "sendAudio", fileField: "audio" };
|
|
164
|
+
}
|
|
165
|
+
return { method: "sendDocument", fileField: "document" };
|
|
166
|
+
}
|
|
167
|
+
|
|
110
168
|
function formatTelegramOutboundAttachmentSizeLimitError(
|
|
111
169
|
size: number,
|
|
112
170
|
maxSize: number,
|
|
@@ -388,6 +446,14 @@ export async function queueTelegramOutboundAttachments(options: {
|
|
|
388
446
|
statPath: options.statPath,
|
|
389
447
|
});
|
|
390
448
|
}
|
|
449
|
+
if (
|
|
450
|
+
options.activeTurn.guestQueryId &&
|
|
451
|
+
options.activeTurn.queuedAttachments.length + options.paths.length > 1
|
|
452
|
+
) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
"Telegram Guest Mode supports one attachment per reply; no attachment was queued",
|
|
455
|
+
);
|
|
456
|
+
}
|
|
391
457
|
if (
|
|
392
458
|
options.activeTurn.queuedAttachments.length + options.paths.length >
|
|
393
459
|
options.maxAttachmentsPerTurn
|
|
@@ -414,6 +480,110 @@ export async function queueTelegramOutboundAttachments(options: {
|
|
|
414
480
|
};
|
|
415
481
|
}
|
|
416
482
|
|
|
483
|
+
export async function deliverTelegramGuestCachedAttachment(options: {
|
|
484
|
+
guestQueryId: string;
|
|
485
|
+
stagingChatId: number;
|
|
486
|
+
stagingTarget?: TelegramTarget;
|
|
487
|
+
attachment: TelegramQueuedOutboundAttachmentView;
|
|
488
|
+
caption?: string;
|
|
489
|
+
sendMultipart: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
|
|
490
|
+
answerGuestQuery: (
|
|
491
|
+
guestQueryId: string,
|
|
492
|
+
result: TelegramGuestCachedAttachmentResult,
|
|
493
|
+
) => Promise<void>;
|
|
494
|
+
answerGuestText?: (guestQueryId: string, text: string) => Promise<void>;
|
|
495
|
+
fallbackText?: string;
|
|
496
|
+
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
|
|
497
|
+
recordRuntimeEvent?: TelegramOutboundAttachmentRuntimeEventRecorderPort["recordRuntimeEvent"];
|
|
498
|
+
}): Promise<void> {
|
|
499
|
+
const transport = getTelegramGuestAttachmentTransport(options.attachment.path);
|
|
500
|
+
let stagingMessageId: number | undefined;
|
|
501
|
+
let answerAttempted = false;
|
|
502
|
+
try {
|
|
503
|
+
const message = (await options.sendMultipart(
|
|
504
|
+
transport.method,
|
|
505
|
+
{
|
|
506
|
+
chat_id: String(options.stagingChatId),
|
|
507
|
+
...getTelegramMultipartTargetFields(options.stagingTarget),
|
|
508
|
+
},
|
|
509
|
+
transport.fileField,
|
|
510
|
+
options.attachment.path,
|
|
511
|
+
options.attachment.fileName,
|
|
512
|
+
)) as TelegramGuestStagingMessage;
|
|
513
|
+
stagingMessageId = message.message_id;
|
|
514
|
+
const caption = options.caption
|
|
515
|
+
? Array.from(options.caption).slice(0, 1024).join("")
|
|
516
|
+
: undefined;
|
|
517
|
+
let result: TelegramGuestCachedAttachmentResult;
|
|
518
|
+
if (transport.fileField === "photo") {
|
|
519
|
+
const photo = [...(message.photo ?? [])]
|
|
520
|
+
.sort((left, right) => (left.file_size ?? 0) - (right.file_size ?? 0))
|
|
521
|
+
.at(-1);
|
|
522
|
+
if (!photo?.file_id) throw new Error("Guest staging upload returned no photo file_id");
|
|
523
|
+
result = {
|
|
524
|
+
type: "photo",
|
|
525
|
+
id: "attachment-1",
|
|
526
|
+
photo_file_id: photo.file_id,
|
|
527
|
+
...(caption ? { caption } : {}),
|
|
528
|
+
};
|
|
529
|
+
} else if (transport.fileField === "audio") {
|
|
530
|
+
if (!message.audio?.file_id)
|
|
531
|
+
throw new Error("Guest staging upload returned no audio file_id");
|
|
532
|
+
result = {
|
|
533
|
+
type: "audio",
|
|
534
|
+
id: "attachment-1",
|
|
535
|
+
audio_file_id: message.audio.file_id,
|
|
536
|
+
...(caption ? { caption } : {}),
|
|
537
|
+
};
|
|
538
|
+
} else if (transport.fileField === "voice") {
|
|
539
|
+
if (!message.voice?.file_id)
|
|
540
|
+
throw new Error("Guest staging upload returned no voice file_id");
|
|
541
|
+
result = {
|
|
542
|
+
type: "voice",
|
|
543
|
+
id: "attachment-1",
|
|
544
|
+
voice_file_id: message.voice.file_id,
|
|
545
|
+
title: options.attachment.fileName,
|
|
546
|
+
...(caption ? { caption } : {}),
|
|
547
|
+
};
|
|
548
|
+
} else {
|
|
549
|
+
if (!message.document?.file_id)
|
|
550
|
+
throw new Error("Guest staging upload returned no document file_id");
|
|
551
|
+
result = {
|
|
552
|
+
type: "document",
|
|
553
|
+
id: "attachment-1",
|
|
554
|
+
title: options.attachment.fileName,
|
|
555
|
+
document_file_id: message.document.file_id,
|
|
556
|
+
...(caption ? { caption } : {}),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
answerAttempted = true;
|
|
560
|
+
await options.answerGuestQuery(options.guestQueryId, result);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (
|
|
563
|
+
!answerAttempted &&
|
|
564
|
+
options.answerGuestText &&
|
|
565
|
+
options.fallbackText
|
|
566
|
+
) {
|
|
567
|
+
answerAttempted = true;
|
|
568
|
+
await options.answerGuestText(options.guestQueryId, options.fallbackText);
|
|
569
|
+
} else {
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
} finally {
|
|
573
|
+
if (stagingMessageId !== undefined) {
|
|
574
|
+
try {
|
|
575
|
+
await options.deleteMessage(options.stagingChatId, stagingMessageId);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
options.recordRuntimeEvent?.("attachment", error, {
|
|
578
|
+
phase: "guest-staging-cleanup",
|
|
579
|
+
chatId: options.stagingChatId,
|
|
580
|
+
messageId: stagingMessageId,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
417
587
|
export async function sendTelegramOutboundMessage(options: {
|
|
418
588
|
text: string;
|
|
419
589
|
chatId?: number;
|
package/lib/paths.ts
CHANGED
|
@@ -76,9 +76,10 @@ export function getTelegramDiagnosticsDisplayPaths(profileName?: string): {
|
|
|
76
76
|
logs: string;
|
|
77
77
|
} {
|
|
78
78
|
const suffix = getTelegramProfilePathSuffix(profileName);
|
|
79
|
+
const profileSlug = suffix.slice(1);
|
|
79
80
|
return {
|
|
80
81
|
state: `~/.pi/agent/tmp/telegram/state${suffix}.json`,
|
|
81
|
-
logs: `~/.pi/agent/tmp/telegram/logs${
|
|
82
|
+
logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
|
|
82
83
|
};
|
|
83
84
|
}
|
|
84
85
|
|
package/lib/queue.ts
CHANGED
|
@@ -899,6 +899,16 @@ export interface TelegramAgentEndRuntimeDeps<
|
|
|
899
899
|
options?: { parseMode?: string },
|
|
900
900
|
) => Promise<void>;
|
|
901
901
|
sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
|
|
902
|
+
sendGuestAttachment?: (
|
|
903
|
+
turn: TTurn,
|
|
904
|
+
attachment: QueuedAttachment,
|
|
905
|
+
caption?: string,
|
|
906
|
+
) => Promise<void>;
|
|
907
|
+
sendGuestVoiceReply?: (
|
|
908
|
+
turn: TTurn,
|
|
909
|
+
plan: TelegramAgentEndOutboundReplyPlan<TReplyMarkup>,
|
|
910
|
+
caption?: string,
|
|
911
|
+
) => Promise<void>;
|
|
902
912
|
planOutboundReply?: (
|
|
903
913
|
markdown: string,
|
|
904
914
|
) => TelegramAgentEndOutboundReplyPlan<TReplyMarkup>;
|
|
@@ -958,6 +968,8 @@ export interface TelegramAgentEndHookRuntimeDeps<
|
|
|
958
968
|
sendQueuedAttachments: (turn: TTurn) => Promise<void>;
|
|
959
969
|
answerGuestQuery?: TelegramAgentEndRuntimeDeps<TTurn>["answerGuestQuery"];
|
|
960
970
|
sendGuestReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestReply"];
|
|
971
|
+
sendGuestAttachment?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestAttachment"];
|
|
972
|
+
sendGuestVoiceReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestVoiceReply"];
|
|
961
973
|
planOutboundReply?: TelegramAgentEndRuntimeDeps<
|
|
962
974
|
TTurn,
|
|
963
975
|
TReplyMarkup
|
|
@@ -1083,6 +1095,8 @@ export function createTelegramAgentEndHook<
|
|
|
1083
1095
|
sendQueuedAttachments: deps.sendQueuedAttachments,
|
|
1084
1096
|
answerGuestQuery: deps.answerGuestQuery,
|
|
1085
1097
|
sendGuestReply: deps.sendGuestReply,
|
|
1098
|
+
sendGuestAttachment: deps.sendGuestAttachment,
|
|
1099
|
+
sendGuestVoiceReply: deps.sendGuestVoiceReply,
|
|
1086
1100
|
planOutboundReply: deps.planOutboundReply,
|
|
1087
1101
|
sendOutboundReplyArtifacts: deps.sendOutboundReplyArtifacts,
|
|
1088
1102
|
getDefaultChatId: deps.getDefaultChatId,
|
|
@@ -1181,7 +1195,38 @@ export async function handleTelegramAgentEndRuntime<
|
|
|
1181
1195
|
if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
|
|
1182
1196
|
return;
|
|
1183
1197
|
}
|
|
1184
|
-
|
|
1198
|
+
const [guestAttachment] = turn.queuedAttachments;
|
|
1199
|
+
if (guestAttachment && deps.sendGuestAttachment) {
|
|
1200
|
+
try {
|
|
1201
|
+
await deps.sendGuestAttachment(
|
|
1202
|
+
turn,
|
|
1203
|
+
guestAttachment,
|
|
1204
|
+
finalText || undefined,
|
|
1205
|
+
);
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
deps.recordRuntimeEvent?.("delivery", error, {
|
|
1208
|
+
phase: "guest-attachment",
|
|
1209
|
+
guestQueryId: turn.guestQueryId,
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
} else if (
|
|
1213
|
+
outboundReply &&
|
|
1214
|
+
(outboundReply.voiceText || outboundReply.voiceReplies?.length) &&
|
|
1215
|
+
deps.sendGuestVoiceReply
|
|
1216
|
+
) {
|
|
1217
|
+
try {
|
|
1218
|
+
await deps.sendGuestVoiceReply(
|
|
1219
|
+
turn,
|
|
1220
|
+
outboundReply,
|
|
1221
|
+
finalText || undefined,
|
|
1222
|
+
);
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
deps.recordRuntimeEvent?.("delivery", error, {
|
|
1225
|
+
phase: "guest-voice",
|
|
1226
|
+
guestQueryId: turn.guestQueryId,
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
} else if (finalText) {
|
|
1185
1230
|
if (deps.sendGuestReply) {
|
|
1186
1231
|
await deps.sendGuestReply(turn.guestQueryId, finalText);
|
|
1187
1232
|
} else {
|
package/lib/setup.ts
CHANGED
|
@@ -197,8 +197,14 @@ export function createTelegramSetupPromptRuntime<
|
|
|
197
197
|
promptEditor: (label, value) => ctx.ui.editor(label, value),
|
|
198
198
|
getMe: deps.getMe,
|
|
199
199
|
persistConfig: async (config) => {
|
|
200
|
-
|
|
200
|
+
const previousConfig = deps.getConfig();
|
|
201
201
|
deps.setConfig(config);
|
|
202
|
+
try {
|
|
203
|
+
await deps.persistConfig(config);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
deps.setConfig(previousConfig);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
202
208
|
},
|
|
203
209
|
notify: (message, level) => ctx.ui.notify(message, level),
|
|
204
210
|
startPolling: () => deps.startPolling(ctx),
|
package/lib/status.ts
CHANGED
|
@@ -181,6 +181,7 @@ export interface TelegramBridgeStatusLineState {
|
|
|
181
181
|
hasBotToken?: boolean;
|
|
182
182
|
botUsername?: string;
|
|
183
183
|
activeProfileName?: string;
|
|
184
|
+
diagnosticPaths?: { state: string; logs: string };
|
|
184
185
|
allowedUserId?: number;
|
|
185
186
|
botThreadMode?: "unknown" | "enabled" | "disabled";
|
|
186
187
|
botThreadModeUpdatedAtMs?: number;
|
|
@@ -259,6 +260,9 @@ export interface TelegramBridgeStatusRuntimeDeps<
|
|
|
259
260
|
statusKey?: string;
|
|
260
261
|
getConfig: () => TelegramBridgeStatusConfig;
|
|
261
262
|
getActiveProfileName?: () => string | undefined;
|
|
263
|
+
getDiagnosticPaths?: (
|
|
264
|
+
profileName?: string,
|
|
265
|
+
) => { state: string; logs: string };
|
|
262
266
|
isPollingActive: () => boolean;
|
|
263
267
|
getActiveSourceMessageIds: () => number[] | undefined;
|
|
264
268
|
hasActiveTurn: () => boolean;
|
|
@@ -620,10 +624,12 @@ export function createTelegramBridgeStatusRuntime<
|
|
|
620
624
|
getBridgeStatusLineState: () => {
|
|
621
625
|
const config = deps.getConfig();
|
|
622
626
|
const botThreadMode = deps.getBotThreadMode?.();
|
|
627
|
+
const activeProfileName = deps.getActiveProfileName?.();
|
|
623
628
|
return {
|
|
624
629
|
hasBotToken: Boolean(config.botToken),
|
|
625
630
|
botUsername: config.botUsername,
|
|
626
|
-
activeProfileName
|
|
631
|
+
activeProfileName,
|
|
632
|
+
diagnosticPaths: deps.getDiagnosticPaths?.(activeProfileName),
|
|
627
633
|
allowedUserId: config.allowedUserId,
|
|
628
634
|
botThreadMode: botThreadMode?.threadMode,
|
|
629
635
|
botThreadModeUpdatedAtMs: botThreadMode?.updatedAtMs,
|
|
@@ -774,9 +780,6 @@ export function buildTelegramStatusBarText(
|
|
|
774
780
|
return `${label} ${theme.fg("warning", "electing")}${queued}`;
|
|
775
781
|
if (!state.pollingActive && state.busRole !== "follower")
|
|
776
782
|
return `${theme.fg("accent", "telegram")} ${theme.fg("muted", "disconnected")}${queued}`;
|
|
777
|
-
if (state.compactionInProgress) {
|
|
778
|
-
return `${label} ${theme.fg("warning", "compacting")}${queued}`;
|
|
779
|
-
}
|
|
780
783
|
if (state.processing) {
|
|
781
784
|
const processingStatus = state.queuedStatus
|
|
782
785
|
? "active"
|
|
@@ -1058,19 +1061,18 @@ function buildTelegramBridgeCompactStatusLines(
|
|
|
1058
1061
|
? ` (control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
|
|
1059
1062
|
: ""
|
|
1060
1063
|
}`;
|
|
1061
|
-
const executionState = state.
|
|
1062
|
-
? "
|
|
1063
|
-
: state.
|
|
1064
|
-
? "
|
|
1065
|
-
:
|
|
1066
|
-
? "active"
|
|
1067
|
-
: "idle";
|
|
1064
|
+
const executionState = state.pendingDispatch
|
|
1065
|
+
? "pending dispatch"
|
|
1066
|
+
: state.activeSourceMessageIds?.length
|
|
1067
|
+
? "active"
|
|
1068
|
+
: "idle";
|
|
1068
1069
|
const profileSuffix = state.activeProfileName
|
|
1069
1070
|
? `.${state.activeProfileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`
|
|
1070
1071
|
: "";
|
|
1071
|
-
const
|
|
1072
|
+
const profileSlug = profileSuffix.slice(1);
|
|
1073
|
+
const diagnosticsPaths = state.diagnosticPaths ?? {
|
|
1072
1074
|
state: `~/.pi/agent/tmp/telegram/state${profileSuffix}.json`,
|
|
1073
|
-
logs: `~/.pi/agent/tmp/telegram/logs${
|
|
1075
|
+
logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
|
|
1074
1076
|
};
|
|
1075
1077
|
return [
|
|
1076
1078
|
"connection:",
|
|
@@ -1251,7 +1253,6 @@ function buildContextSummary(
|
|
|
1251
1253
|
}
|
|
1252
1254
|
|
|
1253
1255
|
function buildStatusSummary(ctx: TelegramStatusContext): string {
|
|
1254
|
-
if (ctx.isCompactionInProgress?.()) return "compacting";
|
|
1255
1256
|
if (ctx.hasPendingMessages?.()) return "pending";
|
|
1256
1257
|
if (ctx.isIdle?.() === false) return "active";
|
|
1257
1258
|
if (ctx.isIdle?.() === true) return "idle";
|
package/lib/telegram-api.ts
CHANGED
|
@@ -287,6 +287,44 @@ export interface TelegramFileDownloadOptions {
|
|
|
287
287
|
maxFileSizeBytes?: number;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
export type TelegramGuestCachedMediaResult =
|
|
291
|
+
| {
|
|
292
|
+
type: "document";
|
|
293
|
+
id: string;
|
|
294
|
+
title: string;
|
|
295
|
+
document_file_id: string;
|
|
296
|
+
caption?: string;
|
|
297
|
+
parse_mode?: string;
|
|
298
|
+
}
|
|
299
|
+
| {
|
|
300
|
+
type: "photo";
|
|
301
|
+
id: string;
|
|
302
|
+
photo_file_id: string;
|
|
303
|
+
caption?: string;
|
|
304
|
+
parse_mode?: string;
|
|
305
|
+
}
|
|
306
|
+
| {
|
|
307
|
+
type: "audio";
|
|
308
|
+
id: string;
|
|
309
|
+
audio_file_id: string;
|
|
310
|
+
caption?: string;
|
|
311
|
+
parse_mode?: string;
|
|
312
|
+
}
|
|
313
|
+
| {
|
|
314
|
+
type: "voice";
|
|
315
|
+
id: string;
|
|
316
|
+
voice_file_id: string;
|
|
317
|
+
title: string;
|
|
318
|
+
caption?: string;
|
|
319
|
+
parse_mode?: string;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export interface TelegramAnswerGuestQueryOptions {
|
|
323
|
+
parseMode?: string;
|
|
324
|
+
richMessage?: TelegramInputRichMessage;
|
|
325
|
+
result?: TelegramGuestCachedMediaResult;
|
|
326
|
+
}
|
|
327
|
+
|
|
290
328
|
export interface TelegramAnswerCallbackQueryOptions {
|
|
291
329
|
recordRuntimeEvent?: (
|
|
292
330
|
kind: "api",
|
|
@@ -322,7 +360,7 @@ export interface TelegramApiClient {
|
|
|
322
360
|
answerGuestQuery?: (
|
|
323
361
|
guestQueryId: string,
|
|
324
362
|
text?: string,
|
|
325
|
-
options?:
|
|
363
|
+
options?: TelegramAnswerGuestQueryOptions,
|
|
326
364
|
) => Promise<void>;
|
|
327
365
|
}
|
|
328
366
|
|
|
@@ -401,7 +439,7 @@ export interface TelegramBridgeApiRuntime {
|
|
|
401
439
|
answerGuestQuery: (
|
|
402
440
|
guestQueryId: string,
|
|
403
441
|
text?: string,
|
|
404
|
-
options?:
|
|
442
|
+
options?: TelegramAnswerGuestQueryOptions,
|
|
405
443
|
) => Promise<void>;
|
|
406
444
|
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
|
|
407
445
|
prepareTempDir: () => Promise<number>;
|
|
@@ -1312,12 +1350,12 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1312
1350
|
answerGuestQuery: (
|
|
1313
1351
|
guestQueryId: string,
|
|
1314
1352
|
text: string | undefined,
|
|
1315
|
-
options:
|
|
1316
|
-
| { parseMode?: string; richMessage?: TelegramInputRichMessage }
|
|
1317
|
-
| undefined,
|
|
1353
|
+
options: TelegramAnswerGuestQueryOptions | undefined,
|
|
1318
1354
|
) => {
|
|
1319
1355
|
const body: Record<string, unknown> = { guest_query_id: guestQueryId };
|
|
1320
|
-
if (
|
|
1356
|
+
if (options?.result) {
|
|
1357
|
+
body.result = options.result;
|
|
1358
|
+
} else if (text !== undefined || options?.richMessage) {
|
|
1321
1359
|
const inputContent: Record<string, unknown> = options?.richMessage
|
|
1322
1360
|
? { rich_message: options.richMessage }
|
|
1323
1361
|
: { message_text: text };
|