pi-braid-telegram 0.0.0-stage → 0.1.4
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/LICENSE +21 -0
- package/README.md +204 -2
- package/docs/live-update.md +190 -0
- package/docs/pause-control.md +151 -0
- package/docs/turn-complete-notify.md +110 -0
- package/package.json +60 -4
- package/src/index.ts +1853 -0
- package/src/lib/audio-normalize.ts +91 -0
- package/src/lib/decision-buttons.ts +574 -0
- package/src/lib/document.ts +203 -0
- package/src/lib/format.ts +809 -0
- package/src/lib/html-check.ts +280 -0
- package/src/lib/inbound-poller.ts +1248 -0
- package/src/lib/inbound-queue.ts +157 -0
- package/src/lib/local-time.ts +181 -0
- package/src/lib/notify/agent-delivery.ts +29 -0
- package/src/lib/notify/craft.ts +157 -0
- package/src/lib/notify/live-update.ts +716 -0
- package/src/lib/notify/model.ts +197 -0
- package/src/lib/notify/rate-limit.ts +44 -0
- package/src/lib/notify/turn-notify.ts +138 -0
- package/src/lib/pause-control.ts +425 -0
- package/src/lib/photo.ts +182 -0
- package/src/lib/poller-lock.ts +127 -0
- package/src/lib/session-role.ts +32 -0
- package/src/lib/state-paths.ts +164 -0
- package/src/lib/stt-keys.ts +211 -0
- package/src/lib/telegram-client.ts +699 -0
- package/src/lib/telegram-inbox.ts +203 -0
- package/src/lib/telegram-settings.ts +92 -0
- package/src/lib/tool-logic.ts +604 -0
- package/src/lib/transcribe.ts +574 -0
- package/src/lib/tts.ts +451 -0
- package/src/lib/venice-catalog-base-url.ts +45 -0
- package/src/lib/voice.ts +192 -0
- package/src/lib/watchdog-tunnel.ts +102 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-braid/telegram — bidirectional Telegram channel.
|
|
3
|
+
*
|
|
4
|
+
* Extension factory registering ONE tool (`telegram_send`, outbound) and
|
|
5
|
+
* starting the inbound long-poll injector. Enabled ONLY when
|
|
6
|
+
* `BRAID_TG_BOT_TOKEN` is present (if-has-token guard): with no token the
|
|
7
|
+
* factory returns immediately — no tool is registered, no poller starts, and
|
|
8
|
+
* nothing crashes (debug log only).
|
|
9
|
+
*
|
|
10
|
+
* Live credentials: the TELEGRAM_BOT_TOKEN /
|
|
11
|
+
* TELEGRAM_CHAT_ID / TELEGRAM_ALLOWED_USERS alias names are read as
|
|
12
|
+
* fallbacks to the canonical BRAID_TG_* names (see
|
|
13
|
+
* ENV_ALIASES below) so a later rename is no code change. Voice is
|
|
14
|
+
* config-driven via `braid.telegram.tts` (lib/tts.ts). Ships fully
|
|
15
|
+
* config-driven and unit-testable against fixtures so only live-config
|
|
16
|
+
* values change later.
|
|
17
|
+
*
|
|
18
|
+
* Inbound opt-out: Telegram allows only ONE getUpdates
|
|
19
|
+
* consumer per bot, so every pi instance loading this package would start a
|
|
20
|
+
* long-poll and immediately 409 against the owner's own poller. Set
|
|
21
|
+
* BRAID_TG_POLL_DISABLED (alias TELEGRAM_POLL_DISABLED) to keep the
|
|
22
|
+
* outbound telegram_send tool registered while skipping the inbound poller
|
|
23
|
+
* entirely (no createInboundPoller, no state file, no getUpdates).
|
|
24
|
+
*
|
|
25
|
+
* Inbound injection mechanism (VERIFIED in pi-core source):
|
|
26
|
+
* pi-core ExtensionAPI exposes `sendUserMessage(content, { deliverAs })`
|
|
27
|
+
* (dist/core/extensions/types.d.ts:919) which the runtime implements as a
|
|
28
|
+
* genuine queued user prompt — exactly the agent-zero
|
|
29
|
+
* `context.communicate(UserMessage(...))` equivalent:
|
|
30
|
+
* dist/core/agent-session.js:1103 `sendUserMessage()` ->
|
|
31
|
+
* this.prompt(text, { expandPromptTemplates:false, streamingBehavior:
|
|
32
|
+
* deliverAs, source:"extension" }) // "Always triggers a turn"
|
|
33
|
+
* We wire the poller's `onOwnerMessage` to `pi.sendUserMessage(..., {
|
|
34
|
+
* deliverAs: "steer" })` — while streaming, steer injects the owner
|
|
35
|
+
* message into the CURRENT turn immediately (agent.steer()); the followUp
|
|
36
|
+
* mode would queue it as the agent's NEXT task — minutes of inbound lag.
|
|
37
|
+
*
|
|
38
|
+
* Voice: the client implements sendVoiceNote (OGG/Opus) and the tool accepts
|
|
39
|
+
* `voice`/`voiceText`. Delivery routes through the TTS hook populated from
|
|
40
|
+
* `braid.telegram.tts` at boot (edge-tts → ffmpeg ogg/opus by default).
|
|
41
|
+
* `source: "none"` leaves the hook undefined and `voice:true` then returns a
|
|
42
|
+
* clear error (never silently sends text). Text-first delivery.
|
|
43
|
+
*
|
|
44
|
+
* Voice inbound: owner voice notes are received like photos —
|
|
45
|
+
* downloaded to the media dir and delivered with the absolute path — and,
|
|
46
|
+
* when an STT key is configured, transcribed so the receiving agent sees the
|
|
47
|
+
* transcript. Default path is Venice chat/input_audio on the existing
|
|
48
|
+
* AGENT_ZERO_API_KEY (BRAID_TG_STT_MODE=chat, the deployment default); the
|
|
49
|
+
* chat-mode baseUrl defaults from the userland venice catalog — the gateway
|
|
50
|
+
* this key pairs with (absent catalog falls back to Venice direct).
|
|
51
|
+
* BRAID_TG_STT_MODE=transcriptions opts back to the OpenAI-compatible
|
|
52
|
+
* /v1/audio/transcriptions route. Explicit overrides: BRAID_TG_STT_BASE_URL /
|
|
53
|
+
* BRAID_TG_STT_API_KEY / BRAID_TG_STT_MODEL / BRAID_TG_STT_LANGUAGE. Without
|
|
54
|
+
* STT the note still arrives (path + an "unavailable" note) — never dropped.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import type {
|
|
58
|
+
ExtensionAPI,
|
|
59
|
+
ExtensionContext,
|
|
60
|
+
} from "@earendil-works/pi-coding-agent";
|
|
61
|
+
import { Type } from "typebox";
|
|
62
|
+
import {
|
|
63
|
+
createTelegramClient,
|
|
64
|
+
type TelegramFetch,
|
|
65
|
+
type TelegramUpdate,
|
|
66
|
+
} from "./lib/telegram-client.js";
|
|
67
|
+
import {
|
|
68
|
+
createInboundPoller,
|
|
69
|
+
type IncomingOwnerMessage,
|
|
70
|
+
type InboundPoller,
|
|
71
|
+
type PauseStateReader,
|
|
72
|
+
} from "./lib/inbound-poller.js";
|
|
73
|
+
import {
|
|
74
|
+
inboundQueueFileFor,
|
|
75
|
+
loadInboundQueue,
|
|
76
|
+
saveInboundQueue,
|
|
77
|
+
type QueuedInboundMessage,
|
|
78
|
+
} from "./lib/inbound-queue.js";
|
|
79
|
+
import { publishWatchdogTunnel } from "./lib/watchdog-tunnel.js";
|
|
80
|
+
import {
|
|
81
|
+
DEFAULT_STT_CHAT_BASE_URL,
|
|
82
|
+
DEFAULT_STT_CHAT_MODEL,
|
|
83
|
+
type SttConfig,
|
|
84
|
+
} from "./lib/transcribe.js";
|
|
85
|
+
import { resolveSttKeyCandidates } from "./lib/stt-keys.js";
|
|
86
|
+
import { normalizeAudioBytes } from "./lib/audio-normalize.js";
|
|
87
|
+
import { populateVoiceTts } from "./lib/tts.js";
|
|
88
|
+
import { runTelegramSend } from "./lib/tool-logic.js";
|
|
89
|
+
import { fetchInbox, formatInboxMessage } from "./lib/telegram-inbox.js";
|
|
90
|
+
import { formatWhisperClock } from "./lib/local-time.js";
|
|
91
|
+
import {
|
|
92
|
+
createTextCraftHook,
|
|
93
|
+
createRichCraftHook,
|
|
94
|
+
resolveNotifyModel,
|
|
95
|
+
callCraftModel,
|
|
96
|
+
} from "./lib/notify/model.js";
|
|
97
|
+
import {
|
|
98
|
+
extractFinalText,
|
|
99
|
+
shouldNotifyTurnEnd,
|
|
100
|
+
isTerminalNoReportTurn,
|
|
101
|
+
} from "./lib/notify/craft.js";
|
|
102
|
+
import {
|
|
103
|
+
createTurnNotifyHandler,
|
|
104
|
+
DEFAULT_NOTIFY_MAX_PER_MIN,
|
|
105
|
+
type NotifyModelCtx,
|
|
106
|
+
} from "./lib/notify/turn-notify.js";
|
|
107
|
+
import {
|
|
108
|
+
createLiveUpdateTracker,
|
|
109
|
+
defaultLiveUpdateStateFile,
|
|
110
|
+
DEFAULT_LIVE_UPDATE_MAX_PER_MIN,
|
|
111
|
+
type LiveUpdateTracker,
|
|
112
|
+
} from "./lib/notify/live-update.js";
|
|
113
|
+
import {
|
|
114
|
+
defaultStateFilePath,
|
|
115
|
+
setPathRefusalSink,
|
|
116
|
+
warnUnderivableRoot,
|
|
117
|
+
} from "./lib/state-paths.js";
|
|
118
|
+
import {
|
|
119
|
+
acquirePollerLock,
|
|
120
|
+
defaultPollerLockPath,
|
|
121
|
+
describeLockHolder,
|
|
122
|
+
releasePollerLock,
|
|
123
|
+
type PollerLockInfo,
|
|
124
|
+
} from "./lib/poller-lock.js";
|
|
125
|
+
import { isWorkerRole, resolveSessionRole } from "./lib/session-role.js";
|
|
126
|
+
import {
|
|
127
|
+
handlePauseCallbackQuery,
|
|
128
|
+
placeControlKeyboard,
|
|
129
|
+
resolvePauseStateFile,
|
|
130
|
+
getPauseState as readPauseState,
|
|
131
|
+
type PauseCallbackQuery,
|
|
132
|
+
} from "./lib/pause-control.js";
|
|
133
|
+
import { handleDecisionCallbackQuery } from "./lib/decision-buttons.js";
|
|
134
|
+
import { readCatalogBaseUrl } from "./lib/venice-catalog-base-url.js";
|
|
135
|
+
|
|
136
|
+
// Env configuration (mirrors providers' env-key conventions)
|
|
137
|
+
|
|
138
|
+
export const ENV_TOKEN = "BRAID_TG_BOT_TOKEN";
|
|
139
|
+
export const ENV_CHAT_ID = "BRAID_TG_CHAT_ID";
|
|
140
|
+
export const ENV_ALLOWED_USERS = "BRAID_TG_ALLOWED_USERS";
|
|
141
|
+
export const ENV_STATE_FILE = "BRAID_TG_STATE_FILE";
|
|
142
|
+
export const ENV_MEDIA_DIR = "BRAID_TG_MEDIA_DIR";
|
|
143
|
+
export const ENV_POLL_INTERVAL_MS = "BRAID_TG_POLL_INTERVAL_MS";
|
|
144
|
+
/**
|
|
145
|
+
* Coalescing window (ms) for inbound owner messages: a burst is handed over
|
|
146
|
+
* as ONE user message when the window closes, idle or busy, because the
|
|
147
|
+
* handoff is a steer that reaches the running turn instead of waiting for
|
|
148
|
+
* the next idle boundary. Anchored at the FIRST arrival (never reset by
|
|
149
|
+
* later arrivals), so a lone message is delayed by at most one window; the
|
|
150
|
+
* default is longer than the default poll interval, so a burst split across
|
|
151
|
+
* poll cycles still coalesces. `0` closes the window on the next tick.
|
|
152
|
+
* Tests set it small.
|
|
153
|
+
*/
|
|
154
|
+
export const ENV_COALESCE_WINDOW_MS = "BRAID_TG_COALESCE_WINDOW_MS";
|
|
155
|
+
export const DEFAULT_COALESCE_WINDOW_MS = 2000;
|
|
156
|
+
export const ENV_POLL_DISABLED = "BRAID_TG_POLL_DISABLED";
|
|
157
|
+
export const ENV_POLL_ENABLED = "BRAID_TG_POLL_ENABLED";
|
|
158
|
+
/** Machine-wide poller ownership lock (BRAID_HOME-overridable default). */
|
|
159
|
+
export const ENV_POLLER_LOCK_FILE = "BRAID_TG_POLLER_LOCK_FILE";
|
|
160
|
+
export const ENV_VERBOSE = "BRAID_TG_VERBOSE";
|
|
161
|
+
|
|
162
|
+
// session role (poller ownership)
|
|
163
|
+
// The marker the orchestrator seat exports into a worker's pane before pi
|
|
164
|
+
// starts. Only "worker" changes behavior; unset/seat keeps the default.
|
|
165
|
+
export {
|
|
166
|
+
ENV_SESSION_ROLE,
|
|
167
|
+
isWorkerRole,
|
|
168
|
+
resolveSessionRole,
|
|
169
|
+
} from "./lib/session-role.js";
|
|
170
|
+
// voice inbound STT
|
|
171
|
+
export const ENV_STT_BASE_URL = "BRAID_TG_STT_BASE_URL";
|
|
172
|
+
export const ENV_STT_API_KEY = "BRAID_TG_STT_API_KEY";
|
|
173
|
+
export const ENV_STT_MODEL = "BRAID_TG_STT_MODEL";
|
|
174
|
+
export const ENV_STT_LANGUAGE = "BRAID_TG_STT_LANGUAGE";
|
|
175
|
+
export const ENV_STT_MODE = "BRAID_TG_STT_MODE";
|
|
176
|
+
export const ENV_STT_KEYS = "BRAID_TG_STT_KEYS";
|
|
177
|
+
export const ENV_STT_KEYRING_SERVICE = "BRAID_TG_STT_KEYRING_SERVICE";
|
|
178
|
+
export const ENV_STT_NORMALIZE = "BRAID_TG_STT_NORMALIZE";
|
|
179
|
+
|
|
180
|
+
/** STT runtime counters (attempts, successes by candidate index,
|
|
181
|
+
* normalizations applied, failures by class) — read-only snapshot. */
|
|
182
|
+
export { getSttCounters } from "./lib/stt-keys.js";
|
|
183
|
+
|
|
184
|
+
/** Input for the pure STT baseUrl resolver (testable seam). */
|
|
185
|
+
export interface ResolveSttBaseUrlInput {
|
|
186
|
+
/** BRAID_TG_STT_BASE_URL value (undefined when unset). Always wins. */
|
|
187
|
+
env?: string;
|
|
188
|
+
/** readCatalogBaseUrl() result (null when the catalog is absent/invalid). */
|
|
189
|
+
catalogBaseUrl?: string | null;
|
|
190
|
+
/** STT mode; the catalog default applies to "chat" only (default "chat"). */
|
|
191
|
+
mode?: "transcriptions" | "chat";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Pure STT baseUrl resolution. Precedence: explicit env override > catalog-
|
|
196
|
+
* derived baseUrl (chat mode only) > legacy Venice-direct default. The
|
|
197
|
+
* catalog is consulted for CHAT mode only — it is a chat-completions proxy,
|
|
198
|
+
* not an /audio/transcriptions route. `??`-semantics on env: an empty-string
|
|
199
|
+
* override still wins (an empty string is not the default).
|
|
200
|
+
*/
|
|
201
|
+
export function resolveSttBaseUrl(input: ResolveSttBaseUrlInput): string {
|
|
202
|
+
if (input.env != null) return input.env;
|
|
203
|
+
if (input.catalogBaseUrl && (input.mode ?? "chat") === "chat") {
|
|
204
|
+
return input.catalogBaseUrl;
|
|
205
|
+
}
|
|
206
|
+
return DEFAULT_STT_CHAT_BASE_URL;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// turn-complete auto-notify
|
|
210
|
+
export const ENV_NOTIFY_ENABLED = "BRAID_TG_NOTIFY_ENABLED";
|
|
211
|
+
export const ENV_NOTIFY_MODEL = "BRAID_TG_NOTIFY_MODEL";
|
|
212
|
+
export const ENV_NOTIFY_MAX_PER_MIN = "BRAID_TG_NOTIFY_MAX_PER_MIN";
|
|
213
|
+
// live-update tracked message
|
|
214
|
+
export const ENV_LIVE_UPDATE_ENABLED = "BRAID_TG_LIVE_UPDATE_ENABLED";
|
|
215
|
+
export const ENV_LIVE_UPDATE_MAX_PER_MIN = "BRAID_TG_LIVE_UPDATE_MAX_PER_MIN";
|
|
216
|
+
export const ENV_LIVE_UPDATE_STATE_FILE = "BRAID_TG_LIVE_UPDATE_STATE_FILE";
|
|
217
|
+
|
|
218
|
+
// Watchdog tg-signal seam (cross-package)
|
|
219
|
+
// Publish a 1-line plain-text sender for the core watchdog's optional remote
|
|
220
|
+
// signal (escalation "keeps the remote canary warm": in-session wake whisper
|
|
221
|
+
// +, when tg is up, a compact 1-line tg ping). Core reads the slot at
|
|
222
|
+
// escalate time; an absent slot (telegram not loaded) makes the optional
|
|
223
|
+
// signal a silent no-op. Same process-global seam pattern as the tunnel hub
|
|
224
|
+
// and providers' key-ring feedback — the literal MUST stay in sync with
|
|
225
|
+
// packages/core/src/lib/state/global-state.ts WATCHDOG_TG_SIGNAL_KEY.
|
|
226
|
+
export const WATCHDOG_TG_SIGNAL_KEY = "__braid_watchdog_tg_signal";
|
|
227
|
+
|
|
228
|
+
// Provider-outage notify seam (cross-package)
|
|
229
|
+
// Publish a plain-text sender for the providers outage watchdog: a provider
|
|
230
|
+
// connectivity outage holds the agent loop, so its alert cannot ride the
|
|
231
|
+
// in-session whisper path — it goes straight out through the same
|
|
232
|
+
// resolveChatId + client.sendMessage route every telegram tool uses. The
|
|
233
|
+
// watcher reads the slot at transition time and treats an absent slot (no
|
|
234
|
+
// token, or telegram not loaded) as "no owner alert channel"; the literal
|
|
235
|
+
// MUST stay in sync with packages/providers/outage-watch.ts
|
|
236
|
+
// PROVIDER_OUTAGE_NOTIFY_KEY. The sender returns false on any send failure
|
|
237
|
+
// so the watcher can release its cross-process dedupe claim.
|
|
238
|
+
export const PROVIDER_OUTAGE_NOTIFY_KEY = "__braid_provider_outage_notify";
|
|
239
|
+
|
|
240
|
+
// TG poller liveness seam (cross-package)
|
|
241
|
+
// Publish a live poller probe for the prompt-builder's awareness whisper: the
|
|
242
|
+
// "TG inbound" catalog row must not assert reachability when the poller is
|
|
243
|
+
// not running (the poller died silently three times while the row kept
|
|
244
|
+
// asserting). Core owns the registry at
|
|
245
|
+
// packages/core/src/lib/state/telegram-poller-liveness.ts; the literal is
|
|
246
|
+
// mirrored here because this package has no core dep — same seam pattern as
|
|
247
|
+
// the tunnel hub and the tg-signal sender above.
|
|
248
|
+
export const TELEGRAM_POLLER_LIVENESS_KEY = "__braid_tg_poller_liveness";
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Owner-side env aliases: TELEGRAM_* and BRAID_TG_* both resolve, BRAID_TG_*
|
|
252
|
+
* first. It wins when set;
|
|
253
|
+
* TELEGRAM_* is read only as a fallback, so deployments using the TELEGRAM_*
|
|
254
|
+
* names keep working unchanged, and a rename to BRAID_TG_* is a no-op. Const
|
|
255
|
+
* names stay BRAID_TG_* (tests reference them).
|
|
256
|
+
*/
|
|
257
|
+
const ENV_ALIASES: Record<string, string | undefined> = {
|
|
258
|
+
[ENV_TOKEN]: "TELEGRAM_BOT_TOKEN",
|
|
259
|
+
[ENV_CHAT_ID]: "TELEGRAM_CHAT_ID",
|
|
260
|
+
[ENV_ALLOWED_USERS]: "TELEGRAM_ALLOWED_USERS",
|
|
261
|
+
[ENV_STATE_FILE]: "TELEGRAM_STATE_FILE",
|
|
262
|
+
[ENV_MEDIA_DIR]: "TELEGRAM_MEDIA_DIR",
|
|
263
|
+
[ENV_POLL_INTERVAL_MS]: "TELEGRAM_POLL_INTERVAL_MS",
|
|
264
|
+
[ENV_COALESCE_WINDOW_MS]: "TELEGRAM_COALESCE_WINDOW_MS",
|
|
265
|
+
[ENV_POLL_DISABLED]: "TELEGRAM_POLL_DISABLED",
|
|
266
|
+
[ENV_POLL_ENABLED]: "TELEGRAM_POLL_ENABLED",
|
|
267
|
+
[ENV_POLLER_LOCK_FILE]: "TELEGRAM_POLLER_LOCK_FILE",
|
|
268
|
+
[ENV_VERBOSE]: "TELEGRAM_VERBOSE",
|
|
269
|
+
[ENV_STT_BASE_URL]: "TELEGRAM_STT_BASE_URL",
|
|
270
|
+
[ENV_STT_API_KEY]: "TELEGRAM_STT_API_KEY",
|
|
271
|
+
[ENV_STT_MODEL]: "TELEGRAM_STT_MODEL",
|
|
272
|
+
[ENV_STT_LANGUAGE]: "TELEGRAM_STT_LANGUAGE",
|
|
273
|
+
[ENV_STT_MODE]: "TELEGRAM_STT_MODE",
|
|
274
|
+
[ENV_STT_KEYS]: "TELEGRAM_STT_KEYS",
|
|
275
|
+
[ENV_STT_KEYRING_SERVICE]: "TELEGRAM_STT_KEYRING_SERVICE",
|
|
276
|
+
[ENV_STT_NORMALIZE]: "TELEGRAM_STT_NORMALIZE",
|
|
277
|
+
[ENV_NOTIFY_ENABLED]: "TELEGRAM_NOTIFY_ENABLED",
|
|
278
|
+
[ENV_NOTIFY_MODEL]: "TELEGRAM_NOTIFY_MODEL",
|
|
279
|
+
[ENV_NOTIFY_MAX_PER_MIN]: "TELEGRAM_NOTIFY_MAX_PER_MIN",
|
|
280
|
+
[ENV_LIVE_UPDATE_ENABLED]: "TELEGRAM_LIVE_UPDATE_ENABLED",
|
|
281
|
+
[ENV_LIVE_UPDATE_MAX_PER_MIN]: "TELEGRAM_LIVE_UPDATE_MAX_PER_MIN",
|
|
282
|
+
[ENV_LIVE_UPDATE_STATE_FILE]: "TELEGRAM_LIVE_UPDATE_STATE_FILE",
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Lookup one config key: canonical BRAID_TG_* value if set, else the
|
|
287
|
+
* TELEGRAM_* alias (if any). Trimmed; empty counts as unset. Exported so
|
|
288
|
+
* tests can assert which name wins.
|
|
289
|
+
*/
|
|
290
|
+
export function env(name: string): string | undefined {
|
|
291
|
+
const canonical = process.env[name]?.trim();
|
|
292
|
+
if (canonical) return canonical;
|
|
293
|
+
const alias = ENV_ALIASES[name];
|
|
294
|
+
const fallback = alias ? process.env[alias]?.trim() : undefined;
|
|
295
|
+
return fallback ? fallback : undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Comma-separated allow-list: numeric ids and/or @usernames. */
|
|
299
|
+
function parseAllowedUsers(envValue?: string): Array<number | string> {
|
|
300
|
+
const allowed: Array<number | string> = [];
|
|
301
|
+
const chatId = env(ENV_CHAT_ID);
|
|
302
|
+
if (chatId && /^\d+$/.test(chatId)) allowed.push(Number(chatId));
|
|
303
|
+
for (const entry of (envValue ?? "").split(",")) {
|
|
304
|
+
const s = entry.trim();
|
|
305
|
+
if (!s) continue;
|
|
306
|
+
if (/^\d+$/.test(s)) allowed.push(Number(s));
|
|
307
|
+
else allowed.push(s);
|
|
308
|
+
}
|
|
309
|
+
return allowed;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* TUI-safe log sink — notify-shaped, same signature as pi's `ctx.ui.notify`
|
|
314
|
+
* (`(message, type: "info" | "warning" | "error")`). The factory binds it to
|
|
315
|
+
* the session's `ctx.ui.notify`; in headless/RPC/print runs (or with no sink
|
|
316
|
+
* set) it stays undefined and log() is SILENT — never console.*.
|
|
317
|
+
*/
|
|
318
|
+
export type TelegramLogSink = (
|
|
319
|
+
message: string,
|
|
320
|
+
type: "info" | "warning" | "error",
|
|
321
|
+
) => void;
|
|
322
|
+
|
|
323
|
+
/** Module-level notify sink (native pi-core pattern — see core's logger.ts). */
|
|
324
|
+
let logSink: TelegramLogSink | undefined;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Set (or clear) the log sink log() routes through. The extension factory
|
|
328
|
+
* binds this to `ctx.ui.notify` on `session_start` (the factory's ExtensionAPI
|
|
329
|
+
* has no `ui` — only event/command contexts do) and clears it on
|
|
330
|
+
* `session_shutdown`. With NO sink configured — headless/RPC/print modes or
|
|
331
|
+
* before the first session — warn/error are SILENT. Never console.*, so the
|
|
332
|
+
* TUI input field never sees a telegram line.
|
|
333
|
+
*/
|
|
334
|
+
export function setLogSink(sink: TelegramLogSink | undefined): void {
|
|
335
|
+
logSink = sink;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Join extra args into the notify message (Errors become their message). */
|
|
339
|
+
function logDetail(rest: unknown[]): string {
|
|
340
|
+
if (rest.length === 0) return "";
|
|
341
|
+
return ` ${rest.map((v) => (v instanceof Error ? v.message : String(v))).join(" ")}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Extension logger (TUI-safe).
|
|
346
|
+
*
|
|
347
|
+
* warn+error route through the `setLogSink(ctx.ui.notify)` sink and NEVER
|
|
348
|
+
* console.*: any console write from an extension in the live pi TUI is
|
|
349
|
+
* restored into the owner's INPUT line and corrupts it. With no sink bound
|
|
350
|
+
* (headless/print runs or before the first session_start) they are SILENT.
|
|
351
|
+
* debug+info emit NOTHING by default — same TUI-corruption reason — unless
|
|
352
|
+
* BRAID_TG_VERBOSE=1 (alias TELEGRAM_VERBOSE), which routes them through the
|
|
353
|
+
* sink as info toasts (still never console). inbound-poller.ts throttles the
|
|
354
|
+
* repeating poll failures; this gate closes the remaining
|
|
355
|
+
* stdout path. Exported so tests can pin the default-silent no-console
|
|
356
|
+
* contract.
|
|
357
|
+
*/
|
|
358
|
+
export function log(
|
|
359
|
+
level: "debug" | "info" | "warn" | "error",
|
|
360
|
+
message: string,
|
|
361
|
+
...rest: unknown[]
|
|
362
|
+
): void {
|
|
363
|
+
if (level === "debug" || level === "info") {
|
|
364
|
+
if (!env(ENV_VERBOSE)) return; // default: zero output
|
|
365
|
+
logSink?.(`[telegram] ${message}${logDetail(rest)}`, "info");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// warn/error: sink only; no sink → silent (never console.*).
|
|
369
|
+
logSink?.(
|
|
370
|
+
`[telegram] ${message}${logDetail(rest)}`,
|
|
371
|
+
level === "warn" ? "warning" : "error",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Inbound formatting (agent-zero prompts/fw.telegram.user_message.md template)
|
|
376
|
+
|
|
377
|
+
export function formatInboundMessage(message: IncomingOwnerMessage): string {
|
|
378
|
+
const sender = message.username
|
|
379
|
+
? `@${message.username}`
|
|
380
|
+
: `id ${message.fromId}`;
|
|
381
|
+
const name = message.firstName ? ` (${message.firstName})` : "";
|
|
382
|
+
// `@ HH:MM TZ` = the update's send time (TelegramUpdate.date via receivedAt,
|
|
383
|
+
// Owner TZ — mirrored core formatter): a stale queued
|
|
384
|
+
// message is distinguishable from a fresh one.
|
|
385
|
+
const sentClock = Number.isFinite(message.receivedAt)
|
|
386
|
+
? ` @ ${formatWhisperClock(message.receivedAt)}`
|
|
387
|
+
: "";
|
|
388
|
+
// The opening tag line carries nothing after `]`: the provenance line
|
|
389
|
+
// starts on its own line so the envelope header is readable at a glance.
|
|
390
|
+
const header = `[braid-whisper - telegram${sentClock}]\nfrom ${sender}${name} in chat ${message.chatId}/${message.chatType}`;
|
|
391
|
+
return `${header}\n\n${message.text.trim()}\n[braid-whisper-end]`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* One delivery carrying N owner messages, in arrival order. Each part keeps
|
|
396
|
+
* its own "from ... in chat ..." line and send-time clock (`@ HH:MM TZ`), so
|
|
397
|
+
* provenance and ordering survive the coalescing; the header's count makes it
|
|
398
|
+
* legible that this is ONE delivery of N (never N deliveries). A single
|
|
399
|
+
* message returns the plain formatInboundMessage envelope unchanged: a lone
|
|
400
|
+
* message must not look like a batch.
|
|
401
|
+
*/
|
|
402
|
+
export function formatInboundBatch(messages: IncomingOwnerMessage[]): string {
|
|
403
|
+
if (messages.length === 1) return formatInboundMessage(messages[0]);
|
|
404
|
+
const header = `[braid-whisper - telegram]\n${messages.length} coalesced owner messages`;
|
|
405
|
+
const parts = messages.map((message, index) => {
|
|
406
|
+
const sender = message.username
|
|
407
|
+
? `@${message.username}`
|
|
408
|
+
: `id ${message.fromId}`;
|
|
409
|
+
const name = message.firstName ? ` (${message.firstName})` : "";
|
|
410
|
+
const sentClock = Number.isFinite(message.receivedAt)
|
|
411
|
+
? ` @ ${formatWhisperClock(message.receivedAt)}`
|
|
412
|
+
: "";
|
|
413
|
+
const partHeader = `[${index + 1}/${messages.length}${sentClock}] from ${sender}${name} in chat ${message.chatId}/${message.chatType}`;
|
|
414
|
+
return `${partHeader}\n\n${message.text.trim()}`;
|
|
415
|
+
});
|
|
416
|
+
return `${header}\n\n${parts.join("\n\n")}\n[braid-whisper-end]`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** if-has-token guard predicate (exported for tests). */
|
|
420
|
+
export function isTelegramEnabled(): boolean {
|
|
421
|
+
return Boolean(env(ENV_TOKEN));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Injectable dependencies for tests (fixture pattern); defaults match the live runtime. */
|
|
425
|
+
export interface TelegramExtensionDeps {
|
|
426
|
+
/** Injectable fetch (tests use a recorded-fixture fake; defaults to globalThis.fetch). */
|
|
427
|
+
fetch?: TelegramFetch;
|
|
428
|
+
/** Live-pid probe for the ownership lock (tests inject; default process.kill(pid, 0)). */
|
|
429
|
+
pidAlive?: (pid: number) => boolean;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Singleton background poller (OFF by default)
|
|
433
|
+
|
|
434
|
+
/** The single active background poller, if any. At most one per process. */
|
|
435
|
+
let activePoller: InboundPoller | undefined;
|
|
436
|
+
/** Which extension started it (for diagnostics). */
|
|
437
|
+
let pollerStartedBy: string | undefined;
|
|
438
|
+
/**
|
|
439
|
+
* Poller intent across agent-session reloads: pi-core emits
|
|
440
|
+
* session_shutdown on EVERY reload (agent-session.js reload() → emit
|
|
441
|
+
* session_shutdown), and the rebuilt runtime re-runs this factory with the
|
|
442
|
+
* default-off branch — so a plain reload would otherwise strand the inbound
|
|
443
|
+
* channel silently. When true, the session_start re-arm restarts the poller
|
|
444
|
+
* with fresh bindings. Explicit disables clear it.
|
|
445
|
+
*/
|
|
446
|
+
let pollerWanted = false;
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Why a start attempt did not start: `already-running` (this process's own
|
|
450
|
+
* singleton), `role-forbidden` (worker session — the seat owns the poller),
|
|
451
|
+
* `lock-held` (another LIVE process owns the machine-wide lock; `holder`
|
|
452
|
+
* names it), or `lock-path-underivable` (no absolute braid root, so the
|
|
453
|
+
* ownership lock has no home). Callers render the reason for their own
|
|
454
|
+
* surface.
|
|
455
|
+
*/
|
|
456
|
+
type PollerStartRefusal =
|
|
457
|
+
| { started: false; reason: "already-running" }
|
|
458
|
+
| { started: false; reason: "role-forbidden" }
|
|
459
|
+
| { started: false; reason: "lock-held"; holder: PollerLockInfo }
|
|
460
|
+
| { started: false; reason: "lock-path-underivable" };
|
|
461
|
+
type PollerStartOutcome = { started: true } | PollerStartRefusal;
|
|
462
|
+
|
|
463
|
+
/** Refusal body call sites append to their own surface-specific prefix. */
|
|
464
|
+
function describePollerStartRefusal(outcome: PollerStartRefusal): string {
|
|
465
|
+
if (outcome.reason === "lock-held") {
|
|
466
|
+
return `ownership lock held by ${describeLockHolder(outcome.holder)}`;
|
|
467
|
+
}
|
|
468
|
+
if (outcome.reason === "role-forbidden") {
|
|
469
|
+
return "worker role — the orchestrator seat owns the Telegram poller";
|
|
470
|
+
}
|
|
471
|
+
if (outcome.reason === "lock-path-underivable") {
|
|
472
|
+
return "no absolute braid root for the ownership lock (set BRAID_HOME)";
|
|
473
|
+
}
|
|
474
|
+
return `already running (started by ${pollerStartedBy ?? "unknown"})`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Start the singleton background poller. Idempotent — if one is already
|
|
479
|
+
* running this is a no-op. Gates, in order: the per-process singleton, the
|
|
480
|
+
* session role, then the machine-wide ownership lock.
|
|
481
|
+
*/
|
|
482
|
+
function startSingletonPoller(opts: {
|
|
483
|
+
token: string;
|
|
484
|
+
allowedUsers: Array<number | string>;
|
|
485
|
+
stateFile: string;
|
|
486
|
+
fetchFn: TelegramFetch;
|
|
487
|
+
intervalMs: number;
|
|
488
|
+
mediaDir?: string;
|
|
489
|
+
stt?: SttConfig;
|
|
490
|
+
onOwnerMessage: (msg: IncomingOwnerMessage) => void;
|
|
491
|
+
onCallbackQuery?: (update: TelegramUpdate) => void | Promise<void>;
|
|
492
|
+
/**
|
|
493
|
+
* Pause-gate reader: the poller gates NON-callback
|
|
494
|
+
* Owner delivery on this. Defaults to the real getPauseState() (resolves the
|
|
495
|
+
* same settings/default state file the callback handler uses), so the gate
|
|
496
|
+
* is ON in production whenever the flag is paused — no new config keys.
|
|
497
|
+
*/
|
|
498
|
+
getPauseState?: PauseStateReader;
|
|
499
|
+
startedBy: string;
|
|
500
|
+
/**
|
|
501
|
+
* Post-start hook: invoked once AFTER the poller is
|
|
502
|
+
* running. This is the lifecycle seam where the orchestrator (index.ts)
|
|
503
|
+
* places the ⏸/▶️/📊 control keyboard into the owner chat — the factory
|
|
504
|
+
* supplies a closure that reads the pause flag and sendControlKeyboard's, so
|
|
505
|
+
* the keyboard actually appears on every poller start (boot env auto-start,
|
|
506
|
+
* /telegram enable, telegram_poller enable).
|
|
507
|
+
*/
|
|
508
|
+
onStart?: () => void;
|
|
509
|
+
/** Live-pid probe for the ownership lock (tests inject; default isPidAlive). */
|
|
510
|
+
pidAlive?: (pid: number) => boolean;
|
|
511
|
+
}): PollerStartOutcome {
|
|
512
|
+
if (activePoller?.isRunning()) {
|
|
513
|
+
log(
|
|
514
|
+
"info",
|
|
515
|
+
`telegram poller already running (started by ${pollerStartedBy ?? "unknown"}) — not starting another`,
|
|
516
|
+
);
|
|
517
|
+
return { started: false, reason: "already-running" };
|
|
518
|
+
}
|
|
519
|
+
// Role gate: a worker session never owns the channel — the poller is the
|
|
520
|
+
// orchestrator seat's machine-wide singleton, so every start path stops
|
|
521
|
+
// here in the worker role.
|
|
522
|
+
if (isWorkerRole()) {
|
|
523
|
+
log(
|
|
524
|
+
"info",
|
|
525
|
+
"telegram poller not started: worker role — the orchestrator seat owns the poller",
|
|
526
|
+
);
|
|
527
|
+
return { started: false, reason: "role-forbidden" };
|
|
528
|
+
}
|
|
529
|
+
// Machine-wide ownership lock: refuse while a live foreign pid holds the
|
|
530
|
+
// getUpdates singleton; our own pid (reload cycles) and stale locks (dead
|
|
531
|
+
// pid, unreadable file) are taken over silently. An underivable lock path
|
|
532
|
+
// refuses the start: without the lock two processes could poll the same bot.
|
|
533
|
+
const lockPath = pollerLockFile();
|
|
534
|
+
if (lockPath === null) {
|
|
535
|
+
warnUnderivableRoot("poller lock refused");
|
|
536
|
+
log(
|
|
537
|
+
"warn",
|
|
538
|
+
"telegram poller not started: the ownership lock path cannot be derived " +
|
|
539
|
+
"(BRAID_HOME is unset and no absolute home resolves)",
|
|
540
|
+
);
|
|
541
|
+
return { started: false, reason: "lock-path-underivable" };
|
|
542
|
+
}
|
|
543
|
+
const acquired = acquirePollerLock(
|
|
544
|
+
lockPath,
|
|
545
|
+
{
|
|
546
|
+
pid: process.pid,
|
|
547
|
+
startedAt: Date.now(),
|
|
548
|
+
role: resolveSessionRole(),
|
|
549
|
+
startedBy: opts.startedBy,
|
|
550
|
+
},
|
|
551
|
+
opts.pidAlive,
|
|
552
|
+
);
|
|
553
|
+
if (!acquired.ok) {
|
|
554
|
+
log(
|
|
555
|
+
"warn",
|
|
556
|
+
`telegram poller not started: ${describeLockHolder(acquired.holder)} holds the ownership lock — refusing to steal the getUpdates singleton`,
|
|
557
|
+
);
|
|
558
|
+
return { started: false, reason: "lock-held", holder: acquired.holder };
|
|
559
|
+
}
|
|
560
|
+
activePoller = createInboundPoller({
|
|
561
|
+
token: opts.token,
|
|
562
|
+
allowedUsers: opts.allowedUsers,
|
|
563
|
+
stateFile: opts.stateFile,
|
|
564
|
+
fetchFn: opts.fetchFn,
|
|
565
|
+
mediaDir: opts.mediaDir,
|
|
566
|
+
stt: opts.stt,
|
|
567
|
+
intervalMs: opts.intervalMs,
|
|
568
|
+
bootstrap: true,
|
|
569
|
+
onOwnerMessage: opts.onOwnerMessage,
|
|
570
|
+
onCallbackQuery: opts.onCallbackQuery,
|
|
571
|
+
// Pause gate: real reader by default → inbound is held + acked once per
|
|
572
|
+
// paused period while the flag is paused (callbacks stay ungated).
|
|
573
|
+
getPauseState: opts.getPauseState ?? readPauseState,
|
|
574
|
+
onError: (error) => log("warn", `inbound poll error: ${String(error)}`),
|
|
575
|
+
// Watchdog canary: relay the poller's tunnel liveness transitions into
|
|
576
|
+
// the process-global watchdog hub the core watchdog subscribes to. One
|
|
577
|
+
// emitter (the poller) -> one relay -> one consumer (core watchdog).
|
|
578
|
+
onTunnelStatusChange: (event) => publishWatchdogTunnel(event),
|
|
579
|
+
log,
|
|
580
|
+
});
|
|
581
|
+
activePoller.start();
|
|
582
|
+
pollerStartedBy = opts.startedBy;
|
|
583
|
+
pollerWanted = true;
|
|
584
|
+
log(
|
|
585
|
+
"info",
|
|
586
|
+
`telegram poller started by ${opts.startedBy} (allow-list: ${JSON.stringify(opts.allowedUsers.length)}, state: ${opts.stateFile})`,
|
|
587
|
+
);
|
|
588
|
+
// Post-start seam: the orchestrator places the control keyboard now that the
|
|
589
|
+
// poller is live — deferred to the first owner
|
|
590
|
+
// message when the owner chat is not yet known.
|
|
591
|
+
opts.onStart?.();
|
|
592
|
+
return { started: true };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Lock file path: explicit env override first, then the braid-home default.
|
|
596
|
+
* Null when the default root cannot be derived. */
|
|
597
|
+
function pollerLockFile(): string | null {
|
|
598
|
+
return env(ENV_POLLER_LOCK_FILE) ?? defaultPollerLockPath();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Stop the singleton background poller if running. */
|
|
602
|
+
function stopSingletonPoller(): boolean {
|
|
603
|
+
if (!activePoller?.isRunning()) return false;
|
|
604
|
+
pollerWanted = false;
|
|
605
|
+
activePoller.stop();
|
|
606
|
+
activePoller = undefined;
|
|
607
|
+
pollerStartedBy = undefined;
|
|
608
|
+
// An explicit disable gives the channel up machine-wide; the reload-path
|
|
609
|
+
// shutdown does NOT release — the session_start re-arm re-acquires our own
|
|
610
|
+
// pid's lock without a window where another process could claim it.
|
|
611
|
+
const lockPath = pollerLockFile();
|
|
612
|
+
if (lockPath !== null) releasePollerLock(lockPath);
|
|
613
|
+
log("info", "telegram poller stopped");
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function isPollerRunning(): boolean {
|
|
618
|
+
return activePoller?.isRunning() ?? false;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export default async function telegramExtension(
|
|
622
|
+
pi: ExtensionAPI,
|
|
623
|
+
deps?: TelegramExtensionDeps,
|
|
624
|
+
): Promise<void> {
|
|
625
|
+
// The collision gate below needs a live ExtensionContext to ask pi's
|
|
626
|
+
// supported idle question (ctx.isIdle()); the poller callbacks that deliver
|
|
627
|
+
// owner messages have no handler context, so the lifecycle handlers refresh
|
|
628
|
+
// this reference. A replaced session makes the old context throw on access;
|
|
629
|
+
// the gate then treats the session as idle and delivers immediately.
|
|
630
|
+
let liveCtx: ExtensionContext | undefined;
|
|
631
|
+
|
|
632
|
+
// TUI notify sink (native pi-core pattern)
|
|
633
|
+
// The factory's ExtensionAPI has no `ui`; the session_start handler is the
|
|
634
|
+
// first context carrying ctx.ui.notify. In RPC/print/headless runs pi hands
|
|
635
|
+
// notify a no-op, so these lines are inert there; cleared on shutdown so no
|
|
636
|
+
// stale reference outlives the session. Gates every log() call whether or
|
|
637
|
+
// not the guard below lets the channel start.
|
|
638
|
+
pi.on("session_start", (_event, ctx) => {
|
|
639
|
+
liveCtx = ctx;
|
|
640
|
+
setLogSink(ctx?.ui?.notify ? ctx.ui.notify.bind(ctx.ui) : undefined);
|
|
641
|
+
});
|
|
642
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
643
|
+
liveCtx = undefined;
|
|
644
|
+
setLogSink(undefined);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// publish the live poller probe for the awareness whisper's TG row
|
|
648
|
+
// (see TELEGRAM_POLLER_LIVENESS_KEY above). Written before the if-has-token
|
|
649
|
+
// guard so no-token deployments (poller can never start) read "stopped",
|
|
650
|
+
// not "unknown". The probe reads the module-local poller singleton live on
|
|
651
|
+
// every call, so start/stop/silent-death are all captured without per-event
|
|
652
|
+
// wiring; each factory run (including post-reload) re-binds the slot.
|
|
653
|
+
(globalThis as Record<string, unknown>)[TELEGRAM_POLLER_LIVENESS_KEY] = () =>
|
|
654
|
+
Boolean(activePoller?.isRunning());
|
|
655
|
+
|
|
656
|
+
// Route a refused braid path through the TUI-safe log (never console.*),
|
|
657
|
+
// exactly like log() itself: silent until a session binds the notify sink.
|
|
658
|
+
setPathRefusalSink((message) => log("warn", message));
|
|
659
|
+
|
|
660
|
+
// if-has-token guard
|
|
661
|
+
const token = env(ENV_TOKEN);
|
|
662
|
+
if (!token) {
|
|
663
|
+
log(
|
|
664
|
+
"debug",
|
|
665
|
+
`${ENV_TOKEN} is not set — telegram tools NOT registered (if-has-token guard). Set ${ENV_TOKEN} to enable the Telegram channel.`,
|
|
666
|
+
);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
log("info", `Telegram channel enabled (${ENV_TOKEN} present)`);
|
|
670
|
+
|
|
671
|
+
const chatIdEnv = env(ENV_CHAT_ID);
|
|
672
|
+
const allowedUsers = parseAllowedUsers(env(ENV_ALLOWED_USERS));
|
|
673
|
+
const stateFile = env(ENV_STATE_FILE) ?? defaultStateFilePath();
|
|
674
|
+
// No absolute braid root and no explicit BRAID_TG_STATE_FILE: every inbound
|
|
675
|
+
// surface here needs offset persistence (a poller without it re-delivers,
|
|
676
|
+
// an inbox pull without it duplicates), so the channel refuses to register
|
|
677
|
+
// instead of running with state that cannot be written. Same shape as the
|
|
678
|
+
// token guard above; a pinned deployment is unaffected.
|
|
679
|
+
if (stateFile === null) {
|
|
680
|
+
warnUnderivableRoot("telegram channel refused");
|
|
681
|
+
log(
|
|
682
|
+
"error",
|
|
683
|
+
"telegram channel NOT registered: no braid state file can be derived " +
|
|
684
|
+
"(BRAID_HOME is unset and no absolute home resolves). Set BRAID_HOME " +
|
|
685
|
+
`(or ${ENV_STATE_FILE}) to an absolute path.`,
|
|
686
|
+
);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
// The delivery hold (see the delivery block below) is a file beside the offset
|
|
690
|
+
// state, so BRAID_TG_STATE_FILE governs both and a deployment or test that
|
|
691
|
+
// redirects the state directory redirects the hold with it — one knob, no
|
|
692
|
+
// second config key to document.
|
|
693
|
+
const inboundQueueFile = inboundQueueFileFor(stateFile);
|
|
694
|
+
const interval = Number(env(ENV_POLL_INTERVAL_MS)) || 1500;
|
|
695
|
+
// Coalescing window (see the collision-aware delivery block below): invalid
|
|
696
|
+
// or negative values fall back to the default; 0 is a legal "next tick".
|
|
697
|
+
const coalesceWindowMs = (() => {
|
|
698
|
+
const parsed = Number(env(ENV_COALESCE_WINDOW_MS));
|
|
699
|
+
return Number.isFinite(parsed) && parsed >= 0
|
|
700
|
+
? parsed
|
|
701
|
+
: DEFAULT_COALESCE_WINDOW_MS;
|
|
702
|
+
})();
|
|
703
|
+
const fetchImpl = deps?.fetch ?? (globalThis.fetch as TelegramFetch);
|
|
704
|
+
const client = createTelegramClient({ fetch: fetchImpl });
|
|
705
|
+
|
|
706
|
+
// Session role (registration-time gate): a worker session keeps
|
|
707
|
+
// telegram_send but never sees the poller-ownership surfaces and never
|
|
708
|
+
// starts the poller — the orchestrator seat owns the getUpdates singleton.
|
|
709
|
+
const workerRole = isWorkerRole();
|
|
710
|
+
|
|
711
|
+
// Inline-keyboard pause/resume control
|
|
712
|
+
// The pause flag is a signal for the CONSUMER (the orchestrator reads it via
|
|
713
|
+
// the exported getPauseState()); this handler only persists the flag and
|
|
714
|
+
// keeps the control keyboard in sync — it never blocks the poller or the
|
|
715
|
+
// core loop. Config via settings (braid.telegram.pauseStateFile), not env;
|
|
716
|
+
// the default file lives under the braid root (see lib/state-paths.ts) and
|
|
717
|
+
// reads fall back to the pre-move agent-dir copy.
|
|
718
|
+
const pauseStateFile = resolvePauseStateFile();
|
|
719
|
+
const handleCallbackQuery = async (update: TelegramUpdate): Promise<void> => {
|
|
720
|
+
const cq = update.callback_query;
|
|
721
|
+
if (!cq?.id) return;
|
|
722
|
+
// Decision taps first: a well-formed `dec:` payload is acknowledged and
|
|
723
|
+
// handed to the SAME inbound delivery path as a text message (coalescing,
|
|
724
|
+
// steer handoff; callbacks are not pause-gated, as today). Everything else —
|
|
725
|
+
// pause/resume/status and unknown data — keeps the control handler's path
|
|
726
|
+
// byte-for-byte.
|
|
727
|
+
const decision = await handleDecisionCallbackQuery(
|
|
728
|
+
{ client, token, log },
|
|
729
|
+
update,
|
|
730
|
+
);
|
|
731
|
+
if (decision.handled) {
|
|
732
|
+
// Awaited so the hold is persisted before the poller confirms the offset
|
|
733
|
+
// (same durability rule the text path's onOwnerMessage await carries).
|
|
734
|
+
if (decision.delivery) await deliverOwnerMessage(decision.delivery);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
await handlePauseCallbackQuery(
|
|
738
|
+
{ client, token, stateFile: pauseStateFile, log },
|
|
739
|
+
cq as unknown as PauseCallbackQuery,
|
|
740
|
+
);
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
// Publish the watchdog tg-signal sender so the core watchdog can push a
|
|
744
|
+
// 1-line remote canary ping on escalations (stall/quota; tunnel-down needs
|
|
745
|
+
// no ping — tg is down). Best-effort: returns false on any failure so the
|
|
746
|
+
// watchdog treats the optional signal as skipped, never fatal.
|
|
747
|
+
(globalThis as Record<string, unknown>)[WATCHDOG_TG_SIGNAL_KEY] = async (
|
|
748
|
+
text: string,
|
|
749
|
+
): Promise<boolean> => {
|
|
750
|
+
const chatId = resolveChatId();
|
|
751
|
+
if (!chatId) return false;
|
|
752
|
+
try {
|
|
753
|
+
await client.sendMessage(token, chatId, text);
|
|
754
|
+
return true;
|
|
755
|
+
} catch {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
// Publish the provider-outage notify sender (same route, same chat
|
|
761
|
+
// resolution). The watcher renders the text; this side only resolves the
|
|
762
|
+
// configured chat and sends. Delivery is read off the client result, so a
|
|
763
|
+
// refused send reports false and the watcher can release its dedupe claim
|
|
764
|
+
// for another process or a later episode.
|
|
765
|
+
(globalThis as Record<string, unknown>)[PROVIDER_OUTAGE_NOTIFY_KEY] = async (
|
|
766
|
+
text: string,
|
|
767
|
+
): Promise<boolean> => {
|
|
768
|
+
const chatId = resolveChatId();
|
|
769
|
+
if (!chatId) return false;
|
|
770
|
+
const result = await client.sendMessage(token, chatId, text);
|
|
771
|
+
return result.ok === true;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
// voice inbound STT config
|
|
775
|
+
// One config object threaded to both inbound consumers (poller + inbox);
|
|
776
|
+
// absent apiKey means voice notes are still received (delivered as the saved
|
|
777
|
+
// path) but not transcribed.
|
|
778
|
+
// Venice chat/input_audio is the default path on the existing
|
|
779
|
+
// AGENT_ZERO_API_KEY (no new env required); BRAID_TG_STT_MODE=transcriptions
|
|
780
|
+
// opts back to the OpenAI-compatible route. BRAID_TG_STT_API_KEY /
|
|
781
|
+
// BRAID_TG_STT_BASE_URL / BRAID_TG_STT_MODEL stay as explicit overrides for
|
|
782
|
+
// non-Venice providers; the Venice default path requires none of them.
|
|
783
|
+
// Resolve the STT mode first: the Venice AGENT_ZERO_API_KEY fallback below is
|
|
784
|
+
// gated on it. In mode=transcriptions (or any non-Venice baseUrl) with no
|
|
785
|
+
// explicit BRAID_TG_STT_API_KEY, the Venice key would be sent to the
|
|
786
|
+
// OpenAI/foreign endpoint → 401 + credential exposure.
|
|
787
|
+
// Chat-mode baseUrl default: the deployed key is gateway-scoped —
|
|
788
|
+
// Venice DIRECT rejects it (voice STT 401 on every note). Default to the
|
|
789
|
+
// userland venice catalog's gateway baseUrl (readCatalogBaseUrl, mirroring
|
|
790
|
+
// packages/providers/venice-catalog.ts) so the key pairs with the gateway
|
|
791
|
+
// the rest of the deployment already uses; absent catalog → legacy default.
|
|
792
|
+
const sttMode: "transcriptions" | "chat" =
|
|
793
|
+
env(ENV_STT_MODE) === "transcriptions" ? "transcriptions" : "chat";
|
|
794
|
+
// Key pool (failover): explicit key first, then BRAID_TG_STT_KEYS, the
|
|
795
|
+
// macOS keyring (absent item tolerated), then every AGENT_ZERO_API_KEY*
|
|
796
|
+
// env var in stable order. Resolved once at boot; the pool advances past
|
|
797
|
+
// auth/quota/rate failures at call time and the winning key is remembered
|
|
798
|
+
// for the process lifetime (stt-keys.ts). Transcription is bounded to two
|
|
799
|
+
// rounds; the second amplifies quiet audio via ffmpeg when
|
|
800
|
+
// BRAID_TG_STT_NORMALIZE is on (default).
|
|
801
|
+
const sttKeys = await resolveSttKeyCandidates({
|
|
802
|
+
explicit: env(ENV_STT_API_KEY),
|
|
803
|
+
env: process.env as Record<string, string | undefined>,
|
|
804
|
+
keyringService: env(ENV_STT_KEYRING_SERVICE),
|
|
805
|
+
// The AGENT_ZERO_API_KEY* family is Venice-specific: pool it for the chat
|
|
806
|
+
// endpoint only, so transcriptions mode never sends a Venice key to the
|
|
807
|
+
// OpenAI-compatible endpoint (cross-provider key leak).
|
|
808
|
+
includeAgentZeroEnv: sttMode === "chat",
|
|
809
|
+
});
|
|
810
|
+
const stt: SttConfig = {
|
|
811
|
+
baseUrl: resolveSttBaseUrl({
|
|
812
|
+
env: env(ENV_STT_BASE_URL),
|
|
813
|
+
catalogBaseUrl: readCatalogBaseUrl(),
|
|
814
|
+
mode: sttMode,
|
|
815
|
+
}),
|
|
816
|
+
apiKey: sttKeys[0]?.key,
|
|
817
|
+
keys: sttKeys,
|
|
818
|
+
normalize: {
|
|
819
|
+
enabled: env(ENV_STT_NORMALIZE) !== "0",
|
|
820
|
+
normalizeBytes: (bytes) => normalizeAudioBytes(bytes),
|
|
821
|
+
},
|
|
822
|
+
model: env(ENV_STT_MODEL) ?? DEFAULT_STT_CHAT_MODEL,
|
|
823
|
+
language: env(ENV_STT_LANGUAGE),
|
|
824
|
+
mode: sttMode,
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
// Outbound target: explicit BRAID_TG_CHAT_ID first; once the owner DMs the
|
|
828
|
+
// bot (first-message bootstrap), capture the real private chat.id so the
|
|
829
|
+
// tool can reply without BRAID_TG_CHAT_ID being set.
|
|
830
|
+
let ownerChatId: number | string | undefined = chatIdEnv;
|
|
831
|
+
const resolveChatId = (): number | string =>
|
|
832
|
+
ownerChatId ?? (allowedUsers[0] as number | string | undefined) ?? 0;
|
|
833
|
+
|
|
834
|
+
// Control-keyboard placement
|
|
835
|
+
// The owner must actually SEE the ⏸/▶️/📊 control — placement is wired at the
|
|
836
|
+
// POLLER-START seam: every time the background poller starts (env auto-start
|
|
837
|
+
// at boot, /telegram enable, telegram_poller enable) the persisted pause flag
|
|
838
|
+
// is read and placeControlKeyboard places the control in the owner chat.
|
|
839
|
+
// Delivery-neutral: nothing self-sends on import — THIS factory (the entity
|
|
840
|
+
// that owns the inbound poller) places. When the owner chat is not yet known
|
|
841
|
+
// at start (no BRAID_TG_CHAT_ID and no allow-list — pure first-message
|
|
842
|
+
// bootstrap) placement is DEFERRED and fires once the first owner message
|
|
843
|
+
// captures ownerChatId (deliverOwnerMessage → flushDeferredControlPlacement).
|
|
844
|
+
// Post-resume correctness is already cheap: the callback handler edits the
|
|
845
|
+
// SAME keyboard in place (editMessageText) on every tap, so pause/resume
|
|
846
|
+
// refresh in place — no per-transition re-send/spam.
|
|
847
|
+
let controlPlacement: "idle" | "placed" | "deferred" = "idle";
|
|
848
|
+
// Arrow const (not a hoisted declaration) so `token` stays narrow-typed to
|
|
849
|
+
// `string` inside this closure — the same pattern live-update/turn-notify use.
|
|
850
|
+
const placeControlKeyboardOnce = async (): Promise<void> => {
|
|
851
|
+
if (controlPlacement === "placed") return; // already in the owner chat this boot
|
|
852
|
+
const outcome = await placeControlKeyboard(resolveChatId(), {
|
|
853
|
+
client,
|
|
854
|
+
token,
|
|
855
|
+
// Same reader the poller pause gate uses — placement and gate always agree.
|
|
856
|
+
getPauseState: readPauseState,
|
|
857
|
+
log,
|
|
858
|
+
});
|
|
859
|
+
controlPlacement = outcome.deferred ? "deferred" : "placed";
|
|
860
|
+
};
|
|
861
|
+
function flushDeferredControlPlacement(): void {
|
|
862
|
+
if (controlPlacement !== "deferred") return;
|
|
863
|
+
controlPlacement = "idle"; // one-shot retry now that the owner chat is known
|
|
864
|
+
void placeControlKeyboardOnce();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Voice TTS source: resolved once at factory start from `braid.telegram.tts`
|
|
868
|
+
// in ~/.pi/agent/settings.json (config shape and defaults in lib/tts.ts).
|
|
869
|
+
// populateVoiceTts() returns undefined when synthesis is disabled
|
|
870
|
+
// (`source: "none"`), which keeps the voice-refusal path in tool-logic.ts
|
|
871
|
+
// intact — voice:true errors, it never silently sends text.
|
|
872
|
+
const voiceTts = populateVoiceTts();
|
|
873
|
+
|
|
874
|
+
// Outbound tool (telegram_send)
|
|
875
|
+
pi.registerTool({
|
|
876
|
+
name: "telegram_send",
|
|
877
|
+
label: "Telegram Send",
|
|
878
|
+
promptSnippet:
|
|
879
|
+
"Send a message (text or voice) to the human owner on Telegram",
|
|
880
|
+
promptGuidelines: [
|
|
881
|
+
"Use telegram_send to notify or ask the human owner. Messages are LONG by default: plain text sends verbatim up to the cap (braid.telegram.sendMaxChars, default 4096 — Telegram's per-message limit); there is no tweet-length rule. Reply with telegram_send when the owner's message arrived inside a [braid-whisper - telegram ...] envelope; human input with no envelope is TUI input — answer that in-session.",
|
|
882
|
+
"`text` is required and must be non-empty; over-cap text without an explicit choice returns an ERROR (nothing sent) — pick rich:true, a shorter text, or an explicit strategy (never a silent truncate).",
|
|
883
|
+
"Set `rich: true` past the cap (or for formatted/split delivery): it bypasses the cap and splits the text to fit Telegram's 4096-char messages, honoring `parse_mode` (HTML or Markdown).",
|
|
884
|
+
'Author the body in the `parse_mode` you pick. `parse_mode:"Markdown"` is Telegram LEGACY Markdown (`*bold*`, `_italic_`, `` `code` ``, `[link](url)`); clean single-line MarkdownV2 pairs (`**bold**`, `__underline__`, `~strike~`, `||spoiler||`) are converted to HTML (`<b>`, `<u>`, `<s>`, `<tg-spoiler>`) and reported as `converted` — runs (`~~x~~`), spaced operators (`x ~ y ~ z`) and pairs that would cross author tags stay literal. HTML (tags: `<b>`, `<i>`, `<u>`, `<s>`, `<code>`, `<tg-spoiler>`, `<a href>`) is the predictable choice for anything nontrivial. Cross-mode markup is sanitized automatically: raw HTML tags never leak into a Markdown message and Markdown markers never show literally in an HTML message.',
|
|
885
|
+
"Pass `options` (1-2 rows, up to 4 buttons per row: {label, value}) to let the owner answer with a tap: the choice arrives as an inbound `[decision] <token> → <label> (<value>)` message. `decisionToken` is the correlation id (generated when omitted, echoed in the result). The owner can always reply in words instead.",
|
|
886
|
+
],
|
|
887
|
+
description:
|
|
888
|
+
"Send a message to the owner on Telegram (bidirectional channel). " +
|
|
889
|
+
"LENGTH FIRST, before writing the body: messages are LONG by default — plain text sends verbatim up to 4096 chars (braid.telegram.sendMaxChars); there is no tweet-length rule. " +
|
|
890
|
+
"A body over the cap needs rich:true (split-to-fit at 4096/message); `limit` raises/lowers the cap. " +
|
|
891
|
+
'Example (correct): text "Deploy finished, 2 tests failed — rerunning suite B" sends as-is. ' +
|
|
892
|
+
'Example (over cap, no rich/limit, no distillation): a 5000-char plain body errors with "NOTHING was sent". ' +
|
|
893
|
+
'Over the cap with no explicit choice: the crafting model distills to ≤ the cap; unavailable distillation = refused — never a silent truncate. Explicit choice wins: strategy:"truncate" hard-cuts; strategy:"error" refuses. ' +
|
|
894
|
+
'rich:true + parse_mode (HTML | Markdown): `"Markdown"` is Telegram LEGACY Markdown (`*bold*`, `_italic_`, `` `code` ``, `[t](url)`); clean single-line MarkdownV2 pairs (`**bold**`, `__underline__`, `~strike~`, `||spoiler||`) convert to HTML (`<b>`, `<u>`, `<s>`, `<tg-spoiler>`) and are reported as `converted` (runs, spaced operators and tag-crossing pairs stay literal). Cross-mode markup is neutralized; a body with HTML tags auto-selects HTML. ' +
|
|
895
|
+
"voice:true sends a voice note (needs a TTS source); voiceText defaults to text. " +
|
|
896
|
+
"options: 1-2 rows of up to 4 {label,value} buttons attach an inline keyboard; a tap returns to the agent as an inbound `[decision] <token> → <label> (<value>)` message (the owner can always answer in words too); decisionToken is the correlation id, generated when omitted and echoed in the result. " +
|
|
897
|
+
"Returns the delivery result (message_id or error). " +
|
|
898
|
+
(workerRole
|
|
899
|
+
? "A successful send while the inbound poller is OFF carries a warn field — the orchestrator seat owns the inbound poller; messages are delivered without inbound (owner replies reach the seat session)."
|
|
900
|
+
: "A successful send while the inbound poller is OFF carries a warn field — the owner's replies won't reach the agent until telegram_poller enable."),
|
|
901
|
+
parameters: Type.Object({
|
|
902
|
+
text: Type.String({
|
|
903
|
+
minLength: 1,
|
|
904
|
+
description:
|
|
905
|
+
"Message text to send. Sends verbatim up to the cap (default 4096 = braid.telegram.sendMaxChars); over-cap text without an explicit choice returns an error (nothing sent) — never a silent truncate. Must be non-empty.",
|
|
906
|
+
}),
|
|
907
|
+
limit: Type.Optional(
|
|
908
|
+
Type.Number({
|
|
909
|
+
minimum: 1,
|
|
910
|
+
description:
|
|
911
|
+
"Explicit char cap for the plain path (default: braid.telegram.sendMaxChars, 4096). Text ≤ limit sends verbatim; over it, `strategy` applies (default: error, nothing sent). Explicit choice wins.",
|
|
912
|
+
}),
|
|
913
|
+
),
|
|
914
|
+
strategy: Type.Optional(
|
|
915
|
+
Type.Union([Type.Literal("truncate"), Type.Literal("error")], {
|
|
916
|
+
description:
|
|
917
|
+
'Explicit over-limit handling: "truncate" hard-truncates to the limit with an ellipsis (skips crafting); "error" refuses with an error stating the limit. Default (omitted): craft-then-error — never a silent truncate.',
|
|
918
|
+
}),
|
|
919
|
+
),
|
|
920
|
+
voice: Type.Optional(
|
|
921
|
+
Type.Boolean({
|
|
922
|
+
description:
|
|
923
|
+
"Send as a Telegram voice note (needs a configured TTS source). Default false (text).",
|
|
924
|
+
}),
|
|
925
|
+
),
|
|
926
|
+
voiceText: Type.Optional(
|
|
927
|
+
Type.String({
|
|
928
|
+
description:
|
|
929
|
+
"Text to read aloud in the voice note; defaults to `text`.",
|
|
930
|
+
}),
|
|
931
|
+
),
|
|
932
|
+
rich: Type.Optional(
|
|
933
|
+
Type.Boolean({
|
|
934
|
+
description:
|
|
935
|
+
"Send a long formatted report: bypasses the default cap and splits the text to fit Telegram's 4096-char messages. Default false.",
|
|
936
|
+
}),
|
|
937
|
+
),
|
|
938
|
+
parse_mode: Type.Optional(
|
|
939
|
+
Type.Union([Type.Literal("HTML"), Type.Literal("Markdown")], {
|
|
940
|
+
description:
|
|
941
|
+
'Format for the delivered text. `"Markdown"` is Telegram LEGACY Markdown (`*bold*`, `_italic_`, `` `code` ``, `[t](url)`); clean single-line MarkdownV2 pairs (`**bold**`, `__underline__`, `~strike~`, `||spoiler||`) are converted to HTML (`<b>`, `<u>`, `<s>`, `<tg-spoiler>`) instead and reported as `converted` — runs (`~~x~~`), spaced operators and pairs that would cross author tags stay literal. HTML is the predictable choice for anything nontrivial. Cross-mode markup is sanitized automatically: nothing from the other format leaks raw. Optional; plain text by default, except a body containing HTML tags auto-selects HTML.',
|
|
942
|
+
}),
|
|
943
|
+
),
|
|
944
|
+
options: Type.Optional(
|
|
945
|
+
Type.Array(
|
|
946
|
+
Type.Array(
|
|
947
|
+
Type.Object({
|
|
948
|
+
label: Type.String({
|
|
949
|
+
minLength: 1,
|
|
950
|
+
description: "Button text shown to the owner.",
|
|
951
|
+
}),
|
|
952
|
+
value: Type.String({
|
|
953
|
+
minLength: 1,
|
|
954
|
+
description:
|
|
955
|
+
"Machine-readable choice returned to the agent when the button is tapped.",
|
|
956
|
+
}),
|
|
957
|
+
}),
|
|
958
|
+
{ minItems: 1, maxItems: 4 },
|
|
959
|
+
),
|
|
960
|
+
{
|
|
961
|
+
minItems: 1,
|
|
962
|
+
maxItems: 2,
|
|
963
|
+
description:
|
|
964
|
+
"Decision buttons: 1-2 rows of up to 4 {label, value} buttons, attached to the message as an inline keyboard. A tap returns to the agent as an inbound `[decision] <token> → <label> (<value>)` message. Malformed rows refuse the send (nothing delivered).",
|
|
965
|
+
},
|
|
966
|
+
),
|
|
967
|
+
),
|
|
968
|
+
decisionToken: Type.Optional(
|
|
969
|
+
Type.String({
|
|
970
|
+
description:
|
|
971
|
+
"Short correlation id (1-16 chars of A-Za-z0-9_-) for the decision buttons; echoed back on every tap and in the result. Generated when omitted. Only meaningful together with `options`.",
|
|
972
|
+
}),
|
|
973
|
+
),
|
|
974
|
+
}),
|
|
975
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
976
|
+
const outcome = await runTelegramSend(params as never, {
|
|
977
|
+
client,
|
|
978
|
+
token,
|
|
979
|
+
chatId: resolveChatId(),
|
|
980
|
+
tts: voiceTts,
|
|
981
|
+
// LLM-crafted content distills before any hard cap; the model call
|
|
982
|
+
// is key-ring-backed via ctx.modelRegistry — never the bot token.
|
|
983
|
+
craft: ctx
|
|
984
|
+
? createTextCraftHook(
|
|
985
|
+
ctx.modelRegistry,
|
|
986
|
+
ctx.model,
|
|
987
|
+
env(ENV_NOTIFY_MODEL),
|
|
988
|
+
)
|
|
989
|
+
: undefined,
|
|
990
|
+
// Poller-liveness guard: a successful
|
|
991
|
+
// send while inbound is dead warns on the outcome so the agent knows
|
|
992
|
+
// replies won't be consumed. BRAID_TG_POLL_DISABLED deployments are
|
|
993
|
+
// deliberately outbound-only — no warning.
|
|
994
|
+
pollerRunning: env(ENV_POLL_DISABLED)
|
|
995
|
+
? () => true
|
|
996
|
+
: () => Boolean(activePoller?.isRunning()),
|
|
997
|
+
workerRole,
|
|
998
|
+
});
|
|
999
|
+
return {
|
|
1000
|
+
content: [
|
|
1001
|
+
{ type: "text" as const, text: JSON.stringify(outcome, null, 2) },
|
|
1002
|
+
],
|
|
1003
|
+
details: outcome,
|
|
1004
|
+
};
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
// turn-complete auto-notify
|
|
1009
|
+
// When a turn ends with a completed plain-text answer (no trailing tool
|
|
1010
|
+
// call), a distilling model call crafts an owner update up to
|
|
1011
|
+
// braid.telegram.notifyMaxChars (default 1200) and sends it through the SAME
|
|
1012
|
+
// outbound seam as telegram_send. Opt-in via BRAID_TG_NOTIFY_ENABLED=1
|
|
1013
|
+
// (default OFF — no unsolicited messages). The model call is key-ring-backed
|
|
1014
|
+
// via ctx.modelRegistry (never the bot token); the cap is only a
|
|
1015
|
+
// guard-fallback. Rate-limited (default 4/min).
|
|
1016
|
+
const notifyEnabled = Boolean(env(ENV_NOTIFY_ENABLED));
|
|
1017
|
+
const notifyMaxPerMin =
|
|
1018
|
+
Number(env(ENV_NOTIFY_MAX_PER_MIN)) || DEFAULT_NOTIFY_MAX_PER_MIN;
|
|
1019
|
+
const notifyLog = (m: string, t?: "info" | "warning" | "error") =>
|
|
1020
|
+
log(t === "warning" ? "warn" : t === "error" ? "error" : "info", m);
|
|
1021
|
+
if (notifyEnabled) {
|
|
1022
|
+
const notifyHandler = createTurnNotifyHandler({
|
|
1023
|
+
callModel: async (prompts, modelCtx: NotifyModelCtx, opts) => {
|
|
1024
|
+
const model = resolveNotifyModel(
|
|
1025
|
+
modelCtx.registry,
|
|
1026
|
+
modelCtx.sessionModel,
|
|
1027
|
+
env(ENV_NOTIFY_MODEL),
|
|
1028
|
+
);
|
|
1029
|
+
if (!model) return { ok: false, error: "no notify model resolvable" };
|
|
1030
|
+
return callCraftModel(modelCtx.registry, model, prompts, {
|
|
1031
|
+
...(opts?.maxTokens != null ? { maxTokens: opts.maxTokens } : {}),
|
|
1032
|
+
});
|
|
1033
|
+
},
|
|
1034
|
+
deliver: async (text) => {
|
|
1035
|
+
const chatId = resolveChatId();
|
|
1036
|
+
if (!chatId) return;
|
|
1037
|
+
await client.sendMessage(token, chatId, text);
|
|
1038
|
+
},
|
|
1039
|
+
maxPerWindow: notifyMaxPerMin,
|
|
1040
|
+
});
|
|
1041
|
+
pi.on("turn_end", async (event: unknown, ctx: any) => {
|
|
1042
|
+
const modelCtx: NotifyModelCtx = {
|
|
1043
|
+
registry: ctx?.modelRegistry ?? {},
|
|
1044
|
+
sessionModel: ctx?.model,
|
|
1045
|
+
};
|
|
1046
|
+
await notifyHandler(event, modelCtx, notifyLog);
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// live-update tracked message
|
|
1051
|
+
// When enabled, every inbound owner message IMMEDIATELY pins a "Working…"
|
|
1052
|
+
// tracked message (edited in place — idempotent, no new-message spam) and,
|
|
1053
|
+
// at turn_end with a completed answer, that message is edited to "✅ Done"
|
|
1054
|
+
// plus a SEPARATE rich markdown summary is sent (crafted key-ring-backed via
|
|
1055
|
+
// ctx.modelRegistry, never the bot token). Default ON — an explicit
|
|
1056
|
+
// BRAID_TG_LIVE_UPDATE_ENABLED=0 disables it (no live-update behavior at
|
|
1057
|
+
// all). Edit rate-limited (default 4/min). Independent of the turn-complete
|
|
1058
|
+
// auto-notify feature above — that stays entirely intact.
|
|
1059
|
+
const liveUpdateEnabled = env(ENV_LIVE_UPDATE_ENABLED) !== "0";
|
|
1060
|
+
const liveUpdateMaxPerMin =
|
|
1061
|
+
Number(env(ENV_LIVE_UPDATE_MAX_PER_MIN)) || DEFAULT_LIVE_UPDATE_MAX_PER_MIN;
|
|
1062
|
+
const liveUpdate: LiveUpdateTracker | undefined = (() => {
|
|
1063
|
+
if (!liveUpdateEnabled) return undefined;
|
|
1064
|
+
const liveStateFile =
|
|
1065
|
+
env(ENV_LIVE_UPDATE_STATE_FILE) ?? defaultLiveUpdateStateFile();
|
|
1066
|
+
if (liveStateFile === null) {
|
|
1067
|
+
warnUnderivableRoot("live-update tracker refused");
|
|
1068
|
+
log(
|
|
1069
|
+
"warn",
|
|
1070
|
+
"telegram live-update disabled: the state file cannot be derived " +
|
|
1071
|
+
"(BRAID_HOME is unset and no absolute home resolves)",
|
|
1072
|
+
);
|
|
1073
|
+
return undefined;
|
|
1074
|
+
}
|
|
1075
|
+
const tracker = createLiveUpdateTracker({
|
|
1076
|
+
client,
|
|
1077
|
+
token,
|
|
1078
|
+
// Resolve the owner chat at call time (captured from the first
|
|
1079
|
+
// inbound owner message; BRAID_TG_CHAT_ID env is only the fallback).
|
|
1080
|
+
chatId: resolveChatId,
|
|
1081
|
+
stateFile: liveStateFile,
|
|
1082
|
+
maxPerWindow: liveUpdateMaxPerMin,
|
|
1083
|
+
log,
|
|
1084
|
+
});
|
|
1085
|
+
log(
|
|
1086
|
+
"info",
|
|
1087
|
+
`telegram live-update enabled (default-on; ${ENV_LIVE_UPDATE_ENABLED}=0 to disable, max ${liveUpdateMaxPerMin}/min edits, state: ${liveStateFile})`,
|
|
1088
|
+
);
|
|
1089
|
+
// turn_end lifecycle keyed on the ACTIVE-WORK REFCOUNT: each turn is
|
|
1090
|
+
// one unit of work behind the "Working…".
|
|
1091
|
+
// - terminal non-completed turn (abort/error, or no assistant report):
|
|
1092
|
+
// end("no-report") — on 1→0 the indicator is edited to the honest
|
|
1093
|
+
// terminal state ("⏹️ Turn ended without a report") + state cleared;
|
|
1094
|
+
// - completed plain-text answer: end("done") — on 1→0 it is edited to
|
|
1095
|
+
// "✅ Done" and the final rich summary is delivered;
|
|
1096
|
+
// - trailing-tool-call (toolUse) turns: no end() — work continues next
|
|
1097
|
+
// turn, the indicator stays active (never cleared mid-tool).
|
|
1098
|
+
// The refcount means a queue of inbound messages counts as N units and
|
|
1099
|
+
// the indicator only resolves when the LAST one completes. If this
|
|
1100
|
+
// mapping misses (the agent settles without a clean completion — the
|
|
1101
|
+
// Freeze), the agent_settled safety-net below forces the
|
|
1102
|
+
// honest completion — the indicator NEVER stays at "Working…" forever.
|
|
1103
|
+
pi.on("turn_end", async (event: unknown, ctx: any) => {
|
|
1104
|
+
if (isTerminalNoReportTurn(event)) {
|
|
1105
|
+
await tracker.end("no-report");
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (!shouldNotifyTurnEnd(event)) return; // toolUse etc → stays active
|
|
1109
|
+
const finalText = extractFinalText(event);
|
|
1110
|
+
if (!finalText) return;
|
|
1111
|
+
const craftRich = ctx
|
|
1112
|
+
? createRichCraftHook(
|
|
1113
|
+
ctx?.modelRegistry ?? {},
|
|
1114
|
+
ctx?.model,
|
|
1115
|
+
env(ENV_NOTIFY_MODEL),
|
|
1116
|
+
)
|
|
1117
|
+
: undefined;
|
|
1118
|
+
await tracker.end("done", { resultText: finalText, craftRich });
|
|
1119
|
+
});
|
|
1120
|
+
return tracker;
|
|
1121
|
+
})();
|
|
1122
|
+
|
|
1123
|
+
// Inbound delivery: owner messages steer, coalesced
|
|
1124
|
+
// pi.sendUserMessage always forwards its deliverAs as prompt()'s
|
|
1125
|
+
// streamingBehavior (agent-session.js sendUserMessage). With
|
|
1126
|
+
// deliverAs "steer" ONE call shape is correct in both session states: while
|
|
1127
|
+
// a run is active pi-core queues the message into the CURRENT run at its
|
|
1128
|
+
// next step boundary, and while the session is idle the same call starts a
|
|
1129
|
+
// normal turn. This path lets the owner
|
|
1130
|
+
// message reach the running turn instead of waiting for the next idle
|
|
1131
|
+
// boundary (a busy session would otherwise hold delivery until idle).
|
|
1132
|
+
//
|
|
1133
|
+
// Batching: a burst is handed over as ONE user message, never one per
|
|
1134
|
+
// arrival. Every arrival joins a coalescing window (ENV_COALESCE_WINDOW_MS,
|
|
1135
|
+
// default 2000) anchored at the FIRST arrival: a lone message is delayed by
|
|
1136
|
+
// at most one window, and because the default window is longer than the
|
|
1137
|
+
// default poll interval, a burst split across two poll cycles still lands
|
|
1138
|
+
// as one delivery. Later arrivals inside the window join the open batch;
|
|
1139
|
+
// they never reset it (a debounce would let a long burst postpone delivery
|
|
1140
|
+
// unboundedly). When the window closes, EVERY current-session entry goes
|
|
1141
|
+
// out as one sendUserMessage carrying the parts in arrival order. One
|
|
1142
|
+
// sendUserMessage per flush starts at most one run, so no activeRun race
|
|
1143
|
+
// can form.
|
|
1144
|
+
//
|
|
1145
|
+
// The array/file below holds arrivals until a flush hands them over and is
|
|
1146
|
+
// therefore the fallback when a handoff fails: a steer can still throw (pi
|
|
1147
|
+
// refuses prompts while compaction is in progress), and a factory re-run
|
|
1148
|
+
// (reload, session replacement) mid-window would otherwise lose an entry
|
|
1149
|
+
// the poller's offset has already passed. A thrown handoff re-queues the
|
|
1150
|
+
// batch and re-arms the window; the agent_settled flush settles leftovers.
|
|
1151
|
+
// The fallback is a FILE, not only this array, because both lifecycle paths
|
|
1152
|
+
// take the array away — each re-runs the extension factory, so a fresh
|
|
1153
|
+
// closure starts with an empty array while the poller has already persisted
|
|
1154
|
+
// its offset past the update:
|
|
1155
|
+
// - a reload re-runs the factory (resource-loader reload() clears the
|
|
1156
|
+
// module cache);
|
|
1157
|
+
// - a session replacement (new/resume/fork) builds a new resource loader
|
|
1158
|
+
// whose reload() re-runs the factory too, so none of this closure
|
|
1159
|
+
// survives into the replacement session.
|
|
1160
|
+
// Write-through on every mutation plus a rehydrate at session_start covers
|
|
1161
|
+
// both: the entry outlives the factory run, and the session key on the entry
|
|
1162
|
+
// is what keeps a rehydrated hold from being delivered into a session it was
|
|
1163
|
+
// not accepted for — an entry whose key is still unknown is adopted by the
|
|
1164
|
+
// first session that names itself. session_shutdown needs no queue action of
|
|
1165
|
+
// its own — the file already holds the entry.
|
|
1166
|
+
const pendingInbound: QueuedInboundMessage[] = [];
|
|
1167
|
+
// Coalescing window timer: armed when an arrival joins the batch (busy or
|
|
1168
|
+
// idle), and re-armed to retry a handoff pi refused. It is
|
|
1169
|
+
// unref()ed (a pending window must never keep the process alive) and
|
|
1170
|
+
// cleared at session_shutdown; the hold file is the durable half across
|
|
1171
|
+
// that boundary, and the next session_start rehydrates into a fresh window.
|
|
1172
|
+
let coalesceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1173
|
+
|
|
1174
|
+
/** Arm the window unless one is already pending (later arrivals never reset it). */
|
|
1175
|
+
function armCoalesceTimer(): void {
|
|
1176
|
+
if (coalesceTimer) return;
|
|
1177
|
+
coalesceTimer = setTimeout(() => {
|
|
1178
|
+
coalesceTimer = undefined;
|
|
1179
|
+
flushPendingInbound();
|
|
1180
|
+
}, coalesceWindowMs);
|
|
1181
|
+
coalesceTimer.unref?.();
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function clearCoalesceTimer(): void {
|
|
1185
|
+
if (!coalesceTimer) return;
|
|
1186
|
+
clearTimeout(coalesceTimer);
|
|
1187
|
+
coalesceTimer = undefined;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// `isIdle` is a member of the ExtensionContext (runner.createContext), not
|
|
1191
|
+
// of the ExtensionAPI, and a poller callback has no context of its own — so
|
|
1192
|
+
// the gate reads the reference cached by the lifecycle handlers. ctx.isIdle()
|
|
1193
|
+
// reads the live session state (no active run, no compaction), so a
|
|
1194
|
+
// boundary-old context still answers correctly for a later arrival.
|
|
1195
|
+
const isPiIdle = (): boolean => {
|
|
1196
|
+
try {
|
|
1197
|
+
return liveCtx?.isIdle() ?? true;
|
|
1198
|
+
} catch {
|
|
1199
|
+
// Context from a replaced session: deliver immediately (the pre-fix
|
|
1200
|
+
// behavior, and always legal — see the delivery note above).
|
|
1201
|
+
return true;
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Session the live context belongs to, or undefined when it cannot name one.
|
|
1207
|
+
* `sessionManager` is a member of the ExtensionContext (read-only view), so
|
|
1208
|
+
* this is the same capture pattern as isIdle above; a context from a replaced
|
|
1209
|
+
* session throws on access and answers "unknown".
|
|
1210
|
+
*/
|
|
1211
|
+
function currentSessionId(): string | undefined {
|
|
1212
|
+
try {
|
|
1213
|
+
return liveCtx?.sessionManager?.getSessionId();
|
|
1214
|
+
} catch {
|
|
1215
|
+
return undefined;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Write the hold through to disk (best effort). A failed write is reported,
|
|
1221
|
+
* not swallowed: the in-memory entry still delivers at the next flush, but
|
|
1222
|
+
* it would not survive a reload.
|
|
1223
|
+
*/
|
|
1224
|
+
function persistPendingInbound(): Promise<void> {
|
|
1225
|
+
return saveInboundQueue(inboundQueueFile, pendingInbound).catch(
|
|
1226
|
+
(error: unknown) => {
|
|
1227
|
+
log(
|
|
1228
|
+
"warn",
|
|
1229
|
+
`telegram inbound: could not persist the inbound hold: ${String(error)}`,
|
|
1230
|
+
);
|
|
1231
|
+
},
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* The never-silent half of the contract: an entry that cannot be handed to
|
|
1237
|
+
* the session it was accepted for is NAMED — count, update ids and a text
|
|
1238
|
+
* preview go to ctx.ui.notify through the log sink, and a durable
|
|
1239
|
+
* `telegram_inbound_dropped` session entry keeps the record after the toast
|
|
1240
|
+
* is gone.
|
|
1241
|
+
*/
|
|
1242
|
+
function reportDroppedInbound(
|
|
1243
|
+
entries: QueuedInboundMessage[],
|
|
1244
|
+
currentId: string | undefined,
|
|
1245
|
+
): void {
|
|
1246
|
+
if (entries.length === 0) return;
|
|
1247
|
+
const acceptedFor = entries[0]?.sessionId;
|
|
1248
|
+
const updateIds = entries.map((entry) => entry.message.updateId);
|
|
1249
|
+
const preview = entries
|
|
1250
|
+
.map((entry) =>
|
|
1251
|
+
entry.message.text.trim().replace(/\s+/g, " ").slice(0, 120),
|
|
1252
|
+
)
|
|
1253
|
+
.filter(Boolean)
|
|
1254
|
+
.join(" | ");
|
|
1255
|
+
log(
|
|
1256
|
+
"warn",
|
|
1257
|
+
`telegram inbound: dropped ${entries.length} queued owner message(s) accepted for session ${acceptedFor ?? "(unknown)"} — the current session is ${currentId ?? "(unknown)"} (update id(s) ${updateIds.join(", ")})${preview ? `: ${preview}` : ""}`,
|
|
1258
|
+
);
|
|
1259
|
+
try {
|
|
1260
|
+
pi.appendEntry("telegram_inbound_dropped", {
|
|
1261
|
+
count: entries.length,
|
|
1262
|
+
acceptedFor: acceptedFor ?? null,
|
|
1263
|
+
currentSessionId: currentId ?? null,
|
|
1264
|
+
updateIds,
|
|
1265
|
+
previews: entries.map((entry) => entry.message.text.slice(0, 400)),
|
|
1266
|
+
});
|
|
1267
|
+
} catch {
|
|
1268
|
+
// The durable half of the report is best effort; the notify above is the
|
|
1269
|
+
// visible one. Never let a transcript write break the delivery path.
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* The session-boundary rule, in one place: an entry accepted for a named
|
|
1275
|
+
* session is delivered only to THAT session; an entry accepted while no
|
|
1276
|
+
* session could name itself (sessionId undefined) is adopted by the first
|
|
1277
|
+
* session that names itself — the current one — and stays for delivery.
|
|
1278
|
+
* Adoption stamps the current id onto the entry; returns true when the
|
|
1279
|
+
* entry may stay.
|
|
1280
|
+
*/
|
|
1281
|
+
function sessionAdmitsEntry(
|
|
1282
|
+
entry: QueuedInboundMessage,
|
|
1283
|
+
sessionId: string | undefined,
|
|
1284
|
+
): boolean {
|
|
1285
|
+
if (entry.sessionId === undefined) {
|
|
1286
|
+
if (sessionId !== undefined) entry.sessionId = sessionId;
|
|
1287
|
+
return true;
|
|
1288
|
+
}
|
|
1289
|
+
return entry.sessionId === sessionId;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Drop (and report) every held entry accepted for a different named
|
|
1294
|
+
* session; adopt any entry whose acceptance session is still unknown.
|
|
1295
|
+
* Runs at the delivery point, so the session-scope invariant is enforced
|
|
1296
|
+
* where the message is handed over, not only where the queue is
|
|
1297
|
+
* rehydrated.
|
|
1298
|
+
*/
|
|
1299
|
+
function dropForeignInbound(): void {
|
|
1300
|
+
const sessionId = currentSessionId();
|
|
1301
|
+
const foreign: QueuedInboundMessage[] = [];
|
|
1302
|
+
for (let i = pendingInbound.length - 1; i >= 0; i--) {
|
|
1303
|
+
if (!sessionAdmitsEntry(pendingInbound[i], sessionId)) {
|
|
1304
|
+
foreign.unshift(...pendingInbound.splice(i, 1));
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (foreign.length === 0) return;
|
|
1308
|
+
reportDroppedInbound(foreign, sessionId);
|
|
1309
|
+
void persistPendingInbound();
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Hand the whole held batch to the session as ONE user message (every
|
|
1314
|
+
* current-session entry, in arrival order). One call shape serves both
|
|
1315
|
+
* states: deliverAs "steer" reaches the running turn while busy and starts
|
|
1316
|
+
* a turn while idle, so there is no busy gate here. Runs when the
|
|
1317
|
+
* coalescing window closes, at the agent_settled boundary (retrying a
|
|
1318
|
+
* handoff pi refused), and after a rehydrating session_start found held
|
|
1319
|
+
* entries.
|
|
1320
|
+
*/
|
|
1321
|
+
function flushPendingInbound(): void {
|
|
1322
|
+
dropForeignInbound(); // hand over only to the accepting session
|
|
1323
|
+
if (pendingInbound.length === 0) {
|
|
1324
|
+
clearCoalesceTimer();
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
const batch = pendingInbound.splice(0, pendingInbound.length);
|
|
1328
|
+
try {
|
|
1329
|
+
pi.sendUserMessage(formatInboundBatch(batch.map((e) => e.message)), {
|
|
1330
|
+
deliverAs: "steer",
|
|
1331
|
+
});
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
// The handoff threw before pi took the message: put the whole batch
|
|
1334
|
+
// back, in order, rather than lose it, and say so. The window re-arms
|
|
1335
|
+
// for a bounded retry; the next settle boundary retries too.
|
|
1336
|
+
pendingInbound.unshift(...batch);
|
|
1337
|
+
log(
|
|
1338
|
+
"warn",
|
|
1339
|
+
`telegram inbound: handoff of ${batch.length} message(s) (update id(s) ${batch.map((e) => e.message.updateId).join(", ")}) failed: ${String(error)}; batch kept queued`,
|
|
1340
|
+
);
|
|
1341
|
+
armCoalesceTimer();
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
clearCoalesceTimer();
|
|
1345
|
+
// One begin per DELIVERED batch: a batch is one sendUserMessage and one
|
|
1346
|
+
// run, so the live-update active-work refcount tracks runs, not arrivals.
|
|
1347
|
+
if (liveUpdate) {
|
|
1348
|
+
void liveUpdate.begin().then((outcome) => {
|
|
1349
|
+
if (!outcome.started && outcome.reason === "send-failed") {
|
|
1350
|
+
log(
|
|
1351
|
+
"warn",
|
|
1352
|
+
`live-update: failed to start tracked message: ${outcome.error ?? "unknown"}`,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
void persistPendingInbound();
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Rebuild the hold at every session start: the FILE is the durable half and
|
|
1362
|
+
* the in-memory array the live half, unioned by update id. Any factory
|
|
1363
|
+
* re-run — a reload or a session replacement alike — starts with an empty
|
|
1364
|
+
* array, so the file is the only carrier of the hold across it; the array
|
|
1365
|
+
* only contributes an entry whose persist failed in the current closure.
|
|
1366
|
+
* Entries accepted for a different named session are reported and dropped
|
|
1367
|
+
* here; an entry accepted before any session could name itself is adopted by
|
|
1368
|
+
* the named session that starts now. One entry is delivered when the session
|
|
1369
|
+
* is already idle — a reload lands idle, so without that flush a rehydrated
|
|
1370
|
+
* message would wait for a settle that nothing will produce.
|
|
1371
|
+
*/
|
|
1372
|
+
async function rehydratePendingInbound(): Promise<void> {
|
|
1373
|
+
const stored = await loadInboundQueue(inboundQueueFile, (message) =>
|
|
1374
|
+
log("warn", `telegram inbound: ${message}`),
|
|
1375
|
+
);
|
|
1376
|
+
if (stored.length === 0 && pendingInbound.length === 0) return;
|
|
1377
|
+
const byUpdateId = new Map<number, QueuedInboundMessage>();
|
|
1378
|
+
for (const entry of stored) byUpdateId.set(entry.message.updateId, entry);
|
|
1379
|
+
for (const entry of pendingInbound) {
|
|
1380
|
+
byUpdateId.set(entry.message.updateId, entry);
|
|
1381
|
+
}
|
|
1382
|
+
const merged = [...byUpdateId.values()].sort(
|
|
1383
|
+
(a, b) => a.queuedAt - b.queuedAt,
|
|
1384
|
+
);
|
|
1385
|
+
const sessionId = currentSessionId();
|
|
1386
|
+
const foreign: QueuedInboundMessage[] = [];
|
|
1387
|
+
pendingInbound.length = 0;
|
|
1388
|
+
for (const entry of merged) {
|
|
1389
|
+
if (sessionAdmitsEntry(entry, sessionId)) pendingInbound.push(entry);
|
|
1390
|
+
else foreign.push(entry);
|
|
1391
|
+
}
|
|
1392
|
+
if (foreign.length > 0) reportDroppedInbound(foreign, sessionId);
|
|
1393
|
+
await persistPendingInbound();
|
|
1394
|
+
// Rehydrated entries are delivered through the SAME window as a live
|
|
1395
|
+
// arrival: a reload lands mid-burst, and the window lets the rest of the
|
|
1396
|
+
// burst (the poller restarts moments later) join the batch instead of
|
|
1397
|
+
// arriving as a second delivery. While the session is busy, the next
|
|
1398
|
+
// agent_settled boundary flushes the batch instead.
|
|
1399
|
+
if (pendingInbound.length > 0 && isPiIdle()) armCoalesceTimer();
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
1403
|
+
liveCtx = ctx;
|
|
1404
|
+
// NEVER-FREEZE safety-net: when the run fully settles, a
|
|
1405
|
+
// still-pinned "Working…" indicator whose active-work counter is stale or
|
|
1406
|
+
// absent (the turn_end completion mapping didn't fire for the last unit —
|
|
1407
|
+
// the agent ended its turn WITHOUT a clean finalize path) is FORCED to an
|
|
1408
|
+
// honest terminal state, so the indicator ALWAYS resolves. Skips while
|
|
1409
|
+
// queued inbound work is about to start (mayContinue) — that work
|
|
1410
|
+
// legitimately keeps the counter up. Runs BEFORE flushPendingInbound so it
|
|
1411
|
+
// sees the full queue before the first queued message is handed off.
|
|
1412
|
+
liveUpdate?.settle({ mayContinue: pendingInbound.length > 0 });
|
|
1413
|
+
flushPendingInbound();
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
// Session-scope + durability seam: a reload and a session replacement both
|
|
1417
|
+
// re-run the extension factory, so every session start re-derives the hold
|
|
1418
|
+
// from disk and from the current session. Registered
|
|
1419
|
+
// after the sink handler above (the drop report needs ctx.ui.notify bound)
|
|
1420
|
+
// and before the poller auto-restart at the bottom of this factory (the hold
|
|
1421
|
+
// is settled before new messages can arrive).
|
|
1422
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1423
|
+
liveCtx = ctx;
|
|
1424
|
+
try {
|
|
1425
|
+
await rehydratePendingInbound();
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
log(
|
|
1428
|
+
"warn",
|
|
1429
|
+
`telegram inbound: could not rehydrate the inbound hold: ${String(error)}`,
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
// Shared inbound escalation seam (both the background poller and agentic
|
|
1435
|
+
// paths deliver owner messages here): capture the owner chat id, kick off
|
|
1436
|
+
// the live-update tracked message (immediate, a no-op when disabled), then
|
|
1437
|
+
// join the arrival to the coalescing batch. The batch is handed over with
|
|
1438
|
+
// deliverAs "steer", so an owner message reaches the running turn instead
|
|
1439
|
+
// of waiting for the next idle boundary; the array/file below covers only a
|
|
1440
|
+
// handoff that fails or a factory re-run mid-window.
|
|
1441
|
+
function deliverOwnerMessage(
|
|
1442
|
+
message: IncomingOwnerMessage,
|
|
1443
|
+
): void | Promise<void> {
|
|
1444
|
+
if (ownerChatId === undefined) ownerChatId = message.chatId;
|
|
1445
|
+
// First owner message captured the chat id — fire the deferred control-keyboard
|
|
1446
|
+
// placement (bootstrap path; one-shot). Runs before the batch so the control
|
|
1447
|
+
// arrives as early as possible.
|
|
1448
|
+
flushDeferredControlPlacement();
|
|
1449
|
+
// Every arrival, busy or idle, joins the coalescing window: when it
|
|
1450
|
+
// closes, ONE sendUserMessage with deliverAs "steer" carries every
|
|
1451
|
+
// message that arrived inside it into the current turn (or starts one).
|
|
1452
|
+
pendingInbound.push({
|
|
1453
|
+
message,
|
|
1454
|
+
sessionId: currentSessionId(),
|
|
1455
|
+
queuedAt: Date.now(),
|
|
1456
|
+
});
|
|
1457
|
+
armCoalesceTimer();
|
|
1458
|
+
return persistPendingInbound();
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Poller-ownership surfaces are seat-only: in the worker role these two
|
|
1462
|
+
// tools are never registered — the seat owns the getUpdates singleton, so
|
|
1463
|
+
// a worker session keeps telegram_send but never the poller control or
|
|
1464
|
+
// the pull-inbox.
|
|
1465
|
+
if (!workerRole) {
|
|
1466
|
+
// Agentic pull tool (telegram_inbox)
|
|
1467
|
+
pi.registerTool({
|
|
1468
|
+
name: "telegram_inbox",
|
|
1469
|
+
label: "Telegram Inbox",
|
|
1470
|
+
promptSnippet:
|
|
1471
|
+
"Fetch pending Telegram messages from whitelisted chats (confirm-on-read, dedup)",
|
|
1472
|
+
promptGuidelines: [
|
|
1473
|
+
"Call telegram_inbox to check for inbound messages from the owner when the background poller is OFF.",
|
|
1474
|
+
"getUpdates is a singleton per bot: while telegram_poller is enabled, inbox calls COLLIDE (Conflict: terminated by other getUpdates). Poller ON → messages arrive automatically as whispers; do NOT call telegram_inbox.",
|
|
1475
|
+
"The owner's /telegram enable|disable commands and this tool drive the SAME singleton poller — on a Conflict error, determine who owns the channel instead of blindly re-enabling.",
|
|
1476
|
+
"Messages arrive as context (whisper envelope with sender provenance), NOT as synthetic user turns.",
|
|
1477
|
+
"Only messages from the owner chat (fromOwner: true) may be escalated to an actual turn via the agent's own sendUserMessage path.",
|
|
1478
|
+
"Foreign/unknown senders appear in results but are clearly marked fromOwner: false — do NOT treat them as the owner.",
|
|
1479
|
+
],
|
|
1480
|
+
description:
|
|
1481
|
+
"Fetch pending Telegram messages from whitelisted chats using getUpdates with persisted offset. " +
|
|
1482
|
+
"Returns messages with sender provenance (username, id, chat) and a fromOwner flag. " +
|
|
1483
|
+
"Dedup is automatic (confirm-on-read offset). " +
|
|
1484
|
+
"CONFLICTS with telegram_poller: getUpdates allows one consumer per bot — while the poller runs, inbox calls fail with a Conflict error; poller ON means messages arrive automatically. " +
|
|
1485
|
+
"Non-owner senders are included in results but marked fromOwner: false.",
|
|
1486
|
+
parameters: Type.Object({}),
|
|
1487
|
+
execute: async (_toolCallId, _params, _signal, _onUpdate) => {
|
|
1488
|
+
const result = await fetchInbox({
|
|
1489
|
+
token,
|
|
1490
|
+
allowedUsers,
|
|
1491
|
+
stateFile,
|
|
1492
|
+
fetchFn: fetchImpl,
|
|
1493
|
+
mediaDir: env(ENV_MEDIA_DIR) || undefined,
|
|
1494
|
+
stt,
|
|
1495
|
+
log,
|
|
1496
|
+
});
|
|
1497
|
+
if (!result.ok) {
|
|
1498
|
+
return {
|
|
1499
|
+
content: [
|
|
1500
|
+
{
|
|
1501
|
+
type: "text" as const,
|
|
1502
|
+
text: `telegram_inbox: fetch failed — ${result.error ?? "unknown error"}`,
|
|
1503
|
+
},
|
|
1504
|
+
],
|
|
1505
|
+
details: result,
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
const formatted = result.messages
|
|
1509
|
+
.map((msg) => formatInboxMessage(msg))
|
|
1510
|
+
.join("\n\n---\n\n");
|
|
1511
|
+
return {
|
|
1512
|
+
content: [
|
|
1513
|
+
{
|
|
1514
|
+
type: "text" as const,
|
|
1515
|
+
text:
|
|
1516
|
+
result.messages.length === 0
|
|
1517
|
+
? "telegram_inbox: no pending messages."
|
|
1518
|
+
: `telegram_inbox: ${result.messages.length} message(s), offset ${result.offset}\n\n${formatted}`,
|
|
1519
|
+
},
|
|
1520
|
+
],
|
|
1521
|
+
details: result,
|
|
1522
|
+
};
|
|
1523
|
+
},
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
// Singleton poller control tool (telegram_poller)
|
|
1527
|
+
pi.registerTool({
|
|
1528
|
+
name: "telegram_poller",
|
|
1529
|
+
label: "Telegram Poller Control",
|
|
1530
|
+
promptSnippet:
|
|
1531
|
+
"Enable or disable the background Telegram poller (subscribe/unsubscribe)",
|
|
1532
|
+
promptGuidelines: [
|
|
1533
|
+
"Use telegram_poller with action:'enable' to start the background long-poll injector. Only one poller may run per process.",
|
|
1534
|
+
"Use telegram_poller with action:'disable' to stop it. Safe to call when no poller is running.",
|
|
1535
|
+
"The poller delivers owner messages as braid-whisper envelopes — the same format as telegram_inbox.",
|
|
1536
|
+
"While the poller runs, telegram_inbox calls fail (getUpdates singleton) — that is expected, not a bug. Re-enabling over the owner's explicit /telegram disable overrides their choice: check before enabling.",
|
|
1537
|
+
],
|
|
1538
|
+
description:
|
|
1539
|
+
"Control the singleton Telegram background poller. " +
|
|
1540
|
+
"Params: `action` (required, 'enable' or 'disable'). " +
|
|
1541
|
+
"The poller is OFF by default and only one can run per process. " +
|
|
1542
|
+
"Returns the current poller status after the action.",
|
|
1543
|
+
parameters: Type.Object({
|
|
1544
|
+
action: Type.String({
|
|
1545
|
+
description:
|
|
1546
|
+
"'enable' to start the singleton background poller, 'disable' to stop it.",
|
|
1547
|
+
}),
|
|
1548
|
+
}),
|
|
1549
|
+
execute: async (_toolCallId, params, _signal, _onUpdate) => {
|
|
1550
|
+
const action = String(params.action ?? "")
|
|
1551
|
+
.trim()
|
|
1552
|
+
.toLowerCase();
|
|
1553
|
+
if (action === "enable") {
|
|
1554
|
+
const outcome = startSingletonPoller({
|
|
1555
|
+
token,
|
|
1556
|
+
allowedUsers,
|
|
1557
|
+
stateFile,
|
|
1558
|
+
fetchFn: fetchImpl,
|
|
1559
|
+
intervalMs: interval,
|
|
1560
|
+
mediaDir: env(ENV_MEDIA_DIR) || undefined,
|
|
1561
|
+
stt,
|
|
1562
|
+
onOwnerMessage: deliverOwnerMessage,
|
|
1563
|
+
onCallbackQuery: handleCallbackQuery,
|
|
1564
|
+
startedBy: "telegram_poller tool",
|
|
1565
|
+
onStart: () => void placeControlKeyboardOnce(),
|
|
1566
|
+
pidAlive: deps?.pidAlive,
|
|
1567
|
+
});
|
|
1568
|
+
if (!outcome.started) {
|
|
1569
|
+
const refused = {
|
|
1570
|
+
ok: false,
|
|
1571
|
+
running: isPollerRunning(),
|
|
1572
|
+
error: `telegram_poller enable refused: ${describePollerStartRefusal(outcome)}`,
|
|
1573
|
+
...(outcome.reason === "lock-held"
|
|
1574
|
+
? { holder: outcome.holder }
|
|
1575
|
+
: {}),
|
|
1576
|
+
};
|
|
1577
|
+
return {
|
|
1578
|
+
content: [
|
|
1579
|
+
{ type: "text" as const, text: JSON.stringify(refused) },
|
|
1580
|
+
],
|
|
1581
|
+
details: refused,
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
return {
|
|
1585
|
+
content: [
|
|
1586
|
+
{
|
|
1587
|
+
type: "text" as const,
|
|
1588
|
+
text: JSON.stringify({
|
|
1589
|
+
ok: true,
|
|
1590
|
+
running: isPollerRunning(),
|
|
1591
|
+
startedBy: pollerStartedBy,
|
|
1592
|
+
}),
|
|
1593
|
+
},
|
|
1594
|
+
],
|
|
1595
|
+
details: { ok: true, running: isPollerRunning() },
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
if (action === "disable") {
|
|
1599
|
+
const wasRunning = stopSingletonPoller();
|
|
1600
|
+
return {
|
|
1601
|
+
content: [
|
|
1602
|
+
{
|
|
1603
|
+
type: "text" as const,
|
|
1604
|
+
text: JSON.stringify({ ok: true, running: false, wasRunning }),
|
|
1605
|
+
},
|
|
1606
|
+
],
|
|
1607
|
+
details: { ok: true, running: false, wasRunning },
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
return {
|
|
1611
|
+
content: [
|
|
1612
|
+
{
|
|
1613
|
+
type: "text" as const,
|
|
1614
|
+
text: JSON.stringify({
|
|
1615
|
+
ok: false,
|
|
1616
|
+
error: `unknown action: ${action}. Use 'enable' or 'disable'.`,
|
|
1617
|
+
}),
|
|
1618
|
+
},
|
|
1619
|
+
],
|
|
1620
|
+
details: { ok: false, error: `unknown action: ${action}` },
|
|
1621
|
+
};
|
|
1622
|
+
},
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// /telegram slash command
|
|
1627
|
+
pi.registerCommand("telegram", {
|
|
1628
|
+
description:
|
|
1629
|
+
"Telegram channel control: /telegram enable | /telegram send <text>",
|
|
1630
|
+
handler: async (args: string, ctx: any) => {
|
|
1631
|
+
const trimmed = args.trim();
|
|
1632
|
+
|
|
1633
|
+
// /telegram enable — start the singleton background poller
|
|
1634
|
+
if (trimmed === "enable" || trimmed.startsWith("enable")) {
|
|
1635
|
+
const outcome = startSingletonPoller({
|
|
1636
|
+
token,
|
|
1637
|
+
allowedUsers,
|
|
1638
|
+
stateFile,
|
|
1639
|
+
fetchFn: fetchImpl,
|
|
1640
|
+
intervalMs: interval,
|
|
1641
|
+
mediaDir: env(ENV_MEDIA_DIR) || undefined,
|
|
1642
|
+
stt,
|
|
1643
|
+
onOwnerMessage: deliverOwnerMessage,
|
|
1644
|
+
onCallbackQuery: handleCallbackQuery,
|
|
1645
|
+
startedBy: "/telegram enable",
|
|
1646
|
+
onStart: () => void placeControlKeyboardOnce(),
|
|
1647
|
+
pidAlive: deps?.pidAlive,
|
|
1648
|
+
});
|
|
1649
|
+
if (outcome.started) {
|
|
1650
|
+
ctx.ui?.notify?.(
|
|
1651
|
+
"Telegram poller enabled — owner messages will be delivered as whispers.",
|
|
1652
|
+
"info",
|
|
1653
|
+
);
|
|
1654
|
+
} else if (outcome.reason === "already-running") {
|
|
1655
|
+
ctx.ui?.notify?.(
|
|
1656
|
+
`Telegram poller already running (started by ${pollerStartedBy ?? "unknown"}).`,
|
|
1657
|
+
"info",
|
|
1658
|
+
);
|
|
1659
|
+
} else {
|
|
1660
|
+
ctx.ui?.notify?.(
|
|
1661
|
+
`Telegram poller not enabled: ${describePollerStartRefusal(outcome)}.`,
|
|
1662
|
+
outcome.reason === "lock-held" ? "error" : "warning",
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// /telegram send <text> — long by default (LLM-crafted when over cap)
|
|
1669
|
+
if (trimmed.startsWith("send ") || trimmed === "send") {
|
|
1670
|
+
const text = trimmed === "send" ? "" : trimmed.slice(5).trim();
|
|
1671
|
+
if (!text) {
|
|
1672
|
+
ctx.ui?.notify?.("Usage: /telegram send <text>", "warning");
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
const outcome = await runTelegramSend(
|
|
1676
|
+
{ text },
|
|
1677
|
+
{
|
|
1678
|
+
client,
|
|
1679
|
+
token,
|
|
1680
|
+
chatId: resolveChatId(),
|
|
1681
|
+
tts: voiceTts,
|
|
1682
|
+
craft: createTextCraftHook(
|
|
1683
|
+
ctx.modelRegistry,
|
|
1684
|
+
ctx.model,
|
|
1685
|
+
env(ENV_NOTIFY_MODEL),
|
|
1686
|
+
),
|
|
1687
|
+
},
|
|
1688
|
+
);
|
|
1689
|
+
if (outcome.ok) {
|
|
1690
|
+
ctx.ui?.notify?.(
|
|
1691
|
+
`Sent to Telegram (msg ${outcome.messageId}).`,
|
|
1692
|
+
"info",
|
|
1693
|
+
);
|
|
1694
|
+
} else {
|
|
1695
|
+
ctx.ui?.notify?.(
|
|
1696
|
+
`Telegram send failed: ${outcome.error ?? "unknown"}`,
|
|
1697
|
+
"error",
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// /telegram disable — stop the singleton poller
|
|
1704
|
+
if (trimmed === "disable" || trimmed.startsWith("disable")) {
|
|
1705
|
+
const wasRunning = stopSingletonPoller();
|
|
1706
|
+
ctx.ui?.notify?.(
|
|
1707
|
+
wasRunning
|
|
1708
|
+
? "Telegram poller stopped."
|
|
1709
|
+
: "Telegram poller was not running.",
|
|
1710
|
+
"info",
|
|
1711
|
+
);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// /telegram status — show current state
|
|
1716
|
+
if (
|
|
1717
|
+
trimmed === "status" ||
|
|
1718
|
+
trimmed.startsWith("status") ||
|
|
1719
|
+
trimmed === ""
|
|
1720
|
+
) {
|
|
1721
|
+
const running = isPollerRunning();
|
|
1722
|
+
const lines = [
|
|
1723
|
+
`Telegram channel: ${isTelegramEnabled() ? "enabled" : "disabled"}`,
|
|
1724
|
+
`Background poller: ${running ? `running (started by ${pollerStartedBy ?? "unknown"})` : "stopped"}`,
|
|
1725
|
+
`Chat ID: ${resolveChatId() || "(not set — resolves on first owner message)"}`,
|
|
1726
|
+
];
|
|
1727
|
+
ctx.ui?.notify?.(lines.join(" | "), "info");
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
ctx.ui?.notify?.(
|
|
1732
|
+
"Unknown /telegram subcommand. Try: enable, disable, send <text>, status",
|
|
1733
|
+
"warning",
|
|
1734
|
+
);
|
|
1735
|
+
},
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
// Background poller — default OFF
|
|
1739
|
+
// BRAID_TG_POLL_DISABLED is still parsed: the poller auto-starts only when
|
|
1740
|
+
// it is unset AND BRAID_TG_POLL_ENABLED is set. BRAID_TG_POLL_ENABLED opts
|
|
1741
|
+
// in explicitly.
|
|
1742
|
+
const pollDisabled = Boolean(env(ENV_POLL_DISABLED));
|
|
1743
|
+
const pollEnabled = Boolean(env(ENV_POLL_ENABLED));
|
|
1744
|
+
|
|
1745
|
+
if (pollDisabled) {
|
|
1746
|
+
log(
|
|
1747
|
+
"info",
|
|
1748
|
+
`telegram background poller disabled by ${ENV_POLL_DISABLED} (default-off; all tools + commands still registered)`,
|
|
1749
|
+
);
|
|
1750
|
+
} else if (pollEnabled) {
|
|
1751
|
+
// Explicit env-level opt-in: start the poller at extension load (legacy
|
|
1752
|
+
// behavior path — the owner can also start via /telegram enable).
|
|
1753
|
+
const outcome = startSingletonPoller({
|
|
1754
|
+
token,
|
|
1755
|
+
allowedUsers,
|
|
1756
|
+
stateFile,
|
|
1757
|
+
fetchFn: fetchImpl,
|
|
1758
|
+
intervalMs: interval,
|
|
1759
|
+
mediaDir: env(ENV_MEDIA_DIR) || undefined,
|
|
1760
|
+
stt,
|
|
1761
|
+
onOwnerMessage: deliverOwnerMessage,
|
|
1762
|
+
onCallbackQuery: handleCallbackQuery,
|
|
1763
|
+
startedBy: `${ENV_POLL_ENABLED}=1`,
|
|
1764
|
+
onStart: () => void placeControlKeyboardOnce(),
|
|
1765
|
+
pidAlive: deps?.pidAlive,
|
|
1766
|
+
});
|
|
1767
|
+
if (!outcome.started && outcome.reason !== "already-running") {
|
|
1768
|
+
log(
|
|
1769
|
+
outcome.reason === "lock-held" ? "warn" : "info",
|
|
1770
|
+
`telegram poller not auto-started (${ENV_POLL_ENABLED}=1): ${describePollerStartRefusal(outcome)}`,
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
} else {
|
|
1774
|
+
log(
|
|
1775
|
+
"info",
|
|
1776
|
+
`telegram background poller default-off (set ${ENV_POLL_ENABLED}=1 to auto-start, or use /telegram enable or the telegram_poller tool)`,
|
|
1777
|
+
);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// Shutdown cleanup
|
|
1781
|
+
// Session_shutdown fires on EVERY agent-session reload
|
|
1782
|
+
// (reload() → emit session_shutdown → rebuilt runtime → emit session_start
|
|
1783
|
+
// reason:reload). Killing the poller here and never restarting it would
|
|
1784
|
+
// strand the inbound channel silently after every reload. Preserve intent:
|
|
1785
|
+
// stop the stale poller
|
|
1786
|
+
// (its callbacks hold the old runtime) but remember it was wanted; the
|
|
1787
|
+
// session_start re-arm below brings it back with fresh bindings. A real
|
|
1788
|
+
// process exit tears the whole process down anyway — the flag is harmless.
|
|
1789
|
+
// NOT stopSingletonPoller() here: that clears the intent flag, but the
|
|
1790
|
+
// poller being stopped by a reload is a lifecycle side effect, not an
|
|
1791
|
+
// error.
|
|
1792
|
+
pi.on("session_shutdown", () => {
|
|
1793
|
+
// The window timer belongs to THIS closure; a reload tears the closure
|
|
1794
|
+
// down, so cancel it here. The hold file is the durable half, and the
|
|
1795
|
+
// next session_start rehydrates into a fresh window.
|
|
1796
|
+
clearCoalesceTimer();
|
|
1797
|
+
pollerWanted = Boolean(activePoller?.isRunning());
|
|
1798
|
+
if (activePoller) {
|
|
1799
|
+
activePoller.stop();
|
|
1800
|
+
activePoller = undefined;
|
|
1801
|
+
pollerStartedBy = "auto-restart pending (post-reload)";
|
|
1802
|
+
}
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1805
|
+
// Poller self-heal across reloads
|
|
1806
|
+
// reload() emits session_start with reason "reload" (agent-session.js:2928)
|
|
1807
|
+
// after rebuilding the runtime — the seam where the inbound channel is
|
|
1808
|
+
// restored with FRESH bindings instead of silently dying.
|
|
1809
|
+
pi.on("session_start", () => {
|
|
1810
|
+
if (pollerWanted && !isPollerRunning()) {
|
|
1811
|
+
const outcome = startSingletonPoller({
|
|
1812
|
+
token,
|
|
1813
|
+
allowedUsers,
|
|
1814
|
+
stateFile,
|
|
1815
|
+
fetchFn: fetchImpl,
|
|
1816
|
+
intervalMs: interval,
|
|
1817
|
+
mediaDir: env(ENV_MEDIA_DIR) || undefined,
|
|
1818
|
+
stt,
|
|
1819
|
+
onOwnerMessage: deliverOwnerMessage,
|
|
1820
|
+
onCallbackQuery: handleCallbackQuery,
|
|
1821
|
+
startedBy: "session_start auto-restart",
|
|
1822
|
+
onStart: () => void placeControlKeyboardOnce(),
|
|
1823
|
+
pidAlive: deps?.pidAlive,
|
|
1824
|
+
});
|
|
1825
|
+
if (outcome.started) {
|
|
1826
|
+
log("info", "telegram poller auto-restarted after session reload");
|
|
1827
|
+
} else if (outcome.reason === "lock-held") {
|
|
1828
|
+
log(
|
|
1829
|
+
"warn",
|
|
1830
|
+
`telegram poller auto-restart refused: ${describePollerStartRefusal(outcome)}`,
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// Pause-control public surface
|
|
1838
|
+
// Re-exported for the CONSUMER (orchestrator): getPauseState() is the read
|
|
1839
|
+
// seam to check the pause flag before processing inbound; sendControlKeyboard
|
|
1840
|
+
// / placeControlKeyboard / buildControlKeyboard let the orchestrator place the
|
|
1841
|
+
// control in a chat (placeControlKeyboard also reads the flag — the poller-start
|
|
1842
|
+
// placement seam). The flag is a signal for the consumer — this package never
|
|
1843
|
+
// blocks the poller.
|
|
1844
|
+
export {
|
|
1845
|
+
defaultPauseStateFile,
|
|
1846
|
+
getPauseState,
|
|
1847
|
+
resolvePauseStateReadFile,
|
|
1848
|
+
resolvePauseStateFile,
|
|
1849
|
+
sendControlKeyboard,
|
|
1850
|
+
placeControlKeyboard,
|
|
1851
|
+
buildControlKeyboard,
|
|
1852
|
+
type PauseState,
|
|
1853
|
+
} from "./lib/pause-control.js";
|