switchroom 0.16.29 → 0.16.46
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/dist/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -48,6 +48,34 @@ import {
|
|
|
48
48
|
type GifSendArgs,
|
|
49
49
|
} from '../sticker-aliases.js'
|
|
50
50
|
import { transcribeViaWhisper } from '../voice-transcribe.js'
|
|
51
|
+
import { transcribeViaSidecar } from '../voice-transcribe-sidecar.js'
|
|
52
|
+
import {
|
|
53
|
+
synthesizeViaSidecar,
|
|
54
|
+
chunkTtsText,
|
|
55
|
+
clampTtsSpeed,
|
|
56
|
+
} from '../voice-synthesize-sidecar.js'
|
|
57
|
+
import {
|
|
58
|
+
VoiceOnDemandCache,
|
|
59
|
+
mintVoiceOnDemandToken,
|
|
60
|
+
isVoiceOnDemandCallback,
|
|
61
|
+
parseVoiceOnDemandToken,
|
|
62
|
+
buildListenKeyboard,
|
|
63
|
+
mayInjectListenButton,
|
|
64
|
+
} from '../voice-ondemand.js'
|
|
65
|
+
import {
|
|
66
|
+
PreSynthQueue,
|
|
67
|
+
sweepVoiceCacheDir,
|
|
68
|
+
writeVoiceCacheFile,
|
|
69
|
+
eagerVoiceEnabled,
|
|
70
|
+
VOICE_SWEEP_INTERVAL_MS,
|
|
71
|
+
} from '../voice-presynth.js'
|
|
72
|
+
|
|
73
|
+
/** Fleet default TTS speed when voice_out is enabled but speed is unset —
|
|
74
|
+
* slightly brisker than neutral (Ken's chosen default). */
|
|
75
|
+
const VOICE_OUT_DEFAULT_SPEED = 1.1
|
|
76
|
+
import { normalizeForSpeech } from '../voice-normalize-text.js'
|
|
77
|
+
import { normalizeForTts } from '../tts-normalize.js'
|
|
78
|
+
import { synthesizeViaOpenAi } from '../voice-synthesize.js'
|
|
51
79
|
import {
|
|
52
80
|
createTelegraphAccount,
|
|
53
81
|
createTelegraphPage,
|
|
@@ -65,6 +93,9 @@ import {
|
|
|
65
93
|
import { StatusReactionController } from '../status-reactions.js'
|
|
66
94
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
67
95
|
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
96
|
+
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
97
|
+
import type { PinState, DesiredPin } from '../status-pin.js'
|
|
98
|
+
import { decidePinAction } from '../status-pin.js'
|
|
68
99
|
import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
|
|
69
100
|
import { parseSourceMessageId } from './source-message-id.js'
|
|
70
101
|
import {
|
|
@@ -73,11 +104,14 @@ import {
|
|
|
73
104
|
duplicateDenyMessage,
|
|
74
105
|
isRecentTimeoutDuplicate,
|
|
75
106
|
PERMISSION_TTL_MS,
|
|
107
|
+
approvalTtlMs,
|
|
76
108
|
ttlForTool,
|
|
77
109
|
buildTimedOutCardEdits,
|
|
78
110
|
STALE_TAP_NOTICE,
|
|
79
111
|
type PermissionCardRef,
|
|
80
112
|
} from './permission-timeout.js'
|
|
113
|
+
import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
|
|
114
|
+
import { createPermissionCardStore } from './permission-card-store.js'
|
|
81
115
|
import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
|
|
82
116
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
83
117
|
import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, type SessionActivityHeader } from '../tool-activity-summary.js'
|
|
@@ -140,6 +174,10 @@ import {
|
|
|
140
174
|
runMicrosoftConnectPoll,
|
|
141
175
|
} from './microsoft-connect-flow.js'
|
|
142
176
|
import { resolveAuthBrokerSocketPath } from '../../src/auth/broker/client.js'
|
|
177
|
+
import { materializeVoiceKey } from '../../src/telegram/materialize-voice-key.js'
|
|
178
|
+
import { materializeSidecarToken } from '../../src/telegram/materialize-sidecar-token.js'
|
|
179
|
+
import { loadHostCapabilities } from '../../src/setup/host-capabilities.js'
|
|
180
|
+
import type { VoiceEngine } from '../../src/setup/gpu-detect.js'
|
|
143
181
|
import { createFleetFallbackGate } from '../fleet-fallback-gate.js'
|
|
144
182
|
import { createFleetFallbackResumeGate } from '../fleet-fallback-resume.js'
|
|
145
183
|
import { resolveExhaustUntil } from './exhaust-until.js'
|
|
@@ -195,7 +233,7 @@ const REPLY_TO_TEXT_MAX = 200
|
|
|
195
233
|
const SILENT_END_FALLBACK_TEXT =
|
|
196
234
|
'⚠️ The agent finished working but didn’t send a reply — your last ' +
|
|
197
235
|
'message may not have been answered. Please try asking again.'
|
|
198
|
-
import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
236
|
+
import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
199
237
|
import { richMessage } from '../rich-send.js'
|
|
200
238
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
201
239
|
import {
|
|
@@ -287,6 +325,8 @@ import {
|
|
|
287
325
|
MODEL_CALLBACK_PREFIX,
|
|
288
326
|
MODEL_CALLBACK_HEADER,
|
|
289
327
|
MODEL_CALLBACK_SR,
|
|
328
|
+
MODEL_CALLBACK_PAGE_EXTERNAL,
|
|
329
|
+
MODEL_CALLBACK_PAGE_MAIN,
|
|
290
330
|
srFriendlyLabel,
|
|
291
331
|
type ModelMenuDeps,
|
|
292
332
|
type ModelCommandDeps,
|
|
@@ -341,6 +381,15 @@ import {
|
|
|
341
381
|
obligationEscalationText,
|
|
342
382
|
} from './obligation-ledger.js'
|
|
343
383
|
import { loadObligations, persistObligations } from './obligation-store.js'
|
|
384
|
+
import {
|
|
385
|
+
loadStatusPins,
|
|
386
|
+
pinnedMessageIsOurs,
|
|
387
|
+
reconcileAndPersistStatusPin,
|
|
388
|
+
runStatusPinBootCleanup,
|
|
389
|
+
type PersistedStatusPin,
|
|
390
|
+
type StatusPinPersistOp,
|
|
391
|
+
type TrackedStatusPin,
|
|
392
|
+
} from './status-pin-store.js'
|
|
344
393
|
import { driveEscalation } from './escalation-drive.js'
|
|
345
394
|
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
346
395
|
import { createInboundSpool } from './inbound-spool.js'
|
|
@@ -362,6 +411,9 @@ import {
|
|
|
362
411
|
} from './emission-authority.js'
|
|
363
412
|
import { CurrentTurnMap } from './current-turn-map.js'
|
|
364
413
|
import { resolveAnswerThreadId } from './answer-thread-resolve.js'
|
|
414
|
+
import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
415
|
+
import { decideObligationTurnEnd } from './obligation-turn-end.js'
|
|
416
|
+
import { maybeRotate } from './turns-jsonl-rotate.js'
|
|
365
417
|
import {
|
|
366
418
|
createDeliveryQueue,
|
|
367
419
|
trackDelivery,
|
|
@@ -385,6 +437,7 @@ import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-d
|
|
|
385
437
|
import { maybeFireWarmup } from './prefix-warmup.js'
|
|
386
438
|
import {
|
|
387
439
|
buildVaultGrantApprovedInbound,
|
|
440
|
+
buildVaultGrantApprovedCardText,
|
|
388
441
|
buildVaultGrantDeniedInbound,
|
|
389
442
|
buildVaultSaveCompletedInbound,
|
|
390
443
|
buildVaultSaveFailedInbound,
|
|
@@ -428,6 +481,8 @@ import type {
|
|
|
428
481
|
QuotaWallDetectedMessage,
|
|
429
482
|
PostSkillProposalMessage,
|
|
430
483
|
PermissionEvent,
|
|
484
|
+
RolloutStatusPostMessage,
|
|
485
|
+
RolloutStatusEditMessage,
|
|
431
486
|
} from './ipc-protocol.js'
|
|
432
487
|
import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
|
|
433
488
|
import { buildReactionDispatchInbound, evaluateReactionDispatch, resolveReactionDispatchConfig, type ReactionDispatchResolvedConfig } from './reaction-dispatch.js'
|
|
@@ -599,6 +654,7 @@ process.on('beforeExit', () => {
|
|
|
599
654
|
|
|
600
655
|
// ─── Env + state dir ──────────────────────────────────────────────────────
|
|
601
656
|
const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
657
|
+
const permCardStore = createPermissionCardStore(STATE_DIR)
|
|
602
658
|
const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
|
603
659
|
const APPROVED_DIR = join(STATE_DIR, 'approved')
|
|
604
660
|
const ENV_FILE = join(STATE_DIR, '.env')
|
|
@@ -950,12 +1006,42 @@ type Access = {
|
|
|
950
1006
|
/** Voice-in transcription config (#578 spike). When `enabled` is
|
|
951
1007
|
* true and provider is 'openai', inbound voice/audio messages are
|
|
952
1008
|
* downloaded and transcribed via Whisper, then surface as the
|
|
953
|
-
* user's inbound text. API key
|
|
954
|
-
*
|
|
1009
|
+
* user's inbound text. The provider API key is a `vault:` reference
|
|
1010
|
+
* (`api_key`, default `vault:openai/api-key`) resolved through the
|
|
1011
|
+
* vault broker at use-time — never read from a plaintext file. Off by
|
|
1012
|
+
* default. */
|
|
955
1013
|
voice_in?: {
|
|
956
1014
|
enabled?: boolean
|
|
957
1015
|
provider?: 'openai'
|
|
958
1016
|
language?: string
|
|
1017
|
+
api_key?: string
|
|
1018
|
+
}
|
|
1019
|
+
/** Outbound spoken replies via TTS (PR-C2). When enabled, the agent's
|
|
1020
|
+
* text reply is synthesized into an OGG/Opus voice note and sent
|
|
1021
|
+
* alongside (or instead of) the text, per `reply_mode`. The 'kokoro'
|
|
1022
|
+
* engine uses the local voice sidecar (POST /tts) and is only active
|
|
1023
|
+
* when the host voice verdict is local (SWITCHROOM_VOICE_ENGINE ===
|
|
1024
|
+
* 'local'); 'openai' is an honest-exception cloud path gated on an
|
|
1025
|
+
* `api_key` vault ref. Voice is best-effort and fully non-fatal — any
|
|
1026
|
+
* TTS error falls back to the text reply. ONE voice note per response:
|
|
1027
|
+
* the reply is speech-normalized (markdown/symbols stripped) and, for the
|
|
1028
|
+
* kokoro sidecar, sent in a SINGLE /tts call that returns one concatenated
|
|
1029
|
+
* file. Off by default. */
|
|
1030
|
+
voice_out?: {
|
|
1031
|
+
enabled?: boolean
|
|
1032
|
+
engine?: 'kokoro' | 'openai'
|
|
1033
|
+
voice?: string
|
|
1034
|
+
reply_mode?: 'voice+text' | 'voice-only' | 'on-demand'
|
|
1035
|
+
/** Kokoro-engine playback speed. Clamped to 0.5–2.0; defaults to 1.1
|
|
1036
|
+
* when voice_out is enabled but speed is unset (the fleet default —
|
|
1037
|
+
* slightly brisker than neutral). Ignored by the OpenAI path. */
|
|
1038
|
+
speed?: number
|
|
1039
|
+
/** OpenAI-engine only: per-voice-note chunk size (default 600) used to
|
|
1040
|
+
* split a long reply across sequential notes, since OpenAI's TTS input
|
|
1041
|
+
* has a hard cap. Ignored by the kokoro path, which sends the whole
|
|
1042
|
+
* reply in one call (the sidecar owns length + concatenation). */
|
|
1043
|
+
max_chars?: number
|
|
1044
|
+
api_key?: string
|
|
959
1045
|
}
|
|
960
1046
|
/** Telegraph long-reply publishing (#579). When enabled, replies
|
|
961
1047
|
* above `threshold` chars publish to Telegraph and the agent's
|
|
@@ -1049,6 +1135,7 @@ function readAccessFile(): Access {
|
|
|
1049
1135
|
// `access.telegraph`, `access.stickers`) silently see undefined.
|
|
1050
1136
|
stickers: parsed.stickers,
|
|
1051
1137
|
voice_in: parsed.voice_in,
|
|
1138
|
+
voice_out: parsed.voice_out,
|
|
1052
1139
|
telegraph: parsed.telegraph,
|
|
1053
1140
|
}
|
|
1054
1141
|
} catch (err) {
|
|
@@ -3206,6 +3293,19 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
3206
3293
|
// successor turn; the timer's own turnId match is the second guard, this is
|
|
3207
3294
|
// the first. Idempotent — a no-op when no timer is registered.
|
|
3208
3295
|
stopEarlyLivenessOpen(key as string)
|
|
3296
|
+
// Status-pin: turn end is the canonical unpin point for the foreground
|
|
3297
|
+
// pin. `purgeReactionTracking` is the single turn-end owner (all normal /
|
|
3298
|
+
// abnormal exit branches funnel here), so this is the one place the
|
|
3299
|
+
// foreground status pin is dropped. Idempotent: a no-op when nothing was
|
|
3300
|
+
// pinned (trivial turn that never opened a status message). The
|
|
3301
|
+
// drop-on-unpin contract in reconcilePin guarantees state clears even if
|
|
3302
|
+
// the unpin API throws — a stuck pin can never outlive its turn.
|
|
3303
|
+
{
|
|
3304
|
+
const pinChatId = endingTurn != null
|
|
3305
|
+
? endingTurn.sessionChatId
|
|
3306
|
+
: chatIdOfChatKey(key as _ChatKey)
|
|
3307
|
+
void reconcileStatusPin(`fg:${key}`, pinChatId, { pinned: false })
|
|
3308
|
+
}
|
|
3209
3309
|
if (msgInfo) {
|
|
3210
3310
|
const agentDir = resolveAgentDirFromEnv()
|
|
3211
3311
|
if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
|
|
@@ -3401,7 +3501,20 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
|
3401
3501
|
status: turn.finalAnswerDelivered ? 'complete' : 'no_reply',
|
|
3402
3502
|
turn_id: turn.turnId,
|
|
3403
3503
|
}) + '\n'
|
|
3404
|
-
|
|
3504
|
+
const turnsPath = '/state/agent/turns.jsonl'
|
|
3505
|
+
// Size-cap rotation: keep at most one rotated generation so the file can't
|
|
3506
|
+
// grow unbounded on a long-lived agent. Best-effort (never throws).
|
|
3507
|
+
maybeRotate(turnsPath, {
|
|
3508
|
+
statSize: (p) => {
|
|
3509
|
+
try {
|
|
3510
|
+
return statSync(p).size
|
|
3511
|
+
} catch {
|
|
3512
|
+
return undefined
|
|
3513
|
+
}
|
|
3514
|
+
},
|
|
3515
|
+
rename: (from, to) => renameSync(from, to),
|
|
3516
|
+
})
|
|
3517
|
+
appendFileSync(turnsPath, rec)
|
|
3405
3518
|
} catch {
|
|
3406
3519
|
// best-effort — never let metrics emission break turn teardown
|
|
3407
3520
|
}
|
|
@@ -3457,7 +3570,7 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
|
3457
3570
|
// At turn_end with replyCalled=true the model explicitly signalled completion
|
|
3458
3571
|
// AND replied, so the obligation is satisfied regardless of finalAnswerDelivered.
|
|
3459
3572
|
if (OBLIGATION_LEDGER_ENABLED) {
|
|
3460
|
-
if (turn.finalAnswerDelivered
|
|
3573
|
+
if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === 'close') {
|
|
3461
3574
|
obligationLedger.close(turn.turnId)
|
|
3462
3575
|
} else {
|
|
3463
3576
|
// Turn ended WITHOUT any reply (no ack, no answer). If this turn was
|
|
@@ -4671,7 +4784,10 @@ interface PendingVaultRequestSave {
|
|
|
4671
4784
|
staged_at: number
|
|
4672
4785
|
}
|
|
4673
4786
|
const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
|
|
4674
|
-
|
|
4787
|
+
// Gateway-side reap window for a staged vault-save card. Tracks the operator
|
|
4788
|
+
// approval-card lifetime (config-driven, 60-min default) so the reap never
|
|
4789
|
+
// races ahead of the card the operator is still looking at.
|
|
4790
|
+
const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
|
|
4675
4791
|
function sweepPendingVaultRequestSaves(): void {
|
|
4676
4792
|
const cutoff = Date.now() - VAULT_REQUEST_SAVE_TTL_MS
|
|
4677
4793
|
for (const [k, v] of pendingVaultRequestSaves) {
|
|
@@ -4716,7 +4832,9 @@ interface PendingVaultRequestAccess {
|
|
|
4716
4832
|
staged_at: number
|
|
4717
4833
|
}
|
|
4718
4834
|
const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
|
|
4719
|
-
|
|
4835
|
+
// Gateway-side reap window for a staged vault-access card. Tracks the operator
|
|
4836
|
+
// approval-card lifetime (config-driven, 60-min default) — see approvalTtlMs.
|
|
4837
|
+
const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
|
|
4720
4838
|
function sweepPendingVaultRequestAccesses(): void {
|
|
4721
4839
|
const cutoff = Date.now() - VAULT_REQUEST_ACCESS_TTL_MS
|
|
4722
4840
|
for (const [k, v] of pendingVaultRequestAccesses) {
|
|
@@ -5024,6 +5142,7 @@ const pendingStateReaper = setInterval(() => {
|
|
|
5024
5142
|
`${timeoutMinutes}m)\n`,
|
|
5025
5143
|
)
|
|
5026
5144
|
pendingPermissions.delete(k)
|
|
5145
|
+
permCardStore.remove(k)
|
|
5027
5146
|
}
|
|
5028
5147
|
}
|
|
5029
5148
|
// Drop no-repeat suppression entries past the safety-cap window (the primary
|
|
@@ -5481,6 +5600,273 @@ const CLEAR_STATUS_ON_COMPLETION = (() => {
|
|
|
5481
5600
|
return v === '1' || v === 'true' || v === 'on' || v === 'yes'
|
|
5482
5601
|
})()
|
|
5483
5602
|
|
|
5603
|
+
// Whether to SILENTLY pin the already-rendered status message while its work
|
|
5604
|
+
// is in-flight (auto-unpinned on completion). Default ON (silent + low-risk):
|
|
5605
|
+
// keeps in-flight work in view when the conversation scrolls past it. This is
|
|
5606
|
+
// the ONE sanctioned pin under `chat-is-the-single-source-of-truth` — it pins
|
|
5607
|
+
// a message the chat ALREADY rendered (the per-turn activity/status message,
|
|
5608
|
+
// the `🛠 Worker` message), never a new parallel surface, never with a device
|
|
5609
|
+
// buzz. Opt OUT per agent via channels.telegram.pin_status_while_working: false
|
|
5610
|
+
// (→ this env). See status-pin.ts / status-pin-driver.ts and
|
|
5611
|
+
// `reference/invariants.md` § chat-is-the-single-source-of-truth.
|
|
5612
|
+
const PIN_STATUS_WHILE_WORKING = (() => {
|
|
5613
|
+
const raw = process.env.SWITCHROOM_TG_PIN_STATUS_WHILE_WORKING
|
|
5614
|
+
if (raw == null) return true // default ON
|
|
5615
|
+
const v = raw.trim().toLowerCase()
|
|
5616
|
+
return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
|
|
5617
|
+
})()
|
|
5618
|
+
|
|
5619
|
+
// ─── Status-pin: single-owner state + reconcile ────────────────────────────
|
|
5620
|
+
// EXACTLY ONE owner of pin state. Every desired-state change for a key routes
|
|
5621
|
+
// through `reconcileStatusPin`, which computes ONE action (pin|unpin|noop) via
|
|
5622
|
+
// the pure `decidePinAction` and executes it through `reconcilePin` (which
|
|
5623
|
+
// drops the claim on unpin even if the API throws — so pin state can never get
|
|
5624
|
+
// stuck). Two desired-state sources feed distinct keys:
|
|
5625
|
+
// - foreground: `fg:<statusKey>` — pinned while the per-turn activity message
|
|
5626
|
+
// is in-flight; unpinned at the canonical turn-end (purgeReactionTracking).
|
|
5627
|
+
// - background: `wk:<agentId>` — pinned while the `🛠 Worker` message runs;
|
|
5628
|
+
// unpinned on that worker's completion (worker-feed finish).
|
|
5629
|
+
// It does NOT touch the reply / stream_reply send handlers (the v1 bug was send
|
|
5630
|
+
// handlers unconditionally unpinning on every send) and runs NO polling
|
|
5631
|
+
// watchdog / getChat().pinned_message reconciler.
|
|
5632
|
+
const statusPinState = new Map<string, PinState>()
|
|
5633
|
+
// Companion registry: pinKey → chatId, so the pre-restart sweep can unpin
|
|
5634
|
+
// owned pins without threading the chat id through every call site. Written on
|
|
5635
|
+
// every desired-pinned reconcile, cleared alongside the state on unpin.
|
|
5636
|
+
const statusPinChatIds = new Map<string, string>()
|
|
5637
|
+
|
|
5638
|
+
// Durable snapshot of the pin claim set on the persistent per-agent volume
|
|
5639
|
+
// (STATE_DIR = /state/agent/telegram in prod). Closes the crash hole: the
|
|
5640
|
+
// in-memory Maps alone empty on restart, so a status pin left dangling by a
|
|
5641
|
+
// crashed session is never unpinned and the service-message-deletion handler
|
|
5642
|
+
// can't recognise it as ours. Every reconcile persists the current claim set;
|
|
5643
|
+
// boot cleanup (statusPinBootCleanup, wired after lockedBot is defined) unpins
|
|
5644
|
+
// each persisted entry and clears the store. STATIC mode and feature-off skip
|
|
5645
|
+
// disk. Mirrors obligation-store.ts.
|
|
5646
|
+
const STATUS_PIN_STORE_PATH = join(STATE_DIR, 'status-pins.json')
|
|
5647
|
+
const statusPinStoreFs = {
|
|
5648
|
+
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
5649
|
+
writeFileSync: (p: string, d: string) => writeFileSync(p, d),
|
|
5650
|
+
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
5651
|
+
existsSync: (p: string) => existsSync(p),
|
|
5652
|
+
}
|
|
5653
|
+
const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
|
|
5654
|
+
|
|
5655
|
+
// The full live claim set as persisted rows (confirmed pins), from the Maps.
|
|
5656
|
+
function snapshotStatusPins(): PersistedStatusPin[] {
|
|
5657
|
+
const snapshot: PersistedStatusPin[] = []
|
|
5658
|
+
for (const [pinKey, state] of statusPinState) {
|
|
5659
|
+
const chatId = statusPinChatIds.get(pinKey)
|
|
5660
|
+
if (chatId == null) continue
|
|
5661
|
+
snapshot.push({ pinKey, chatId, messageId: state.messageId })
|
|
5662
|
+
}
|
|
5663
|
+
return snapshot
|
|
5664
|
+
}
|
|
5665
|
+
|
|
5666
|
+
// The live claim set EXCLUDING one key — used by reconcileAndPersistStatusPin so
|
|
5667
|
+
// it can rewrite the whole set atomically while it flips that one key's record
|
|
5668
|
+
// between pending / confirmed / absent.
|
|
5669
|
+
function snapshotStatusPinsExcept(exceptKey: string): PersistedStatusPin[] {
|
|
5670
|
+
return snapshotStatusPins().filter((p) => p.pinKey !== exceptKey)
|
|
5671
|
+
}
|
|
5672
|
+
|
|
5673
|
+
// The Bot API surface the pin driver needs. `lockedBot` is defined later; wrap
|
|
5674
|
+
// lazily so this helper can be declared alongside the state it owns.
|
|
5675
|
+
function statusPinApi(): PinBotApi {
|
|
5676
|
+
return {
|
|
5677
|
+
pinChatMessage: (chat_id, message_id, opts) =>
|
|
5678
|
+
robustApiCall(
|
|
5679
|
+
() => lockedBot.api.pinChatMessage(chat_id, message_id, opts),
|
|
5680
|
+
{ chat_id: String(chat_id), verb: 'status-pin.pin' },
|
|
5681
|
+
),
|
|
5682
|
+
unpinChatMessage: (chat_id, message_id) =>
|
|
5683
|
+
robustApiCall(
|
|
5684
|
+
() => lockedBot.api.unpinChatMessage(chat_id, message_id),
|
|
5685
|
+
{ chat_id: String(chat_id), verb: 'status-pin.unpin' },
|
|
5686
|
+
),
|
|
5687
|
+
}
|
|
5688
|
+
}
|
|
5689
|
+
|
|
5690
|
+
/**
|
|
5691
|
+
* Boot-time orphan cleanup — thin gateway wrapper over the pure
|
|
5692
|
+
* `runStatusPinBootCleanup` (which owns the load → best-effort-unpin →
|
|
5693
|
+
* empty-store contract). Binds the live fs seam, the robust unpin api, and the
|
|
5694
|
+
* gateway logger.
|
|
5695
|
+
*
|
|
5696
|
+
* MUST run ONLY after this gateway wins the startup mutex (the store is a
|
|
5697
|
+
* shared per-agent file; a losing double-boot would unpin the live holder's
|
|
5698
|
+
* legitimate pins). Runs before any new pin claim is written, so the emptied
|
|
5699
|
+
* store leaves the service-message handler's ownership check with no false
|
|
5700
|
+
* positives.
|
|
5701
|
+
*/
|
|
5702
|
+
async function statusPinBootCleanup(): Promise<void> {
|
|
5703
|
+
if (!statusPinPersistEnabled) return
|
|
5704
|
+
const api = statusPinApi()
|
|
5705
|
+
const { cleared, total } = await runStatusPinBootCleanup({
|
|
5706
|
+
path: STATUS_PIN_STORE_PATH,
|
|
5707
|
+
fs: statusPinStoreFs,
|
|
5708
|
+
unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
|
|
5709
|
+
})
|
|
5710
|
+
if (total > 0) {
|
|
5711
|
+
process.stderr.write(
|
|
5712
|
+
`telegram gateway: status-pin: cleared ${cleared}/${total} ` +
|
|
5713
|
+
`orphaned pin(s) from a prior session\n`,
|
|
5714
|
+
)
|
|
5715
|
+
}
|
|
5716
|
+
}
|
|
5717
|
+
// NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
|
|
5718
|
+
// The status-pin store is a SHARED per-agent file, and cleanup issues real
|
|
5719
|
+
// unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
|
|
5720
|
+
// that shared state — its unpins would strip pins the STILL-ALIVE holder
|
|
5721
|
+
// legitimately owns. So the call is gated on winning the startup mutex
|
|
5722
|
+
// (top-level await below); see the acquireStartupLock block.
|
|
5723
|
+
|
|
5724
|
+
/**
|
|
5725
|
+
* The single entry point that mutates pin state for `pinKey`. Serialises
|
|
5726
|
+
* nothing itself — callers fire-and-forget; the pure decision + drop-on-unpin
|
|
5727
|
+
* contract keep state consistent. No-op when the feature is disabled.
|
|
5728
|
+
*/
|
|
5729
|
+
async function reconcileStatusPin(
|
|
5730
|
+
pinKey: string,
|
|
5731
|
+
chatId: string,
|
|
5732
|
+
desired: DesiredPin,
|
|
5733
|
+
): Promise<void> {
|
|
5734
|
+
// Fire-and-forget hard boundary. Most callers invoke this as
|
|
5735
|
+
// `void reconcileStatusPin(...)` (auto status-pin is best-effort — it must
|
|
5736
|
+
// never affect turn flow). reconcilePin already swallows pin/unpin API
|
|
5737
|
+
// errors via onError, but the surrounding persistence + Map bookkeeping
|
|
5738
|
+
// (and any future edge) could still reject the returned promise. A rejected
|
|
5739
|
+
// fire-and-forget promise becomes a process-level `unhandledRejection` which
|
|
5740
|
+
// the gateway's handler crashes on — that is exactly how a benign
|
|
5741
|
+
// "not enough rights to manage pinned messages" 400 in a supergroup took the
|
|
5742
|
+
// whole gateway down (marko, 2026-07-01). Auto status-pin is cosmetic; it
|
|
5743
|
+
// must NEVER be able to crash the gateway. Any throw here is logged and
|
|
5744
|
+
// absorbed. (The `pin_message` MCP tool still surfaces failures to the agent
|
|
5745
|
+
// as a normal tool-error — that path is `executePinMessage`, not this one.)
|
|
5746
|
+
try {
|
|
5747
|
+
await reconcileStatusPinInner(pinKey, chatId, desired)
|
|
5748
|
+
} catch (err) {
|
|
5749
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
5750
|
+
process.stderr.write(
|
|
5751
|
+
`telegram gateway: status-pin reconcile absorbed error ` +
|
|
5752
|
+
`(key=${pinKey} chat=${chatId}): ${msg}\n`,
|
|
5753
|
+
)
|
|
5754
|
+
}
|
|
5755
|
+
}
|
|
5756
|
+
|
|
5757
|
+
async function reconcileStatusPinInner(
|
|
5758
|
+
pinKey: string,
|
|
5759
|
+
chatId: string,
|
|
5760
|
+
desired: DesiredPin,
|
|
5761
|
+
): Promise<void> {
|
|
5762
|
+
if (!PIN_STATUS_WHILE_WORKING) return
|
|
5763
|
+
if (chatId.length === 0) return
|
|
5764
|
+
const prev = statusPinState.get(pinKey) ?? null
|
|
5765
|
+
|
|
5766
|
+
const runReconcile = () =>
|
|
5767
|
+
reconcilePin({
|
|
5768
|
+
api: statusPinApi(),
|
|
5769
|
+
chatId,
|
|
5770
|
+
prevState: prev,
|
|
5771
|
+
desired,
|
|
5772
|
+
onError: (phase, err) => {
|
|
5773
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
5774
|
+
process.stderr.write(
|
|
5775
|
+
`telegram gateway: status-pin ${phase} failed (key=${pinKey} chat=${chatId}): ${msg}\n`,
|
|
5776
|
+
)
|
|
5777
|
+
},
|
|
5778
|
+
})
|
|
5779
|
+
|
|
5780
|
+
// Classify the action so we persist INTENT before the pin API call. Only a
|
|
5781
|
+
// fresh `pin` of a message that isn't already our claim opens the leak window
|
|
5782
|
+
// (the API call actually pins something new); everything else (unpin, noop,
|
|
5783
|
+
// re-pin of the same id) clears / leaves the record and is safe to persist
|
|
5784
|
+
// after. See reconcileAndPersistStatusPin for the ordering rationale.
|
|
5785
|
+
const action = decidePinAction(prev, desired)
|
|
5786
|
+
const op: StatusPinPersistOp =
|
|
5787
|
+
action.kind === 'pin'
|
|
5788
|
+
? { kind: 'pin', messageId: action.messageId }
|
|
5789
|
+
: { kind: 'clear' }
|
|
5790
|
+
|
|
5791
|
+
if (!statusPinPersistEnabled) {
|
|
5792
|
+
// Persistence off (STATIC / feature-off): just reconcile + update Maps.
|
|
5793
|
+
const next = await runReconcile()
|
|
5794
|
+
if (next == null) {
|
|
5795
|
+
statusPinState.delete(pinKey)
|
|
5796
|
+
statusPinChatIds.delete(pinKey)
|
|
5797
|
+
} else {
|
|
5798
|
+
statusPinState.set(pinKey, next)
|
|
5799
|
+
statusPinChatIds.set(pinKey, chatId)
|
|
5800
|
+
}
|
|
5801
|
+
return
|
|
5802
|
+
}
|
|
5803
|
+
|
|
5804
|
+
// Persist-BEFORE-pin ordering lives in reconcileAndPersistStatusPin: for a
|
|
5805
|
+
// pin it writes a `pending` record first, then confirms it after the API call
|
|
5806
|
+
// lands (or drops it on failure). A crash in the window leaves a pending
|
|
5807
|
+
// record boot cleanup will unpin. In-memory Maps are updated from the result.
|
|
5808
|
+
const next = await reconcileAndPersistStatusPin({
|
|
5809
|
+
path: STATUS_PIN_STORE_PATH,
|
|
5810
|
+
fs: statusPinStoreFs,
|
|
5811
|
+
pinKey,
|
|
5812
|
+
chatId,
|
|
5813
|
+
op,
|
|
5814
|
+
snapshotOthers: () => snapshotStatusPinsExcept(pinKey),
|
|
5815
|
+
applyPin: runReconcile,
|
|
5816
|
+
})
|
|
5817
|
+
if (next == null) {
|
|
5818
|
+
statusPinState.delete(pinKey)
|
|
5819
|
+
statusPinChatIds.delete(pinKey)
|
|
5820
|
+
} else {
|
|
5821
|
+
statusPinState.set(pinKey, next)
|
|
5822
|
+
statusPinChatIds.set(pinKey, chatId)
|
|
5823
|
+
}
|
|
5824
|
+
}
|
|
5825
|
+
|
|
5826
|
+
/**
|
|
5827
|
+
* Background-worker desired-pin, driven off the live `🛠 Worker` message.
|
|
5828
|
+
* Reads the worker feed's current message_id (the EXISTING message — we pin
|
|
5829
|
+
* what the feed already rendered, never a new send) and reconciles a silent
|
|
5830
|
+
* pin while it's running / an unpin on completion. No-op until the feed has
|
|
5831
|
+
* actually painted a message for this worker (trivial sub-second workers stay
|
|
5832
|
+
* silent and are never pinned). Keyed `wk:<agentId>`.
|
|
5833
|
+
*/
|
|
5834
|
+
function reconcileWorkerPin(
|
|
5835
|
+
agentId: string,
|
|
5836
|
+
chatId: string | null,
|
|
5837
|
+
running: boolean,
|
|
5838
|
+
): void {
|
|
5839
|
+
if (!PIN_STATUS_WHILE_WORKING) return
|
|
5840
|
+
const key = `wk:${agentId}`
|
|
5841
|
+
if (!running) {
|
|
5842
|
+
// Unpin: recover the chat we pinned in (caller may not have it at
|
|
5843
|
+
// completion). No-op when nothing was pinned for this worker.
|
|
5844
|
+
const unpinChat = chatId ?? statusPinChatIds.get(key)
|
|
5845
|
+
if (unpinChat == null) return
|
|
5846
|
+
void reconcileStatusPin(key, unpinChat, { pinned: false })
|
|
5847
|
+
return
|
|
5848
|
+
}
|
|
5849
|
+
if (chatId == null) return
|
|
5850
|
+
const messageId = workerActivityFeed?.messageIdOf(agentId) ?? null
|
|
5851
|
+
if (messageId == null) return // no message painted yet — nothing to pin
|
|
5852
|
+
void reconcileStatusPin(key, chatId, { pinned: true, messageId })
|
|
5853
|
+
}
|
|
5854
|
+
|
|
5855
|
+
/** Unpin every owned status pin — used by the pre-restart sweep so a
|
|
5856
|
+
* crash / interrupt never leaves a permanent pin behind. Best-effort;
|
|
5857
|
+
* clears the claim regardless of the unpin outcome (drop-on-unpin). */
|
|
5858
|
+
async function unpinAllStatusPins(): Promise<void> {
|
|
5859
|
+
const keys = [...statusPinState.keys()]
|
|
5860
|
+
for (const key of keys) {
|
|
5861
|
+
const st = statusPinState.get(key)
|
|
5862
|
+
if (st == null) continue
|
|
5863
|
+
// Recover the chat id from the state map's companion key registry.
|
|
5864
|
+
const chatId = statusPinChatIds.get(key)
|
|
5865
|
+
if (chatId == null) { statusPinState.delete(key); continue }
|
|
5866
|
+
await reconcileStatusPin(key, chatId, { pinned: false })
|
|
5867
|
+
}
|
|
5868
|
+
}
|
|
5869
|
+
|
|
5484
5870
|
// Activity feed. The gateway streams a live "what it's doing" tool-activity
|
|
5485
5871
|
// feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
|
|
5486
5872
|
// call (flush-independent, so it stays real-time on fast/clustered-tool
|
|
@@ -5628,6 +6014,12 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
5628
6014
|
process.stderr.write(
|
|
5629
6015
|
`telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS}\n`,
|
|
5630
6016
|
)
|
|
6017
|
+
// We WON the startup mutex — this gateway is the sole live owner of the
|
|
6018
|
+
// shared per-agent status-pin store, so it's now safe to clean up orphaned
|
|
6019
|
+
// pins from a prior (dead) session. Gated here (not at import time) so a
|
|
6020
|
+
// LOSING double-boot never unpins the live holder's legitimate pins.
|
|
6021
|
+
// Fire-and-forget: cleanup is best-effort and must not block boot.
|
|
6022
|
+
void statusPinBootCleanup()
|
|
5631
6023
|
} catch (err) {
|
|
5632
6024
|
process.stderr.write(
|
|
5633
6025
|
`telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
|
|
@@ -5639,6 +6031,11 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
5639
6031
|
try {
|
|
5640
6032
|
writePidFile(GATEWAY_PID_PATH, { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS })
|
|
5641
6033
|
process.stderr.write(`telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS} (mutex-fallback)\n`)
|
|
6034
|
+
// Mutex was unavailable (link() unsupported fs); the legacy pid-file
|
|
6035
|
+
// probe + 409-retry loop is still the liveness guard on this path. A
|
|
6036
|
+
// successful writePidFile here means no live holder was detected, so
|
|
6037
|
+
// running orphan cleanup is consistent with the pre-mutex behaviour.
|
|
6038
|
+
void statusPinBootCleanup()
|
|
5642
6039
|
} catch (writeErr) {
|
|
5643
6040
|
process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
|
|
5644
6041
|
}
|
|
@@ -7118,6 +7515,14 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7118
7515
|
const pend = pendingPermissions.get(requestId)
|
|
7119
7516
|
if (pend && sent && typeof sent.message_id === 'number') {
|
|
7120
7517
|
pend.cards.push({ chatId, messageId: sent.message_id })
|
|
7518
|
+
permCardStore.add({
|
|
7519
|
+
requestId,
|
|
7520
|
+
chatId,
|
|
7521
|
+
messageId: sent.message_id,
|
|
7522
|
+
startedAt: pend.startedAt,
|
|
7523
|
+
toolName: pend.tool_name,
|
|
7524
|
+
cardText: pend.card_text,
|
|
7525
|
+
})
|
|
7121
7526
|
}
|
|
7122
7527
|
}).catch(e => {
|
|
7123
7528
|
process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
|
|
@@ -7556,6 +7961,81 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7556
7961
|
})
|
|
7557
7962
|
},
|
|
7558
7963
|
|
|
7964
|
+
// #2726 — hostd-initiated rollout status message. Part 1 uses this only for
|
|
7965
|
+
// the terminal ping; Part 2 uses the same post as the FIRST narration
|
|
7966
|
+
// message, then EDITs it (onRolloutStatusEdit) as later phases arrive. This
|
|
7967
|
+
// is an ORDINARY operator-DM message, NOT a pinned card — the framework
|
|
7968
|
+
// speaking a plain progress line in the chat. We reply with the message_id so
|
|
7969
|
+
// hostd can edit it later; a post failure replies ok:false (hostd then just
|
|
7970
|
+
// won't edit — the durable audit log remains the record).
|
|
7971
|
+
async onRolloutStatusPost(client: IpcClient, msg: RolloutStatusPostMessage) {
|
|
7972
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
7973
|
+
if (self && msg.agentName !== self) {
|
|
7974
|
+
process.stderr.write(
|
|
7975
|
+
`telegram gateway: rollout_status_post rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
7976
|
+
)
|
|
7977
|
+
try {
|
|
7978
|
+
client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: 'agent mismatch' })
|
|
7979
|
+
} catch { /* best effort */ }
|
|
7980
|
+
return
|
|
7981
|
+
}
|
|
7982
|
+
const operator = loadAccess().allowFrom[0]
|
|
7983
|
+
if (operator === undefined) {
|
|
7984
|
+
process.stderr.write(`telegram gateway: rollout_status_post — no operator chat (allowFrom empty)\n`)
|
|
7985
|
+
try {
|
|
7986
|
+
client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: 'no operator chat' })
|
|
7987
|
+
} catch { /* best effort */ }
|
|
7988
|
+
return
|
|
7989
|
+
}
|
|
7990
|
+
try {
|
|
7991
|
+
const sent = await robustApiCall(
|
|
7992
|
+
() =>
|
|
7993
|
+
// allow-raw-bot-api: rich progress line, routed through robustApiCall.
|
|
7994
|
+
bot.api.sendRichMessage(operator, richMessage(msg.text), {}),
|
|
7995
|
+
{ chat_id: String(operator), verb: 'rollout-status-post' },
|
|
7996
|
+
)
|
|
7997
|
+
const messageId = (sent as { message_id: number }).message_id
|
|
7998
|
+
process.stderr.write(
|
|
7999
|
+
`telegram gateway: rollout_status_post agent=${msg.agentName} request=${msg.requestId} message_id=${messageId}\n`,
|
|
8000
|
+
)
|
|
8001
|
+
try {
|
|
8002
|
+
client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: true, messageId })
|
|
8003
|
+
} catch { /* best effort */ }
|
|
8004
|
+
} catch (err) {
|
|
8005
|
+
process.stderr.write(
|
|
8006
|
+
`telegram gateway: rollout_status_post send failed: ${(err as Error).message}\n`,
|
|
8007
|
+
)
|
|
8008
|
+
try {
|
|
8009
|
+
client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: (err as Error).message })
|
|
8010
|
+
} catch { /* best effort */ }
|
|
8011
|
+
}
|
|
8012
|
+
},
|
|
8013
|
+
|
|
8014
|
+
// #2726 Part 2 — edit the previously-posted rollout status message in place as
|
|
8015
|
+
// later phases arrive. Fire-and-forget: an edit failure (incl. Telegram 429)
|
|
8016
|
+
// is swallowed here and NEVER surfaced back toward the roll. hostd owns the
|
|
8017
|
+
// debounce + 429 retry cadence; this handler is a thin edit relay.
|
|
8018
|
+
onRolloutStatusEdit(_client: IpcClient, msg: RolloutStatusEditMessage) {
|
|
8019
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
8020
|
+
if (self && msg.agentName !== self) {
|
|
8021
|
+
process.stderr.write(
|
|
8022
|
+
`telegram gateway: rollout_status_edit rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
8023
|
+
)
|
|
8024
|
+
return
|
|
8025
|
+
}
|
|
8026
|
+
const operator = loadAccess().allowFrom[0]
|
|
8027
|
+
if (operator === undefined) {
|
|
8028
|
+
process.stderr.write(`telegram gateway: rollout_status_edit — no operator chat (allowFrom empty)\n`)
|
|
8029
|
+
return
|
|
8030
|
+
}
|
|
8031
|
+
void swallowingApiCall(
|
|
8032
|
+
() =>
|
|
8033
|
+
// allow-raw-bot-api: in-place edit of the ordinary status message.
|
|
8034
|
+
bot.api.editMessageText(operator, msg.messageId, richMessage(msg.text), {}),
|
|
8035
|
+
{ chat_id: String(operator), verb: 'rollout-status-edit' },
|
|
8036
|
+
)
|
|
8037
|
+
},
|
|
8038
|
+
|
|
7559
8039
|
onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
|
|
7560
8040
|
// Cron fires (incl. cheap-cron, whose session events are dropped before
|
|
7561
8041
|
// currentTurn is set) are real activity — re-arm idle auto-clear so a
|
|
@@ -8129,6 +8609,273 @@ function redactOutboundText(text: string, site: string): string {
|
|
|
8129
8609
|
return masked
|
|
8130
8610
|
}
|
|
8131
8611
|
|
|
8612
|
+
/** Default per-voice-note chunk size (chars). A long reply is split into
|
|
8613
|
+
* sequential voice notes of roughly this size on sentence/paragraph
|
|
8614
|
+
* boundaries. Only the OpenAI (cloud) engine still uses this — the kokoro
|
|
8615
|
+
* sidecar now takes the whole reply in one call and returns a single note. */
|
|
8616
|
+
const VOICE_OUT_DEFAULT_CHUNK_CHARS = 600
|
|
8617
|
+
|
|
8618
|
+
/** Hard ceiling for a single OpenAI TTS request. OpenAI's TTS input caps at
|
|
8619
|
+
* 4096 chars, so client-side chunks for that engine never exceed this; the
|
|
8620
|
+
* reply is spoken across sequential notes when it's longer. (The kokoro path
|
|
8621
|
+
* no longer chunks — the sidecar owns length and returns one file.) */
|
|
8622
|
+
const VOICE_OUT_HARD_CHUNK_CAP = 4096
|
|
8623
|
+
|
|
8624
|
+
/**
|
|
8625
|
+
* On-demand voice-out (reply_mode='on-demand'). Instead of synthesizing at
|
|
8626
|
+
* reply time, the gateway appends a single "🔊 Listen" inline button carrying
|
|
8627
|
+
* a reserved `voice:<token>` callback_data; audio is synthesized + sent only
|
|
8628
|
+
* when the user taps it. This keeps the voice pipeline subscription-honest and
|
|
8629
|
+
* visible — zero GPU/sidecar work happens behind the user's back.
|
|
8630
|
+
*
|
|
8631
|
+
* We cache the (speech-normalized text, voice, speed) needed to synthesize by
|
|
8632
|
+
* TOKEN, not message_id, because the message_id is not known at send time.
|
|
8633
|
+
* Bounded LRU with a TTL so a stale button (tapped an hour later) degrades to
|
|
8634
|
+
* a graceful "expired" toast rather than pinning reply text in memory forever.
|
|
8635
|
+
*/
|
|
8636
|
+
// Persist the Listen-token cache under STATE_DIR (= /state/agent/telegram in
|
|
8637
|
+
// prod, a volume that survives container recreation). Without this the Map was
|
|
8638
|
+
// wiped on every gateway restart, so a 🔊 Listen button tapped on a message
|
|
8639
|
+
// sent before the restart degraded to "Voice expired". The same TTL + LRU cap
|
|
8640
|
+
// still applies; expired entries are dropped on load.
|
|
8641
|
+
const voiceOnDemandCache = new VoiceOnDemandCache({
|
|
8642
|
+
persistPath: join(STATE_DIR, 'voice-ondemand.json'),
|
|
8643
|
+
})
|
|
8644
|
+
|
|
8645
|
+
// ─── Eager voice pre-synthesis (#2763) ─────────────────────────────────────
|
|
8646
|
+
// Whenever a reply becomes Listen-eligible (a voiceOnDemandCache entry is
|
|
8647
|
+
// persisted alongside the Listen button), ALSO synthesize the audio in the
|
|
8648
|
+
// background via the LOCAL sidecar and park it on disk, so the 🔊 Listen tap
|
|
8649
|
+
// attaches the pre-made file instantly instead of waiting on the GPU. Local
|
|
8650
|
+
// engine only (kokoro — zero token/dollar cost); the openai cloud engine
|
|
8651
|
+
// never pre-synthesizes. Strictly off the reply critical path: jobs drain
|
|
8652
|
+
// through a bounded FIFO (concurrency 1, drop-oldest past the cap) and every
|
|
8653
|
+
// failure is swallowed — the tap just falls back to the lazy synth path.
|
|
8654
|
+
// Kill switch: SWITCHROOM_DISABLE_EAGER_VOICE=1 (the sweep still runs so old
|
|
8655
|
+
// files age out).
|
|
8656
|
+
const VOICE_CACHE_DIR = join(STATE_DIR, 'voice-cache')
|
|
8657
|
+
|
|
8658
|
+
const voicePreSynthQueue = new PreSynthQueue({
|
|
8659
|
+
runJob: async (job) => {
|
|
8660
|
+
const sidecarToken = await materializeSidecarToken()
|
|
8661
|
+
if (!sidecarToken) return // sidecar unavailable — lazy path will retry on tap
|
|
8662
|
+
const result = await synthesizeViaSidecar({
|
|
8663
|
+
token: sidecarToken,
|
|
8664
|
+
// Same deterministic normalization the lazy Listen path applies —
|
|
8665
|
+
// job.text is normalizeForSpeech(reply); normalizeForTts is the #2760
|
|
8666
|
+
// L1 pass on top.
|
|
8667
|
+
text: normalizeForTts(job.text),
|
|
8668
|
+
voice: job.voice,
|
|
8669
|
+
speed: job.speed,
|
|
8670
|
+
})
|
|
8671
|
+
if (!result.ok) {
|
|
8672
|
+
process.stderr.write(
|
|
8673
|
+
`telegram gateway: voice-presynth: sidecar synthesis failed token=${job.token} reason=${result.reason}\n`,
|
|
8674
|
+
)
|
|
8675
|
+
return
|
|
8676
|
+
}
|
|
8677
|
+
const filePath = writeVoiceCacheFile(VOICE_CACHE_DIR, job.token, result.audio)
|
|
8678
|
+
voiceOnDemandCache.setFilePath(job.token, filePath)
|
|
8679
|
+
process.stderr.write(
|
|
8680
|
+
`telegram gateway: voice-presynth: cached ${result.audio.length} bytes at ${filePath} ` +
|
|
8681
|
+
`(${result.durationMs}ms, backlog=${voicePreSynthQueue.size})\n`,
|
|
8682
|
+
)
|
|
8683
|
+
},
|
|
8684
|
+
})
|
|
8685
|
+
|
|
8686
|
+
/** Rolling 7-day TTL + 500MB size-budget sweep over VOICE_CACHE_DIR. Runs at
|
|
8687
|
+
* boot and hourly; ALWAYS runs (even under the eager-voice kill switch) so
|
|
8688
|
+
* previously written files age out. Crash-safe: missing dir/files tolerated. */
|
|
8689
|
+
function sweepVoiceCache(): void {
|
|
8690
|
+
try {
|
|
8691
|
+
const result = sweepVoiceCacheDir({ dir: VOICE_CACHE_DIR })
|
|
8692
|
+
if (result.deletedTokens.length > 0) {
|
|
8693
|
+
voiceOnDemandCache.prune(result.deletedTokens)
|
|
8694
|
+
process.stderr.write(
|
|
8695
|
+
`telegram gateway: voice-presynth: sweep removed ${result.deletedTokens.length} file(s), ` +
|
|
8696
|
+
`${result.remainingBytes} bytes remain\n`,
|
|
8697
|
+
)
|
|
8698
|
+
}
|
|
8699
|
+
} catch (err) {
|
|
8700
|
+
// Defence in depth — sweepVoiceCacheDir is already per-file crash-safe.
|
|
8701
|
+
process.stderr.write(`telegram gateway: voice-presynth: sweep failed: ${String(err)}\n`)
|
|
8702
|
+
}
|
|
8703
|
+
}
|
|
8704
|
+
|
|
8705
|
+
if (!STATIC) {
|
|
8706
|
+
sweepVoiceCache() // boot sweep — covers a died timer / long downtime
|
|
8707
|
+
setInterval(sweepVoiceCache, VOICE_SWEEP_INTERVAL_MS).unref()
|
|
8708
|
+
}
|
|
8709
|
+
|
|
8710
|
+
type VoiceOutAccess = NonNullable<Access['voice_out']>
|
|
8711
|
+
|
|
8712
|
+
/**
|
|
8713
|
+
* Resolve whether outbound TTS is active for this reply, and how
|
|
8714
|
+
* (engine + reply_mode + the plain-text TTS chunks). PR-C2.
|
|
8715
|
+
*
|
|
8716
|
+
* Mirrors the voice-IN engine-resolution precedence (message:voice
|
|
8717
|
+
* handler): the compose-injected SWITCHROOM_VOICE_ENGINE verdict gates the
|
|
8718
|
+
* local 'kokoro' engine, exactly like the STT sidecar. The 'openai' engine
|
|
8719
|
+
* is an honest-exception cloud path gated on an api_key vault ref — it does
|
|
8720
|
+
* NOT require a local verdict.
|
|
8721
|
+
*
|
|
8722
|
+
* Returns null when voice-out is off / not applicable; otherwise a plan the
|
|
8723
|
+
* caller acts on. `ttsChunks` is speech-normalized plain text (markdown and
|
|
8724
|
+
* TTS-mispronounced symbols removed — see normalizeForSpeech).
|
|
8725
|
+
*
|
|
8726
|
+
* ONE voice note per response: the kokoro (local sidecar) path now sends the
|
|
8727
|
+
* WHOLE normalized reply to `/tts` in a SINGLE call — the sidecar chunks +
|
|
8728
|
+
* concatenates internally on the GPU and returns a single ogg/opus file — so
|
|
8729
|
+
* `ttsChunks` is a single element for kokoro. `max_chars` is no longer a hard
|
|
8730
|
+
* client-side splitter for kokoro; it's retired for that path (the sidecar
|
|
8731
|
+
* owns length). The openai (cloud) engine keeps client-side chunking because
|
|
8732
|
+
* OpenAI's TTS input has its own hard cap; each chunk is one voice note.
|
|
8733
|
+
*/
|
|
8734
|
+
function resolveVoiceOutPlan(
|
|
8735
|
+
voiceOut: VoiceOutAccess | undefined,
|
|
8736
|
+
replyText: string,
|
|
8737
|
+
): {
|
|
8738
|
+
engine: 'kokoro' | 'openai'
|
|
8739
|
+
voice?: string
|
|
8740
|
+
speed: number
|
|
8741
|
+
apiKeyRef?: string
|
|
8742
|
+
replyMode: 'voice+text' | 'voice-only' | 'on-demand'
|
|
8743
|
+
ttsChunks: string[]
|
|
8744
|
+
} | null {
|
|
8745
|
+
if (voiceOut?.enabled !== true) return null
|
|
8746
|
+
|
|
8747
|
+
const engine = voiceOut.engine ?? 'kokoro'
|
|
8748
|
+
|
|
8749
|
+
// Engine gating mirrors the voice-in handler precedence: the
|
|
8750
|
+
// compose-injected env wins, then the persisted host-capabilities file,
|
|
8751
|
+
// then a fail-safe 'cloud'. The in-container ~/.switchroom is a read-only
|
|
8752
|
+
// view that does NOT carry host-capabilities.json, so the env is
|
|
8753
|
+
// load-bearing in-fleet (see message:voice). Kokoro requires 'local';
|
|
8754
|
+
// openai requires a resolvable api_key.
|
|
8755
|
+
if (engine === 'kokoro') {
|
|
8756
|
+
const envVoiceEngine = process.env.SWITCHROOM_VOICE_ENGINE
|
|
8757
|
+
const voiceEngine: VoiceEngine =
|
|
8758
|
+
envVoiceEngine === 'local' || envVoiceEngine === 'cloud'
|
|
8759
|
+
? envVoiceEngine
|
|
8760
|
+
: loadHostCapabilities()?.voice.engine ?? 'cloud'
|
|
8761
|
+
if (voiceEngine !== 'local') return null
|
|
8762
|
+
}
|
|
8763
|
+
|
|
8764
|
+
// Speech-normalized TTS input: strip markdown AND translate/drop the
|
|
8765
|
+
// symbols TTS mispronounces (~, backticks, arrows, tables, code fences,
|
|
8766
|
+
// link URLs) REGARDLESS of mode so the engine reads clean prose. Applied
|
|
8767
|
+
// for BOTH engines — the older partial stripMarkdown pass leaked `~`,
|
|
8768
|
+
// code fences and arrows.
|
|
8769
|
+
const ttsText = normalizeForSpeech(replyText)
|
|
8770
|
+
if (ttsText.length === 0) return null
|
|
8771
|
+
|
|
8772
|
+
// ONE voice note for kokoro: the sidecar accepts arbitrarily long text and
|
|
8773
|
+
// returns a single concatenated ogg/opus file, so we send the whole reply
|
|
8774
|
+
// in one call — no client-side splitting, no multi-sendVoice. `max_chars`
|
|
8775
|
+
// is retired as a hard splitter here.
|
|
8776
|
+
//
|
|
8777
|
+
// OpenAI still needs client-side chunking (its TTS input has a hard cap),
|
|
8778
|
+
// so for that engine we split at the engine cap; each chunk is one note.
|
|
8779
|
+
let ttsChunks: string[]
|
|
8780
|
+
if (engine === 'kokoro') {
|
|
8781
|
+
ttsChunks = [ttsText]
|
|
8782
|
+
} else {
|
|
8783
|
+
const chunkChars = Math.min(
|
|
8784
|
+
voiceOut.max_chars ?? VOICE_OUT_DEFAULT_CHUNK_CHARS,
|
|
8785
|
+
VOICE_OUT_HARD_CHUNK_CAP,
|
|
8786
|
+
)
|
|
8787
|
+
ttsChunks = chunkTtsText(ttsText, chunkChars)
|
|
8788
|
+
}
|
|
8789
|
+
if (ttsChunks.length === 0) return null
|
|
8790
|
+
|
|
8791
|
+
// Kokoro playback speed: default 1.1 (fleet default) when unset, clamped to
|
|
8792
|
+
// 0.5–2.0. Threaded into the /tts body by synthesizeVoiceOut. The OpenAI
|
|
8793
|
+
// path ignores it.
|
|
8794
|
+
const speed = clampTtsSpeed(voiceOut.speed, VOICE_OUT_DEFAULT_SPEED)
|
|
8795
|
+
|
|
8796
|
+
return {
|
|
8797
|
+
engine,
|
|
8798
|
+
voice: voiceOut.voice,
|
|
8799
|
+
speed,
|
|
8800
|
+
apiKeyRef: voiceOut.api_key,
|
|
8801
|
+
replyMode: voiceOut.reply_mode ?? 'voice+text',
|
|
8802
|
+
ttsChunks,
|
|
8803
|
+
}
|
|
8804
|
+
}
|
|
8805
|
+
|
|
8806
|
+
/**
|
|
8807
|
+
* Synthesize `ttsText` into OGG/Opus bytes via the configured engine.
|
|
8808
|
+
* Best-effort: returns null on ANY failure (logged to stderr), never
|
|
8809
|
+
* throws. The caller treats null as "voice unavailable, fall back to
|
|
8810
|
+
* text". PR-C2.
|
|
8811
|
+
*/
|
|
8812
|
+
async function synthesizeVoiceOut(plan: {
|
|
8813
|
+
engine: 'kokoro' | 'openai'
|
|
8814
|
+
voice?: string
|
|
8815
|
+
speed?: number
|
|
8816
|
+
apiKeyRef?: string
|
|
8817
|
+
ttsText: string
|
|
8818
|
+
}): Promise<Uint8Array | null> {
|
|
8819
|
+
try {
|
|
8820
|
+
// #2760 Phase 1: deterministic L1 TTS normalization, applied at the
|
|
8821
|
+
// single choke point where every engine's request body is built.
|
|
8822
|
+
// On by default; kill switch: SWITCHROOM_DISABLE_TTS_NORMALIZE=1.
|
|
8823
|
+
const ttsText = normalizeForTts(plan.ttsText)
|
|
8824
|
+
if (plan.engine === 'kokoro') {
|
|
8825
|
+
const token = await materializeSidecarToken()
|
|
8826
|
+
if (!token) return null // materializeSidecarToken already logged why.
|
|
8827
|
+
const result = await synthesizeViaSidecar({
|
|
8828
|
+
token,
|
|
8829
|
+
text: ttsText,
|
|
8830
|
+
voice: plan.voice,
|
|
8831
|
+
speed: plan.speed,
|
|
8832
|
+
})
|
|
8833
|
+
if (!result.ok) {
|
|
8834
|
+
process.stderr.write(
|
|
8835
|
+
`telegram gateway: voice-out: local sidecar synthesis failed reason=${result.reason}` +
|
|
8836
|
+
(result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
|
|
8837
|
+
'\n',
|
|
8838
|
+
)
|
|
8839
|
+
return null
|
|
8840
|
+
}
|
|
8841
|
+
process.stderr.write(
|
|
8842
|
+
`telegram gateway: voice-out: local sidecar synthesized ${result.audio.length} bytes in ${result.durationMs}ms ` +
|
|
8843
|
+
`voice=${result.voice ?? '?'} audio_s=${result.audioSeconds ?? '?'} chars=${plan.ttsText.length}\n`,
|
|
8844
|
+
)
|
|
8845
|
+
return result.audio
|
|
8846
|
+
}
|
|
8847
|
+
|
|
8848
|
+
// engine === 'openai' — honest-exception cloud path.
|
|
8849
|
+
const apiKey = await materializeVoiceKey({ apiKeyRef: plan.apiKeyRef })
|
|
8850
|
+
if (!apiKey) return null // materializeVoiceKey already logged why.
|
|
8851
|
+
const result = await synthesizeViaOpenAi({
|
|
8852
|
+
apiKey,
|
|
8853
|
+
text: ttsText,
|
|
8854
|
+
voice: plan.voice,
|
|
8855
|
+
})
|
|
8856
|
+
if (!result.ok) {
|
|
8857
|
+
process.stderr.write(
|
|
8858
|
+
`telegram gateway: voice-out: openai synthesis failed reason=${result.reason}` +
|
|
8859
|
+
(result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
|
|
8860
|
+
'\n',
|
|
8861
|
+
)
|
|
8862
|
+
return null
|
|
8863
|
+
}
|
|
8864
|
+
process.stderr.write(
|
|
8865
|
+
`telegram gateway: voice-out: openai synthesized ${result.audio.length} bytes in ${result.durationMs}ms ` +
|
|
8866
|
+
`voice=${result.voice} chars=${plan.ttsText.length}\n`,
|
|
8867
|
+
)
|
|
8868
|
+
return result.audio
|
|
8869
|
+
} catch (err) {
|
|
8870
|
+
// Defence in depth: the synth helpers never throw, but the vault
|
|
8871
|
+
// materialize calls might. Voice is best-effort — swallow + fall back.
|
|
8872
|
+
process.stderr.write(
|
|
8873
|
+
`telegram gateway: voice-out: synthesis threw (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`,
|
|
8874
|
+
)
|
|
8875
|
+
return null
|
|
8876
|
+
}
|
|
8877
|
+
}
|
|
8878
|
+
|
|
8132
8879
|
async function executeReply(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
8133
8880
|
// #1664 — pin the turn this reply belongs to at entry. The
|
|
8134
8881
|
// finalAnswerDelivered write near the end of this function runs after
|
|
@@ -8149,18 +8896,20 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8149
8896
|
// null because clearTurnStarted fired ≥5 min of model silence, but the model
|
|
8150
8897
|
// finally called reply after the poke).
|
|
8151
8898
|
const chat_id = (() => {
|
|
8152
|
-
const
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8899
|
+
const resolved = resolveChatIdFallback(
|
|
8900
|
+
_rawChatId,
|
|
8901
|
+
loadAccess(),
|
|
8902
|
+
turn?.sessionChatId,
|
|
8903
|
+
lastActiveTurnChatId,
|
|
8904
|
+
turn != null,
|
|
8905
|
+
)
|
|
8906
|
+
if (resolved.tier !== 'raw') {
|
|
8157
8907
|
process.stderr.write(
|
|
8158
8908
|
`telegram gateway: reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
|
|
8159
|
-
`routing to ${tier} turn chat "${
|
|
8909
|
+
`routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
|
|
8160
8910
|
)
|
|
8161
|
-
return fallbackChatId
|
|
8162
8911
|
}
|
|
8163
|
-
return
|
|
8912
|
+
return resolved.chatId // raw tier → let assertAllowedChat throw the human-readable error
|
|
8164
8913
|
})()
|
|
8165
8914
|
const rawText = args.text as string | undefined
|
|
8166
8915
|
if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
|
|
@@ -8171,8 +8920,16 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8171
8920
|
// Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
|
|
8172
8921
|
// the stderr preview below, the dedup key, the send, and the history
|
|
8173
8922
|
// record. Mutates `text` so every downstream consumer sees the masked
|
|
8174
|
-
// value, exactly like the voice scrub that follows.
|
|
8923
|
+
// value, exactly like the voice scrub that follows. Runs BEFORE the
|
|
8924
|
+
// punctuation/bold normalizers so a secret containing an em-dash or `**`
|
|
8925
|
+
// is matched literally by the redactor before any mutation.
|
|
8175
8926
|
text = redactOutboundText(text, 'reply')
|
|
8927
|
+
// Fleet-wide consistent formatting: normalize dashes/bullets and trip the
|
|
8928
|
+
// over-bold guard deterministically, on code-masked text (same contract on
|
|
8929
|
+
// reply / edit / stream paths). Runs before scrubVoice below so dashes get
|
|
8930
|
+
// the comma treatment on every path; scrubVoice's dash telemetry
|
|
8931
|
+
// (voice_scrub_applied) drops to ~zero here as a result — deliberate.
|
|
8932
|
+
text = stripExcessBold(normalizePunctuation(text))
|
|
8176
8933
|
// Voice scrub (#1683): replace em / en dashes with commas / periods.
|
|
8177
8934
|
// Runs BEFORE outboundDedup so retries see the scrubbed key, and on the
|
|
8178
8935
|
// raw markdown text (the scrubber does its own code-region parking so
|
|
@@ -8233,6 +8990,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8233
8990
|
const protectContent = args.protect_content === true
|
|
8234
8991
|
const quoteText = args.quote_text as string | undefined
|
|
8235
8992
|
const access = loadAccess()
|
|
8993
|
+
// Outbound TTS plan (PR-C2). Resolved once here (engine gating + mode +
|
|
8994
|
+
// plain-text TTS input); synthesis happens just before the send so a
|
|
8995
|
+
// voice-only reply can suppress the text chunk loop on success. Voice is
|
|
8996
|
+
// fully best-effort — every failure below falls back to the text reply.
|
|
8997
|
+
const voiceOutPlan = resolveVoiceOutPlan(access.voice_out, text)
|
|
8236
8998
|
const configParseMode = access.parseMode ?? 'html'
|
|
8237
8999
|
const format = (args.format as string | undefined) ?? configParseMode
|
|
8238
9000
|
const disableLinkPreview = args.disable_web_page_preview != null
|
|
@@ -8400,7 +9162,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8400
9162
|
// ships the raw GFM markdown via `sendRichMessage`. `effectiveText` is the
|
|
8401
9163
|
// raw text either way (no HTML/MarkdownV2 rendering happens here anymore).
|
|
8402
9164
|
const literalText = format === 'text'
|
|
8403
|
-
|
|
9165
|
+
// Paragraph-spacing fix (rich-message regression after #2669). The rich GFM
|
|
9166
|
+
// renderer collapses a `\n\n` gap TIGHT, so multi-paragraph replies render
|
|
9167
|
+
// jammed together — unlike the old HTML path. Inject a visible blank-line
|
|
9168
|
+
// spacer into prose `\n\n` gaps on the rich path only. The literal
|
|
9169
|
+
// (`format:'text'`) path must stay byte-exact, so it is left untouched.
|
|
9170
|
+
const effectiveText: string = literalText ? text : addParagraphSpacers(text)
|
|
8404
9171
|
|
|
8405
9172
|
assertAllowedChat(chat_id)
|
|
8406
9173
|
|
|
@@ -8469,6 +9236,60 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8469
9236
|
: splitMarkdownChunks(effectiveText, limit)
|
|
8470
9237
|
const sentIds: number[] = []
|
|
8471
9238
|
|
|
9239
|
+
// Outbound TTS synthesis (PR-C2). Done BEFORE the text send so a
|
|
9240
|
+
// voice-only reply can suppress the text chunks on success. ONE voice note
|
|
9241
|
+
// per response for the kokoro path (ttsChunks is a single element — the
|
|
9242
|
+
// whole normalized reply — synthesized in one /tts call). The OpenAI path
|
|
9243
|
+
// may still produce several ordered notes (one per chunk) because of its
|
|
9244
|
+
// input cap. Ken is often on a bike/driving and can't read the screen, so
|
|
9245
|
+
// the full answer must be SPOKEN. Best-effort: a chunk that fails to
|
|
9246
|
+
// synthesize is skipped; if NOTHING synthesizes the text path proceeds
|
|
9247
|
+
// unchanged so the answer is never dropped.
|
|
9248
|
+
// on-demand is a LOCAL-engine (kokoro) feature only: the tap handler
|
|
9249
|
+
// synthesizes via the local sidecar, so a Listen button is only meaningful
|
|
9250
|
+
// when the resolved engine is kokoro. resolveVoiceOutPlan already gated the
|
|
9251
|
+
// local host verdict for kokoro, so engine==='kokoro' here implies the
|
|
9252
|
+
// sidecar is available. For engine==='openai' + reply_mode='on-demand' we do
|
|
9253
|
+
// NOT inject a button (its taps would dead-end on the local sidecar) — we
|
|
9254
|
+
// fall through to the normal immediate-synth path so the openai reply behaves
|
|
9255
|
+
// exactly like a normal openai voice reply.
|
|
9256
|
+
const useOnDemandButton =
|
|
9257
|
+
voiceOutPlan != null &&
|
|
9258
|
+
voiceOutPlan.replyMode === 'on-demand' &&
|
|
9259
|
+
voiceOutPlan.engine === 'kokoro'
|
|
9260
|
+
|
|
9261
|
+
const voiceOggs: Uint8Array[] = []
|
|
9262
|
+
// Skip reply-time synthesis ONLY when we're actually deferring to a Listen
|
|
9263
|
+
// button (kokoro on-demand). An openai on-demand config still synthesizes
|
|
9264
|
+
// immediately below.
|
|
9265
|
+
if (voiceOutPlan != null && !useOnDemandButton) {
|
|
9266
|
+
for (const chunkText of voiceOutPlan.ttsChunks) {
|
|
9267
|
+
const ogg = await synthesizeVoiceOut({
|
|
9268
|
+
engine: voiceOutPlan.engine,
|
|
9269
|
+
voice: voiceOutPlan.voice,
|
|
9270
|
+
speed: voiceOutPlan.speed,
|
|
9271
|
+
apiKeyRef: voiceOutPlan.apiKeyRef,
|
|
9272
|
+
ttsText: chunkText,
|
|
9273
|
+
})
|
|
9274
|
+
// Skip a failed chunk but keep going — a partial spoken answer still
|
|
9275
|
+
// beats silence; full-fail (no oggs at all) falls back to text below.
|
|
9276
|
+
if (ogg != null) voiceOggs.push(ogg)
|
|
9277
|
+
}
|
|
9278
|
+
if (voiceOggs.length < voiceOutPlan.ttsChunks.length) {
|
|
9279
|
+
process.stderr.write(
|
|
9280
|
+
`telegram gateway: voice-out: synthesized ${voiceOggs.length}/${voiceOutPlan.ttsChunks.length} voice-note chunk(s)\n`,
|
|
9281
|
+
)
|
|
9282
|
+
}
|
|
9283
|
+
}
|
|
9284
|
+
// Suppress the text body ONLY when voice-only AND we synthesized the FULL
|
|
9285
|
+
// set of chunks (every part of the answer is spoken). A partial or total
|
|
9286
|
+
// synthesis failure leaves the text path running so the answer still
|
|
9287
|
+
// lands in full — never drop the user's answer silently.
|
|
9288
|
+
const suppressText =
|
|
9289
|
+
voiceOutPlan?.replyMode === 'voice-only' &&
|
|
9290
|
+
voiceOutPlan.ttsChunks.length > 0 &&
|
|
9291
|
+
voiceOggs.length === voiceOutPlan.ttsChunks.length
|
|
9292
|
+
|
|
8472
9293
|
// #271: validate inline_keyboard and namespace any callback_data with
|
|
8473
9294
|
// the `agent:` prefix so the gateway's callback_query dispatcher can
|
|
8474
9295
|
// round-trip taps back to this agent without colliding with
|
|
@@ -8490,6 +9311,66 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8490
9311
|
replyMarkup = { inline_keyboard: wrapAgentCallbacks(rawKeyboard) }
|
|
8491
9312
|
}
|
|
8492
9313
|
|
|
9314
|
+
// on-demand voice: append a single '🔊 Listen' button that synthesizes the
|
|
9315
|
+
// spoken reply only when tapped. The button carries a RAW `voice:<token>`
|
|
9316
|
+
// callback_data (NOT wrapped with the agent: prefix) so the dispatcher
|
|
9317
|
+
// handles it internally and never routes it to the agent as an inbound.
|
|
9318
|
+
//
|
|
9319
|
+
// Collision gate: inject ONLY when the reply carries no agent-authored
|
|
9320
|
+
// buttons. If the agent supplied its own keyboard, the callback dispatcher's
|
|
9321
|
+
// single_use strip (keyboardIsSingleUse) governs that whole message; adding
|
|
9322
|
+
// a foreign single_use:false button would flip that message to a mixed
|
|
9323
|
+
// keyboard and defeat the agent's double-fire protection. Keep it simple —
|
|
9324
|
+
// agent buttons present → skip the Listen button for this message.
|
|
9325
|
+
//
|
|
9326
|
+
// useOnDemandButton already gates on engine==='kokoro': an openai on-demand
|
|
9327
|
+
// config never reaches here (it synthesized immediately above), so a Listen
|
|
9328
|
+
// button is never minted for an engine whose taps would dead-end on the
|
|
9329
|
+
// local sidecar.
|
|
9330
|
+
if (
|
|
9331
|
+
useOnDemandButton &&
|
|
9332
|
+
voiceOutPlan!.ttsChunks.length > 0 &&
|
|
9333
|
+
voiceOutPlan!.ttsChunks[0]!.length > 0
|
|
9334
|
+
) {
|
|
9335
|
+
if (!mayInjectListenButton(rawKeyboard)) {
|
|
9336
|
+
process.stderr.write(
|
|
9337
|
+
'telegram gateway: voice-out on-demand: agent supplied inline_keyboard — skipping Listen button (single_use collision gate)\n',
|
|
9338
|
+
)
|
|
9339
|
+
} else {
|
|
9340
|
+
// Token is intentionally GLOBAL (not chat-keyed): under the single-tenant
|
|
9341
|
+
// invariant the operator is the only authorized sender across all chats,
|
|
9342
|
+
// and the tap handler re-checks access.allowFrom before synthesizing, so
|
|
9343
|
+
// a token needs no per-chat scoping to be safe.
|
|
9344
|
+
const token = mintVoiceOnDemandToken()
|
|
9345
|
+
voiceOnDemandCache.put(token, {
|
|
9346
|
+
// ttsChunks[0] is already normalizeForSpeech(reply) (kokoro path).
|
|
9347
|
+
text: voiceOutPlan.ttsChunks[0]!,
|
|
9348
|
+
...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
|
|
9349
|
+
speed: voiceOutPlan.speed,
|
|
9350
|
+
})
|
|
9351
|
+
// The keyboard stays after the tap (never stripped) so it can be
|
|
9352
|
+
// replayed. The gate above guarantees this is the ONLY button on the
|
|
9353
|
+
// message, so keeping it is safe (no agent buttons to protect).
|
|
9354
|
+
replyMarkup = buildListenKeyboard(token)
|
|
9355
|
+
// #2763 eager pre-synthesis: kick a background synth of the same
|
|
9356
|
+
// payload so the Listen tap attaches the pre-made file instantly.
|
|
9357
|
+
// Local-engine only (useOnDemandButton already gates engine==='kokoro'
|
|
9358
|
+
// — the cloud engine never eager-synthesizes). enqueue() is a
|
|
9359
|
+
// synchronous array push; the drain is deferred + async and never
|
|
9360
|
+
// awaited here, so the text sends below are never delayed and a synth
|
|
9361
|
+
// failure can never affect message delivery (the tap just falls back
|
|
9362
|
+
// to the lazy path).
|
|
9363
|
+
if (eagerVoiceEnabled()) {
|
|
9364
|
+
voicePreSynthQueue.enqueue({
|
|
9365
|
+
token,
|
|
9366
|
+
text: voiceOutPlan.ttsChunks[0]!,
|
|
9367
|
+
...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
|
|
9368
|
+
speed: voiceOutPlan.speed,
|
|
9369
|
+
})
|
|
9370
|
+
}
|
|
9371
|
+
}
|
|
9372
|
+
}
|
|
9373
|
+
|
|
8493
9374
|
const replySKey = streamKey(chat_id, threadId)
|
|
8494
9375
|
suppressPtyPreview.add(replySKey)
|
|
8495
9376
|
let previewMessageId: number | null = null
|
|
@@ -8739,6 +9620,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8739
9620
|
|
|
8740
9621
|
try {
|
|
8741
9622
|
for (let i = 0; i < chunks.length; i++) {
|
|
9623
|
+
// PR-C2: voice-only mode with a successful synthesis suppresses the
|
|
9624
|
+
// text body — the spoken voice note IS the reply. Bail before the
|
|
9625
|
+
// first chunk send (sentIds stays empty for text); the voice send
|
|
9626
|
+
// below lands the answer. Any other mode (voice+text, or voice-only
|
|
9627
|
+
// that fell back) sends the text chunks as normal.
|
|
9628
|
+
if (suppressText) break
|
|
8742
9629
|
const shouldReplyTo =
|
|
8743
9630
|
reply_to != null && replyMode !== 'off' && (replyMode === 'all' || i === 0)
|
|
8744
9631
|
const isLastChunk = i === chunks.length - 1
|
|
@@ -8894,6 +9781,76 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8894
9781
|
stopTypingLoop(chat_id, threadId ?? null)
|
|
8895
9782
|
}
|
|
8896
9783
|
|
|
9784
|
+
// Outbound voice notes (PR-C2). Sent IN ORDER, AFTER the text body
|
|
9785
|
+
// (voice+text) or INSTEAD of it (voice-only, where suppressText skipped
|
|
9786
|
+
// the chunk loop). A long reply produces SEVERAL notes — each spoken in
|
|
9787
|
+
// sequence so the whole answer is heard, never truncated. Best-effort and
|
|
9788
|
+
// fully non-fatal: a sendVoice failure must NEVER break the text reply
|
|
9789
|
+
// path. In voice-only mode a failure would leave the user with an
|
|
9790
|
+
// incomplete spoken answer, so on the FIRST send failure we recover by
|
|
9791
|
+
// sending the full text once and stop sending further notes.
|
|
9792
|
+
for (let v = 0; v < voiceOggs.length; v++) {
|
|
9793
|
+
const oggBytes = voiceOggs[v]!
|
|
9794
|
+
const voiceOpts: Record<string, unknown> = {
|
|
9795
|
+
// Quote the user's message only on the FIRST voice note (mirrors the
|
|
9796
|
+
// text chunk loop's first-chunk reply behaviour).
|
|
9797
|
+
...(v === 0 && reply_to != null && replyMode !== 'off'
|
|
9798
|
+
? { reply_parameters: { message_id: reply_to } }
|
|
9799
|
+
: {}),
|
|
9800
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
9801
|
+
...(disableNotification ? { disable_notification: true } : {}),
|
|
9802
|
+
}
|
|
9803
|
+
try {
|
|
9804
|
+
const sentVoice = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
|
|
9805
|
+
robustApiCall,
|
|
9806
|
+
(tid) => {
|
|
9807
|
+
const opts = { ...voiceOpts }
|
|
9808
|
+
if (tid != null) opts.message_thread_id = tid
|
|
9809
|
+
else delete opts.message_thread_id
|
|
9810
|
+
// allow-raw-bot-api: adapter callback INSIDE retryWithThreadFallback→robustApiCall; the THREAD_NOT_FOUND fallback is handled by the wrapper.
|
|
9811
|
+
return lockedBot.api.sendVoice(chat_id, new InputFile(Buffer.from(oggBytes)), opts as never)
|
|
9812
|
+
},
|
|
9813
|
+
{ threadId, chat_id, verb: 'sendVoice' },
|
|
9814
|
+
)
|
|
9815
|
+
sentIds.push(sentVoice.message_id)
|
|
9816
|
+
logOutbound(
|
|
9817
|
+
'reply',
|
|
9818
|
+
chat_id,
|
|
9819
|
+
sentVoice.message_id,
|
|
9820
|
+
voiceOutPlan?.ttsChunks[v]?.length ?? 0,
|
|
9821
|
+
`voice-note=${v + 1}/${voiceOggs.length}`,
|
|
9822
|
+
)
|
|
9823
|
+
} catch (err) {
|
|
9824
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
9825
|
+
process.stderr.write(
|
|
9826
|
+
`telegram gateway: voice-out: sendVoice ${v + 1}/${voiceOggs.length} failed (non-fatal): ${msg}\n`,
|
|
9827
|
+
)
|
|
9828
|
+
// voice-only fell over mid-stream with text suppressed → recover by
|
|
9829
|
+
// sending the FULL text body once so the answer still lands, then
|
|
9830
|
+
// stop sending further notes (the text now carries everything).
|
|
9831
|
+
if (suppressText) {
|
|
9832
|
+
try {
|
|
9833
|
+
const opts: Record<string, unknown> = {
|
|
9834
|
+
...(reply_to != null && replyMode !== 'off'
|
|
9835
|
+
? { reply_parameters: { message_id: reply_to } }
|
|
9836
|
+
: {}),
|
|
9837
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
9838
|
+
...(disableNotification ? { disable_notification: true } : {}),
|
|
9839
|
+
}
|
|
9840
|
+
// allow-raw-bot-api: voice-only recovery fallback — the rich path already ran/was skipped; send the source text plainly so the answer is never lost.
|
|
9841
|
+
const sent = await lockedBot.api.sendMessage(chat_id, effectiveText, opts as never)
|
|
9842
|
+
sentIds.push(sent.message_id)
|
|
9843
|
+
logOutbound('reply', chat_id, sent.message_id, effectiveText.length, 'voice-only-text-recovery')
|
|
9844
|
+
} catch (textErr) {
|
|
9845
|
+
process.stderr.write(
|
|
9846
|
+
`telegram gateway: voice-out: voice-only text recovery ALSO failed: ${textErr instanceof Error ? textErr.message : String(textErr)}\n`,
|
|
9847
|
+
)
|
|
9848
|
+
}
|
|
9849
|
+
break
|
|
9850
|
+
}
|
|
9851
|
+
}
|
|
9852
|
+
}
|
|
9853
|
+
|
|
8897
9854
|
// #710: remember per-button agent meta (ack_text / single_use) keyed
|
|
8898
9855
|
// by the message that actually carries the keyboard — that's the last
|
|
8899
9856
|
// text chunk, since the keyboard is attached only on isLastChunk.
|
|
@@ -9154,6 +10111,31 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
9154
10111
|
const turn = currentTurn
|
|
9155
10112
|
if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
|
|
9156
10113
|
if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
|
|
10114
|
+
// chat_id allowlist fallback — mirrors executeReply (~8725). stream_reply is
|
|
10115
|
+
// the primary final-answer path, so a wrong/late chat_id (int/string mismatch,
|
|
10116
|
+
// or the model echoing the wrong identifier after a silence poke flipped
|
|
10117
|
+
// currentTurn to null) would otherwise fail assertAllowedChat in the stream
|
|
10118
|
+
// controller → an invisible reply. Rewrite args.chat_id in place so every
|
|
10119
|
+
// downstream consumer (origin resolution, dedup key, the send) sees the
|
|
10120
|
+
// corrected value. Tier 1: live turn's sessionChatId. Tier 2: last-known
|
|
10121
|
+
// turn's chat (survives silence poke — Bug D fix; currentTurn is null).
|
|
10122
|
+
{
|
|
10123
|
+
const _rawChatId = String(args.chat_id ?? '')
|
|
10124
|
+
const resolved = resolveChatIdFallback(
|
|
10125
|
+
_rawChatId,
|
|
10126
|
+
loadAccess(),
|
|
10127
|
+
turn?.sessionChatId,
|
|
10128
|
+
lastActiveTurnChatId,
|
|
10129
|
+
turn != null,
|
|
10130
|
+
)
|
|
10131
|
+
if (resolved.tier !== 'raw') {
|
|
10132
|
+
process.stderr.write(
|
|
10133
|
+
`telegram gateway: stream_reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
|
|
10134
|
+
`routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
|
|
10135
|
+
)
|
|
10136
|
+
args.chat_id = resolved.chatId
|
|
10137
|
+
}
|
|
10138
|
+
}
|
|
9157
10139
|
// Thread precedence (matches executeReply; component 3 — turn-origin
|
|
9158
10140
|
// routing): when the model passes no explicit message_thread_id, inject
|
|
9159
10141
|
// the ORIGIN turn's thread (matched by origin_turn_id) — authoritative
|
|
@@ -9222,6 +10204,16 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
9222
10204
|
// controller, dedup record, history record) see the scrubbed
|
|
9223
10205
|
// version. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
9224
10206
|
{
|
|
10207
|
+
// Cross-path consistency (#2755): normalizePunctuation runs BEFORE
|
|
10208
|
+
// scrubVoice here, matching the reply/edit paths, so a spaced em-dash
|
|
10209
|
+
// gets the same comma treatment on every outbound path (scrubVoice
|
|
10210
|
+
// would otherwise see the raw dash first on this path only and apply
|
|
10211
|
+
// its period substitution). handleStreamReply's own deps run
|
|
10212
|
+
// normalizePunctuation again downstream — it is idempotent, so the
|
|
10213
|
+
// second pass is a no-op. Side effect: scrubVoice's dash telemetry
|
|
10214
|
+
// (voice_scrub_applied) drops to ~zero on this path since the dashes
|
|
10215
|
+
// are consumed here first — deliberate.
|
|
10216
|
+
args.text = normalizePunctuation(args.text as string)
|
|
9225
10217
|
const scrub = scrubVoice(args.text as string)
|
|
9226
10218
|
if (scrub.replaced > 0) {
|
|
9227
10219
|
args.text = scrub.scrubbed
|
|
@@ -9379,6 +10371,9 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
9379
10371
|
retry: robustApiCall,
|
|
9380
10372
|
repairEscapedWhitespace,
|
|
9381
10373
|
normalizeParagraphBreaks,
|
|
10374
|
+
normalizePunctuation,
|
|
10375
|
+
stripExcessBold,
|
|
10376
|
+
addParagraphSpacers,
|
|
9382
10377
|
assertAllowedChat,
|
|
9383
10378
|
resolveThreadId,
|
|
9384
10379
|
disableLinkPreview: access.disableLinkPreview !== false,
|
|
@@ -10428,31 +11423,6 @@ function buildVaultRequestAccessKeyboard(stageId: string): { inline_keyboard: Ar
|
|
|
10428
11423
|
}
|
|
10429
11424
|
}
|
|
10430
11425
|
|
|
10431
|
-
function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
|
|
10432
|
-
const lines: string[] = []
|
|
10433
|
-
const scopeLabel = req.scope === 'write' ? 'write' : 'read'
|
|
10434
|
-
const days = Math.round(req.ttl_seconds / 86400)
|
|
10435
|
-
const durationLabel = days >= 1 ? `${days}d` : `${Math.round(req.ttl_seconds / 3600)}h`
|
|
10436
|
-
lines.push(`🔐 **${escapeHtmlForTg(req.agent)}** wants vault access`)
|
|
10437
|
-
lines.push(`key: \`${req.key}\``)
|
|
10438
|
-
lines.push(`scope: \`${scopeLabel}\` · duration: \`${durationLabel}\``)
|
|
10439
|
-
// #1790 — always render the why-line, even when the agent omitted
|
|
10440
|
-
// `reason`. Rendering "not provided" makes a missing rationale
|
|
10441
|
-
// visibly an agent-side failure (the tool description nudges the
|
|
10442
|
-
// model to supply one — see executeVaultRequestAccess); skipping
|
|
10443
|
-
// the line silently used to make the omission look like a card-
|
|
10444
|
-
// template choice, which the operator couldn't tell apart from a
|
|
10445
|
-
// legitimate "no reason needed" case.
|
|
10446
|
-
if (req.reason && req.reason.length > 0) {
|
|
10447
|
-
lines.push(`why: _${escapeHtmlForTg(req.reason)}_`)
|
|
10448
|
-
} else {
|
|
10449
|
-
lines.push(`why: _not provided_`)
|
|
10450
|
-
}
|
|
10451
|
-
lines.push('')
|
|
10452
|
-
lines.push(`_Tap Approve to mint a scoped grant token (same flow as \`switchroom vault grant\`). Tap Deny to refuse — the agent will receive a denial result._`)
|
|
10453
|
-
return lines.join('\n')
|
|
10454
|
-
}
|
|
10455
|
-
|
|
10456
11426
|
/**
|
|
10457
11427
|
* `vault_request_access` tool — agent surfaces an approval card asking
|
|
10458
11428
|
* the operator to grant a vault ACL it doesn't yet have. See #1012.
|
|
@@ -10471,7 +11441,12 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
10471
11441
|
if (scopeRaw !== 'read' && scopeRaw !== 'write') {
|
|
10472
11442
|
throw new Error('vault_request_access: scope must be "read" or "write"')
|
|
10473
11443
|
}
|
|
10474
|
-
|
|
11444
|
+
// Accept `why` as an alias for `reason`: the sibling tool
|
|
11445
|
+
// vault_request_save uses `why`, and agents cross-contaminate the two
|
|
11446
|
+
// schemas — without the alias the rationale silently drops off the
|
|
11447
|
+
// approval card and the operator sees "why: not provided".
|
|
11448
|
+
const reason =
|
|
11449
|
+
typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
|
|
10475
11450
|
// Duration: accept a "30d" / "12h" string from the agent OR default
|
|
10476
11451
|
// to 30 days. Cap at 90 days — beyond that the operator should use
|
|
10477
11452
|
// the host CLI and pick the lifetime explicitly. Refuse "never"
|
|
@@ -10636,6 +11611,10 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
|
|
|
10636
11611
|
// Outbound secret scrub (#2044): an edit must not re-introduce a raw
|
|
10637
11612
|
// secret into a live bubble or the history row. Mask before scrub/send.
|
|
10638
11613
|
editRawText = redactOutboundText(editRawText, 'edit_message')
|
|
11614
|
+
// Fleet-wide consistent formatting (same order as the reply path: redact
|
|
11615
|
+
// first so secrets are matched literally, then normalize, then spacers on
|
|
11616
|
+
// the rich path only).
|
|
11617
|
+
if (!editLiteralText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
|
|
10639
11618
|
// Voice scrub (#1683): same em-dash scrub as the reply path. Edits
|
|
10640
11619
|
// are how silent-anchor and progress-update mutate already-sent
|
|
10641
11620
|
// bubbles, so without this an edit can re-introduce dashes the
|
|
@@ -11133,6 +12112,16 @@ async function drainActivitySummary(
|
|
|
11133
12112
|
)
|
|
11134
12113
|
turn.activityMessageId = sent.message_id
|
|
11135
12114
|
turn.activityEverOpened = true
|
|
12115
|
+
// Status-pin: the per-turn status message just opened — it's the
|
|
12116
|
+
// in-flight "what it's doing" surface. Silently pin it so the turn
|
|
12117
|
+
// stays in view when the feed scrolls past. Keyed to the same
|
|
12118
|
+
// status-key the canonical turn-end (purgeReactionTracking) unpins.
|
|
12119
|
+
// Fire-and-forget; the single-owner reconcile keeps state consistent.
|
|
12120
|
+
void reconcileStatusPin(
|
|
12121
|
+
`fg:${statusKey(chat, thread)}`,
|
|
12122
|
+
chat,
|
|
12123
|
+
{ pinned: true, messageId: sent.message_id },
|
|
12124
|
+
)
|
|
11136
12125
|
} else {
|
|
11137
12126
|
const id = turn.activityMessageId
|
|
11138
12127
|
await robustApiCall(
|
|
@@ -15448,6 +16437,15 @@ async function sweepBeforeSelfRestart(): Promise<void> {
|
|
|
15448
16437
|
const agentDir = resolveAgentDirFromEnv()
|
|
15449
16438
|
if (agentDir == null) return
|
|
15450
16439
|
// #1122 PR3: pre-restart progress-card pin sweep removed with the card.
|
|
16440
|
+
// Status-pin: unpin every status pin we own before we hand control to the
|
|
16441
|
+
// restart, so a crash / interrupt / config-bounce never leaves a permanent
|
|
16442
|
+
// pin stuck at the top of the chat. Best-effort; the drop-on-unpin contract
|
|
16443
|
+
// clears the claim even if the API throws.
|
|
16444
|
+
try {
|
|
16445
|
+
await unpinAllStatusPins()
|
|
16446
|
+
} catch (err) {
|
|
16447
|
+
process.stderr.write(`telegram gateway: pre-restart status-pin sweep threw: ${(err as Error).message}\n`)
|
|
16448
|
+
}
|
|
15451
16449
|
try {
|
|
15452
16450
|
await sweepActiveReactions(
|
|
15453
16451
|
agentDir,
|
|
@@ -17159,6 +18157,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
|
|
|
17159
18157
|
action: naturalAction(details.tool_name, details.input_preview),
|
|
17160
18158
|
})
|
|
17161
18159
|
pendingPermissions.delete(request_id)
|
|
18160
|
+
permCardStore.remove(request_id)
|
|
17162
18161
|
process.stderr.write(
|
|
17163
18162
|
`[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
|
|
17164
18163
|
)
|
|
@@ -18861,9 +19860,16 @@ async function performVaultAccessApproval(
|
|
|
18861
19860
|
.editMessageText(
|
|
18862
19861
|
pending.chat_id,
|
|
18863
19862
|
pending.card_message_id,
|
|
18864
|
-
|
|
18865
|
-
|
|
18866
|
-
|
|
19863
|
+
richMessage(
|
|
19864
|
+
buildVaultGrantApprovedCardText({
|
|
19865
|
+
agentEscaped: escapeHtmlForTg(pending.agent),
|
|
19866
|
+
scope: pending.scope,
|
|
19867
|
+
key: pending.key,
|
|
19868
|
+
days,
|
|
19869
|
+
grantId: id,
|
|
19870
|
+
footer,
|
|
19871
|
+
}),
|
|
19872
|
+
),
|
|
18867
19873
|
{ reply_markup: { inline_keyboard: [] } },
|
|
18868
19874
|
)
|
|
18869
19875
|
.catch(() => {})
|
|
@@ -20855,7 +21861,7 @@ bot.command('usage', async ctx => {
|
|
|
20855
21861
|
}
|
|
20856
21862
|
return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
|
|
20857
21863
|
})
|
|
20858
|
-
const { renderAuthSnapshotFormat2, buildSnapshotsFromState } = await import(
|
|
21864
|
+
const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
|
|
20859
21865
|
'../auth-snapshot-format.js'
|
|
20860
21866
|
)
|
|
20861
21867
|
const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
|
|
@@ -20866,7 +21872,22 @@ bot.command('usage', async ctx => {
|
|
|
20866
21872
|
demo,
|
|
20867
21873
|
...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: Date.now() }),
|
|
20868
21874
|
})
|
|
20869
|
-
|
|
21875
|
+
// Preserve the Switch/Refresh/usage/Add inline keyboard on the
|
|
21876
|
+
// rich-message render — the table card carries the same actions the
|
|
21877
|
+
// /auth snapshot does. switchroomReply routes through the rich path
|
|
21878
|
+
// (replyWithRichMessage), which accepts reply_markup. Build a grammy
|
|
21879
|
+
// InlineKeyboard so the markup type matches switchroomReply's contract.
|
|
21880
|
+
const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date() })
|
|
21881
|
+
const keyboard = new InlineKeyboard()
|
|
21882
|
+
kbRows.forEach((row, ri) => {
|
|
21883
|
+
if (ri > 0) keyboard.row()
|
|
21884
|
+
for (const b of row) {
|
|
21885
|
+
if (b.callbackData) keyboard.text(b.text, b.callbackData)
|
|
21886
|
+
else if (b.insertText) keyboard.switchInlineCurrent(b.text, b.insertText)
|
|
21887
|
+
else keyboard.text(b.text, 'auth:noop')
|
|
21888
|
+
}
|
|
21889
|
+
})
|
|
21890
|
+
await switchroomReply(ctx, text, { html: true, reply_markup: keyboard })
|
|
20870
21891
|
return
|
|
20871
21892
|
}
|
|
20872
21893
|
}
|
|
@@ -21186,7 +22207,11 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21186
22207
|
await ctx.answerCallbackQuery({ text: 'Tap a model in this section to switch' }).catch(() => {})
|
|
21187
22208
|
return
|
|
21188
22209
|
}
|
|
21189
|
-
|
|
22210
|
+
// Page navigation (External ▸ / ◂ Back) just re-renders the menu with the
|
|
22211
|
+
// other keyboard page — it never drives the picker, so ack "Loading…"
|
|
22212
|
+
// rather than the switch-oriented "Switching…".
|
|
22213
|
+
const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN
|
|
22214
|
+
await ctx.answerCallbackQuery({ text: isPageNav ? 'Loading…' : 'Switching…' }).catch(() => {})
|
|
21190
22215
|
// sr-* inject waits for claude to respond (can take 10-30s). Edit the
|
|
21191
22216
|
// menu immediately to show a "working on it" state so the operator isn't
|
|
21192
22217
|
// left looking at a stale menu with no feedback. The final edit (✅/❌)
|
|
@@ -21544,6 +22569,135 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21544
22569
|
return
|
|
21545
22570
|
}
|
|
21546
22571
|
|
|
22572
|
+
// on-demand voice: '🔊 Listen' buttons carry a reserved `voice:<token>`
|
|
22573
|
+
// callback_data. Handle INTERNALLY here — synthesize the cached reply text
|
|
22574
|
+
// and send it as a native voice note. This MUST run before the agent:
|
|
22575
|
+
// routing below so a tap never reaches the agent as an inbound message.
|
|
22576
|
+
if (isVoiceOnDemandCallback(data)) {
|
|
22577
|
+
const access = loadAccess()
|
|
22578
|
+
const senderId = String(ctx.from.id)
|
|
22579
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
22580
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
22581
|
+
return
|
|
22582
|
+
}
|
|
22583
|
+
const token = parseVoiceOnDemandToken(data)
|
|
22584
|
+
const entry = token != null ? voiceOnDemandCache.get(token) : null
|
|
22585
|
+
if (entry == null) {
|
|
22586
|
+
await ctx
|
|
22587
|
+
.answerCallbackQuery({ text: 'Voice expired — send again to hear it.' })
|
|
22588
|
+
.catch(() => {})
|
|
22589
|
+
return
|
|
22590
|
+
}
|
|
22591
|
+
const cbChatId = String(ctx.chat?.id ?? ctx.from.id)
|
|
22592
|
+
const cbMessageId = ctx.callbackQuery?.message?.message_id
|
|
22593
|
+
const cbThreadId = (() => {
|
|
22594
|
+
const msg = ctx.callbackQuery?.message
|
|
22595
|
+
if (msg && 'is_topic_message' in msg && msg.is_topic_message && 'message_thread_id' in msg) {
|
|
22596
|
+
const tid = (msg as { message_thread_id?: number }).message_thread_id
|
|
22597
|
+
return typeof tid === 'number' ? tid : undefined
|
|
22598
|
+
}
|
|
22599
|
+
return undefined
|
|
22600
|
+
})()
|
|
22601
|
+
// #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written
|
|
22602
|
+
// by the pre-synth queue at reply time). If it's on disk, attach it
|
|
22603
|
+
// immediately — no GPU wait. Missing/unreadable file (expired + swept,
|
|
22604
|
+
// crash, pre-feature entry, kill-switched gateway) falls back
|
|
22605
|
+
// transparently to the lazy synth path below.
|
|
22606
|
+
let audio: Uint8Array | null = null
|
|
22607
|
+
if (entry.filePath != null) {
|
|
22608
|
+
try {
|
|
22609
|
+
audio = readFileSync(entry.filePath)
|
|
22610
|
+
} catch {
|
|
22611
|
+
audio = null // swept/missing — lazy fallback
|
|
22612
|
+
}
|
|
22613
|
+
}
|
|
22614
|
+
if (audio != null) {
|
|
22615
|
+
await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
|
|
22616
|
+
} else {
|
|
22617
|
+
await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
|
|
22618
|
+
// Local sidecar (kokoro) synthesis — same helper the immediate voice-out
|
|
22619
|
+
// path uses. On-demand is a local-engine feature; the cache is only
|
|
22620
|
+
// populated when resolveVoiceOutPlan gated the local verdict.
|
|
22621
|
+
const sidecarToken = await materializeSidecarToken()
|
|
22622
|
+
if (!sidecarToken) {
|
|
22623
|
+
await ctx
|
|
22624
|
+
.answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
|
|
22625
|
+
.catch(() => {})
|
|
22626
|
+
return
|
|
22627
|
+
}
|
|
22628
|
+
const result = await synthesizeViaSidecar({
|
|
22629
|
+
token: sidecarToken,
|
|
22630
|
+
// #2760 Phase 1: same deterministic normalization as the immediate
|
|
22631
|
+
// voice-out path — the Listen lazy path builds its own /tts body from
|
|
22632
|
+
// the persisted cache, so it must normalize independently (cache
|
|
22633
|
+
// entries may predate the flag flip).
|
|
22634
|
+
text: normalizeForTts(entry.text),
|
|
22635
|
+
voice: entry.voice,
|
|
22636
|
+
speed: entry.speed,
|
|
22637
|
+
})
|
|
22638
|
+
if (!result.ok) {
|
|
22639
|
+
process.stderr.write(
|
|
22640
|
+
`telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
|
|
22641
|
+
)
|
|
22642
|
+
await ctx
|
|
22643
|
+
.answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
|
|
22644
|
+
.catch(() => {})
|
|
22645
|
+
return
|
|
22646
|
+
}
|
|
22647
|
+
audio = result.audio
|
|
22648
|
+
}
|
|
22649
|
+
// Rebind as const so the closure below narrows to non-null (TS doesn't
|
|
22650
|
+
// narrow a captured `let` inside an arrow function).
|
|
22651
|
+
const audioOut: Uint8Array = audio
|
|
22652
|
+
try {
|
|
22653
|
+
// Native voice note (NOT a document), quote-replying the button's
|
|
22654
|
+
// message.
|
|
22655
|
+
await robustApiCall(
|
|
22656
|
+
() =>
|
|
22657
|
+
bot.api.sendVoice(
|
|
22658
|
+
cbChatId,
|
|
22659
|
+
// allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
|
|
22660
|
+
new InputFile(Buffer.from(audioOut)),
|
|
22661
|
+
{
|
|
22662
|
+
...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
|
|
22663
|
+
...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
|
|
22664
|
+
} as never,
|
|
22665
|
+
),
|
|
22666
|
+
{
|
|
22667
|
+
chat_id: cbChatId,
|
|
22668
|
+
verb: 'voice-ondemand.sendVoice',
|
|
22669
|
+
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
22670
|
+
},
|
|
22671
|
+
)
|
|
22672
|
+
// Single-use on SUCCESS: strip the '🔊 Listen' keyboard so the button
|
|
22673
|
+
// can't be re-tapped now that the audio has been delivered. Mirrors the
|
|
22674
|
+
// agent-button single_use strip (keyboardIsSingleUse) house style.
|
|
22675
|
+
// Best-effort + non-fatal — a failed strip only leaves a replayable
|
|
22676
|
+
// button, never drops the delivered audio. Only reached on a successful
|
|
22677
|
+
// sendVoice; expiry / synth-failure / sidecar-unavailable all return
|
|
22678
|
+
// earlier WITHOUT stripping, so the user can retry those.
|
|
22679
|
+
if (cbMessageId != null) {
|
|
22680
|
+
await robustApiCall(
|
|
22681
|
+
() =>
|
|
22682
|
+
bot.api.editMessageReplyMarkup(cbChatId, cbMessageId, {
|
|
22683
|
+
reply_markup: { inline_keyboard: [] },
|
|
22684
|
+
}),
|
|
22685
|
+
{
|
|
22686
|
+
chat_id: cbChatId,
|
|
22687
|
+
verb: 'voice-ondemand.strip-listen-keyboard',
|
|
22688
|
+
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
22689
|
+
},
|
|
22690
|
+
).catch(() => {})
|
|
22691
|
+
}
|
|
22692
|
+
} catch (err) {
|
|
22693
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
22694
|
+
process.stderr.write(
|
|
22695
|
+
`telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
|
|
22696
|
+
)
|
|
22697
|
+
}
|
|
22698
|
+
return
|
|
22699
|
+
}
|
|
22700
|
+
|
|
21547
22701
|
// #271: agent-emitted inline_keyboard callbacks. Namespaced with
|
|
21548
22702
|
// an `agent:` prefix in inline-keyboard-callbacks.ts so they can
|
|
21549
22703
|
// round-trip without colliding with infrastructure prefixes above.
|
|
@@ -21741,6 +22895,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21741
22895
|
}
|
|
21742
22896
|
|
|
21743
22897
|
pendingPermissions.delete(request_id)
|
|
22898
|
+
permCardStore.remove(request_id)
|
|
21744
22899
|
|
|
21745
22900
|
// (2) Dispatch the in-flight permission verdict IMMEDIATELY — before
|
|
21746
22901
|
// any host round-trip — so the turn never blocks on persistence.
|
|
@@ -21764,9 +22919,48 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21764
22919
|
action: naturalAction(details.tool_name, details.input_preview),
|
|
21765
22920
|
})
|
|
21766
22921
|
|
|
21767
|
-
// (3)
|
|
21768
|
-
//
|
|
21769
|
-
//
|
|
22922
|
+
// (3) Ack the tap IMMEDIATELY with an honest interim status. The
|
|
22923
|
+
// durable persist below goes through hostd `config_propose_edit`
|
|
22924
|
+
// (validate→approve→apply→reconcile), which can take 5-10 minutes on
|
|
22925
|
+
// a busy host — the old code awaited only 60s and then reported
|
|
22926
|
+
// "did NOT save" even when the edit later landed. Instead: ack now,
|
|
22927
|
+
// run the persistence in a background continuation with a 12-min
|
|
22928
|
+
// dispatch window, then edit the card with the REAL outcome.
|
|
22929
|
+
// HTML-escape baseText — `ctx.callbackQuery.message.text` returns
|
|
22930
|
+
// entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
|
|
22931
|
+
// expanded permission card's `description` or `input_preview`
|
|
22932
|
+
// (claude-generated tool descriptions routinely carry these)
|
|
22933
|
+
// would break the HTML re-parse. PR #1158 review caught the same
|
|
22934
|
+
// issue on the operator-event card.
|
|
22935
|
+
const sourceMsg = ctx.callbackQuery?.message
|
|
22936
|
+
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
22937
|
+
? escapeHtmlForTg(sourceMsg.text)
|
|
22938
|
+
: ''
|
|
22939
|
+
const interimLabel =
|
|
22940
|
+
`⏳ **Rule applied for this session** — ${escapeHtmlForTg(agentName)} can ${escapeHtmlForTg(grantPhrase)} ` +
|
|
22941
|
+
`without asking for now; saving durably in background…`
|
|
22942
|
+
// #1150 audit: route through finalizeCallback so the keyboard
|
|
22943
|
+
// strips alongside the status-line edit. The in-flight verdict was
|
|
22944
|
+
// ALREADY dispatched above (independently of this host round-trip)
|
|
22945
|
+
// so the turn never blocked — finalizeCallback here only edits the
|
|
22946
|
+
// card; no synthInbound (would double-fire the verdict).
|
|
22947
|
+
await finalizeCallback(ctx, {
|
|
22948
|
+
ackText: 'Rule applied for this session; saving durably in background…'.slice(0, 200),
|
|
22949
|
+
newText: baseText ? `${baseText}\n\n${interimLabel}` : interimLabel,
|
|
22950
|
+
})
|
|
22951
|
+
|
|
22952
|
+
// Background continuation: decide the persistence path.
|
|
22953
|
+
// tryHostdDispatch returns "not-configured" when host_control is
|
|
22954
|
+
// disabled or the per-agent socket is absent → legacy fallback.
|
|
22955
|
+
// The whole body is wrapped in try/catch: a throw inside a void
|
|
22956
|
+
// IIFE (scheduleGrantRestart's sync fs writes, richMessage, etc.)
|
|
22957
|
+
// would surface as an unhandledRejection — which this gateway
|
|
22958
|
+
// routes through shutdown(). Pre-background, those throws were
|
|
22959
|
+
// contained by grammY's handler wrapper; keep that containment.
|
|
22960
|
+
// Known accepted gap: if the gateway restarts mid-persist, the
|
|
22961
|
+
// interim "saving durably in background…" card stays stale.
|
|
22962
|
+
void (async () => {
|
|
22963
|
+
try {
|
|
21770
22964
|
let durable = false
|
|
21771
22965
|
let legacy = false
|
|
21772
22966
|
let failReason = ''
|
|
@@ -21808,8 +23002,10 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21808
23002
|
},
|
|
21809
23003
|
}
|
|
21810
23004
|
// config_propose_edit blocks on validate→approve→apply→reconcile,
|
|
21811
|
-
//
|
|
21812
|
-
|
|
23005
|
+
// which can legitimately take 5-10 minutes — allow 12 min. Safe
|
|
23006
|
+
// to wait this long because we're in a background continuation:
|
|
23007
|
+
// the tap was already acked and the verdict already dispatched.
|
|
23008
|
+
const resp = await tryHostdDispatch(agentName, req, 720_000)
|
|
21813
23009
|
if (resp === 'not-configured') {
|
|
21814
23010
|
warnLegacySpawnIfHostdDisabled('always-allow')
|
|
21815
23011
|
legacy = true
|
|
@@ -21893,23 +23089,6 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21893
23089
|
`always-allow: ${grantPhrase}`,
|
|
21894
23090
|
) !== "disabled"
|
|
21895
23091
|
const liveSuffix = restartScheduled ? " — applying now (restarting to take effect)" : ""
|
|
21896
|
-
const ackText = ok
|
|
21897
|
-
? (legacyNote
|
|
21898
|
-
? `✅ Saved. ${agentName} can now ${grantPhrase} without asking (legacy path).`
|
|
21899
|
-
: `✅ Saved. ${agentName} can now ${grantPhrase} without asking.${liveSuffix}`)
|
|
21900
|
-
: (editLockHint
|
|
21901
|
-
? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
|
|
21902
|
-
: `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
|
|
21903
|
-
// HTML-escape baseText — `ctx.callbackQuery.message.text` returns
|
|
21904
|
-
// entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
|
|
21905
|
-
// expanded permission card's `description` or `input_preview`
|
|
21906
|
-
// (claude-generated tool descriptions routinely carry these)
|
|
21907
|
-
// would break the HTML re-parse. PR #1158 review caught the same
|
|
21908
|
-
// issue on the operator-event card.
|
|
21909
|
-
const sourceMsg = ctx.callbackQuery?.message
|
|
21910
|
-
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
21911
|
-
? escapeHtmlForTg(sourceMsg.text)
|
|
21912
|
-
: ''
|
|
21913
23092
|
const editLabel = ok
|
|
21914
23093
|
? (legacyNote
|
|
21915
23094
|
? `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking (legacy path); restart agent for full effect`
|
|
@@ -21919,15 +23098,30 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21919
23098
|
: (editLockHint
|
|
21920
23099
|
? `⚠️ **Allowed for now — "always" did NOT save.** Config edits are locked; enable \`hostd.config_edit_enabled\`.`
|
|
21921
23100
|
: `⚠️ **Allowed for now — "always" did NOT save.** It will ask again after restart. Check gateway log.`)
|
|
21922
|
-
//
|
|
21923
|
-
//
|
|
21924
|
-
//
|
|
21925
|
-
//
|
|
21926
|
-
|
|
21927
|
-
|
|
21928
|
-
|
|
21929
|
-
|
|
23101
|
+
// Edit the card with the REAL outcome. The keyboard was already
|
|
23102
|
+
// stripped by the interim finalizeCallback ack above, and the
|
|
23103
|
+
// callback query was already answered — this is a plain edit
|
|
23104
|
+
// replacing the "saving durably in background…" interim line.
|
|
23105
|
+
await ctx.editMessageText(
|
|
23106
|
+
richMessage(baseText ? `${baseText}\n\n${editLabel}` : editLabel),
|
|
23107
|
+
{
|
|
23108
|
+
reply_markup: { inline_keyboard: [] },
|
|
23109
|
+
link_preview_options: { is_disabled: true },
|
|
23110
|
+
},
|
|
23111
|
+
).catch((err: unknown) => {
|
|
23112
|
+
process.stderr.write(
|
|
23113
|
+
`telegram gateway: always-allow outcome card edit failed: ${(err as Error).message} (request_id=${request_id})\n`,
|
|
23114
|
+
)
|
|
21930
23115
|
})
|
|
23116
|
+
} catch (err) {
|
|
23117
|
+
// Never let the background persist take the gateway down — an
|
|
23118
|
+
// unhandledRejection here becomes shutdown(). Log and move on;
|
|
23119
|
+
// the in-session rule was already applied at tap time.
|
|
23120
|
+
process.stderr.write(
|
|
23121
|
+
`telegram gateway: always-allow background persist threw: ${(err as Error).message} (request_id=${request_id})\n`,
|
|
23122
|
+
)
|
|
23123
|
+
}
|
|
23124
|
+
})()
|
|
21931
23125
|
return
|
|
21932
23126
|
}
|
|
21933
23127
|
|
|
@@ -21964,6 +23158,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21964
23158
|
: null
|
|
21965
23159
|
const grantAgent = selfAgentName()
|
|
21966
23160
|
pendingPermissions.delete(request_id)
|
|
23161
|
+
permCardStore.remove(request_id)
|
|
21967
23162
|
if (timeBox && grantAgent) {
|
|
21968
23163
|
recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
|
|
21969
23164
|
process.stderr.write(
|
|
@@ -22001,6 +23196,25 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22001
23196
|
const baseText = msg && 'text' in msg && msg.text
|
|
22002
23197
|
? escapeHtmlForTg(msg.text)
|
|
22003
23198
|
: ''
|
|
23199
|
+
// NO SPLIT (#card-ux): the agent-voiced "got it, continuing: <action>"
|
|
23200
|
+
// continuation folds into THIS card edit — below the verdict label — instead
|
|
23201
|
+
// of spawning a separate message. formatPermissionResumeMessage returns the
|
|
23202
|
+
// same GFM markdown the standalone message used, so the wording is
|
|
23203
|
+
// unchanged; it just rides in the card the operator already tapped. The
|
|
23204
|
+
// separate message went to EVERY resolvePermissionCardTargets() surface; the
|
|
23205
|
+
// in-place edit deliberately touches only the tapped card (a single, legible
|
|
23206
|
+
// card, no fan-out). The turn-resume trigger (dispatchPermissionVerdict) is
|
|
23207
|
+
// untouched below — only the USER-VISIBLE second message collapses. Honours
|
|
23208
|
+
// the SWITCHROOM_RESUME_MSG=0 kill-switch (suppresses the continuation line,
|
|
23209
|
+
// as it suppressed the separate message before).
|
|
23210
|
+
const resumeLine = process.env.SWITCHROOM_RESUME_MSG === '0'
|
|
23211
|
+
? ''
|
|
23212
|
+
: formatPermissionResumeMessage({
|
|
23213
|
+
agentName: process.env.SWITCHROOM_AGENT_NAME ?? null,
|
|
23214
|
+
behavior: behavior as 'allow' | 'deny',
|
|
23215
|
+
action: resumeAction,
|
|
23216
|
+
})
|
|
23217
|
+
const labelWithResume = resumeLine ? `${htmlLabel}\n\n${resumeLine}` : htmlLabel
|
|
22004
23218
|
// #1150 audit: P0 fix — was `editMessageText` WITHOUT reply_markup
|
|
22005
23219
|
// strip, leaving the [Allow][Deny][Always] keyboard live after the
|
|
22006
23220
|
// decision. Operator could re-tap and flip Deny → Allow after the
|
|
@@ -22008,7 +23222,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22008
23222
|
// strips the keyboard atomically with the status-line edit.
|
|
22009
23223
|
await finalizeCallback(ctx, {
|
|
22010
23224
|
ackText: ackText.slice(0, 200),
|
|
22011
|
-
newText: baseText ? `${baseText}\n\n${
|
|
23225
|
+
newText: baseText ? `${baseText}\n\n${labelWithResume}` : labelWithResume,
|
|
22012
23226
|
synthInbound: () => {
|
|
22013
23227
|
// No `rule` → the bridge does NOT cache this (truly once on the bridge);
|
|
22014
23228
|
// any 30-min stickiness lives only in scopedGrants (recorded above).
|
|
@@ -22020,10 +23234,11 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22020
23234
|
// Un-park the status reaction: 🙏 → working, re-arming the stall
|
|
22021
23235
|
// watchdog that setAwaiting() suspended.
|
|
22022
23236
|
resumeReactionAfterVerdict()
|
|
22023
|
-
|
|
22024
|
-
|
|
22025
|
-
|
|
22026
|
-
|
|
23237
|
+
// card-folded-resume: the visible "continuing…" line is folded into the
|
|
23238
|
+
// card edit above (labelWithResume), so we deliberately do NOT
|
|
23239
|
+
// postPermissionResumeMessage here — that would re-introduce the split
|
|
23240
|
+
// second message this fix (card-ux fix 3) removes. The turn still resumes
|
|
23241
|
+
// via dispatchPermissionVerdict + resumeReactionAfterVerdict above.
|
|
22027
23242
|
},
|
|
22028
23243
|
})
|
|
22029
23244
|
})
|
|
@@ -22091,12 +23306,42 @@ bot.on('message:voice', async ctx => {
|
|
|
22091
23306
|
// SOMETHING — better than silent drops.
|
|
22092
23307
|
const access = loadAccess()
|
|
22093
23308
|
const voiceIn = access.voice_in
|
|
22094
|
-
|
|
22095
|
-
|
|
22096
|
-
|
|
22097
|
-
|
|
22098
|
-
|
|
22099
|
-
|
|
23309
|
+
// Engine selection (PR-B2): the persisted host verdict decides HOW we
|
|
23310
|
+
// transcribe. `local` → the in-fleet GPU sidecar (no third-party key,
|
|
23311
|
+
// vision #3 + #4); anything else → the OpenAI cloud provider. The local
|
|
23312
|
+
// path needs no `provider === 'openai'` gate — it has no API key — so we
|
|
23313
|
+
// route to the sidecar whenever voice_in is enabled AND the host verdict
|
|
23314
|
+
// is `local`. The cloud path keeps its existing openai gate.
|
|
23315
|
+
// Source precedence: the compose-injected SWITCHROOM_VOICE_ENGINE env
|
|
23316
|
+
// (set per-agent by compose-gen from the host verdict — PR-B3) wins,
|
|
23317
|
+
// then the persisted host-capabilities file, then a fail-safe `cloud`.
|
|
23318
|
+
// The env is load-bearing in-fleet: the in-container `~/.switchroom`
|
|
23319
|
+
// is a read-only constructed view that does NOT carry the host's
|
|
23320
|
+
// host-capabilities.json, so without the env the file lookup always
|
|
23321
|
+
// misses and every agent silently falls back to `cloud`. Narrow the
|
|
23322
|
+
// env value to the VoiceEngine union so a bogus value can't leak
|
|
23323
|
+
// through — anything but 'local'/'cloud' is ignored and we fall back.
|
|
23324
|
+
const envVoiceEngine = process.env.SWITCHROOM_VOICE_ENGINE
|
|
23325
|
+
const voiceEngine: VoiceEngine =
|
|
23326
|
+
envVoiceEngine === 'local' || envVoiceEngine === 'cloud'
|
|
23327
|
+
? envVoiceEngine
|
|
23328
|
+
: loadHostCapabilities()?.voice.engine ?? 'cloud'
|
|
23329
|
+
const localEnabled = voiceIn?.enabled === true && voiceEngine === 'local'
|
|
23330
|
+
const cloudEnabled =
|
|
23331
|
+
voiceIn?.enabled === true && voiceEngine !== 'local' && voiceIn?.provider === 'openai'
|
|
23332
|
+
if (localEnabled || cloudEnabled) {
|
|
23333
|
+
const transcript = localEnabled
|
|
23334
|
+
? await maybeTranscribeVoiceLocal(
|
|
23335
|
+
voice.file_id,
|
|
23336
|
+
voice.mime_type,
|
|
23337
|
+
voiceIn?.language,
|
|
23338
|
+
)
|
|
23339
|
+
: await maybeTranscribeVoice(
|
|
23340
|
+
voice.file_id,
|
|
23341
|
+
voice.mime_type,
|
|
23342
|
+
voiceIn?.language,
|
|
23343
|
+
voiceIn?.api_key,
|
|
23344
|
+
)
|
|
22100
23345
|
if (transcript != null) {
|
|
22101
23346
|
const text = ctx.message.caption
|
|
22102
23347
|
? `${ctx.message.caption}\n\n[voice transcript] ${transcript}`
|
|
@@ -22121,33 +23366,24 @@ bot.on('message:voice', async ctx => {
|
|
|
22121
23366
|
* are logged to stderr but never thrown — voice-in is a UX
|
|
22122
23367
|
* enhancement, not a critical path.
|
|
22123
23368
|
*/
|
|
22124
|
-
|
|
22125
|
-
|
|
22126
|
-
|
|
22127
|
-
|
|
22128
|
-
|
|
22129
|
-
|
|
22130
|
-
|
|
22131
|
-
|
|
22132
|
-
|
|
22133
|
-
|
|
22134
|
-
|
|
22135
|
-
const path = require('path').join(require('os').homedir(), '.switchroom', 'openai-api-key')
|
|
22136
|
-
if (existsSync(path)) {
|
|
22137
|
-
apiKey = readFileSync(path, 'utf-8').trim()
|
|
22138
|
-
}
|
|
22139
|
-
} catch (err) {
|
|
22140
|
-
process.stderr.write(`telegram gateway: voice-in: failed to read api key: ${(err as Error).message}\n`)
|
|
22141
|
-
return null
|
|
22142
|
-
}
|
|
22143
|
-
if (!apiKey) {
|
|
22144
|
-
process.stderr.write(`telegram gateway: voice-in: enabled but no api key at ~/.switchroom/openai-api-key — falling back\n`)
|
|
22145
|
-
return null
|
|
22146
|
-
}
|
|
23369
|
+
/**
|
|
23370
|
+
* Filename hint for the multipart body. Telegram voice is OGG/Opus; agents
|
|
23371
|
+
* may also attach mp3/m4a/wav which arrive as message:audio. Match the mime
|
|
23372
|
+
* so the decoder (Whisper / faster-whisper) gets a usable extension.
|
|
23373
|
+
*/
|
|
23374
|
+
function voiceFilenameExt(mimeType: string | undefined): string {
|
|
23375
|
+
return mimeType?.includes('mp3') ? 'mp3'
|
|
23376
|
+
: mimeType?.includes('m4a') ? 'm4a'
|
|
23377
|
+
: mimeType?.includes('wav') ? 'wav'
|
|
23378
|
+
: 'ogg'
|
|
23379
|
+
}
|
|
22147
23380
|
|
|
22148
|
-
|
|
22149
|
-
|
|
22150
|
-
|
|
23381
|
+
/**
|
|
23382
|
+
* Download a Telegram voice attachment into memory. Shared by the cloud
|
|
23383
|
+
* (Whisper) and local (sidecar) transcription paths. Returns the bytes on
|
|
23384
|
+
* success, or null on any failure (caller falls back). Never throws.
|
|
23385
|
+
*/
|
|
23386
|
+
async function downloadVoiceBytes(fileId: string): Promise<Uint8Array | null> {
|
|
22151
23387
|
try {
|
|
22152
23388
|
const file = await bot.api.getFile(fileId)
|
|
22153
23389
|
if (!file.file_path) {
|
|
@@ -22160,7 +23396,7 @@ async function maybeTranscribeVoice(
|
|
|
22160
23396
|
process.stderr.write(`telegram gateway: voice-in: telegram download HTTP ${res.status}\n`)
|
|
22161
23397
|
return null
|
|
22162
23398
|
}
|
|
22163
|
-
|
|
23399
|
+
return new Uint8Array(await res.arrayBuffer())
|
|
22164
23400
|
} catch (err) {
|
|
22165
23401
|
// Sanitize: never let the bot token leak into log lines via the
|
|
22166
23402
|
// download URL — strip anything that looks like a token.
|
|
@@ -22168,19 +23404,78 @@ async function maybeTranscribeVoice(
|
|
|
22168
23404
|
process.stderr.write(`telegram gateway: voice-in: download failed: ${msg}\n`)
|
|
22169
23405
|
return null
|
|
22170
23406
|
}
|
|
23407
|
+
}
|
|
22171
23408
|
|
|
22172
|
-
|
|
22173
|
-
|
|
22174
|
-
|
|
22175
|
-
|
|
22176
|
-
|
|
22177
|
-
|
|
22178
|
-
|
|
23409
|
+
/**
|
|
23410
|
+
* Transcribe a Telegram voice note via the LOCAL GPU STT sidecar (PR-B2).
|
|
23411
|
+
* Used when the host voice verdict is `local`. Resolves the shared-secret
|
|
23412
|
+
* token from the vault (voice/sidecar-token), downloads the audio, and
|
|
23413
|
+
* POSTs it to the sidecar at loopback (the gateway is network_mode: host).
|
|
23414
|
+
* Returns the transcript on success, null on any failure (caller falls
|
|
23415
|
+
* back to the legacy "(voice message)" envelope). Never throws.
|
|
23416
|
+
*/
|
|
23417
|
+
async function maybeTranscribeVoiceLocal(
|
|
23418
|
+
fileId: string,
|
|
23419
|
+
mimeType: string | undefined,
|
|
23420
|
+
language: string | undefined,
|
|
23421
|
+
): Promise<string | null> {
|
|
23422
|
+
const token = await materializeSidecarToken()
|
|
23423
|
+
if (!token) {
|
|
23424
|
+
// materializeSidecarToken already logged the specific reason.
|
|
23425
|
+
return null
|
|
23426
|
+
}
|
|
23427
|
+
|
|
23428
|
+
const audioBytes = await downloadVoiceBytes(fileId)
|
|
23429
|
+
if (!audioBytes) return null
|
|
23430
|
+
|
|
23431
|
+
const result = await transcribeViaSidecar({
|
|
23432
|
+
token,
|
|
23433
|
+
audio: audioBytes,
|
|
23434
|
+
filename: `voice.${voiceFilenameExt(mimeType)}`,
|
|
23435
|
+
language,
|
|
23436
|
+
})
|
|
23437
|
+
|
|
23438
|
+
if (!result.ok) {
|
|
23439
|
+
process.stderr.write(
|
|
23440
|
+
`telegram gateway: voice-in: local sidecar transcription failed reason=${result.reason}` +
|
|
23441
|
+
(result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
|
|
23442
|
+
'\n',
|
|
23443
|
+
)
|
|
23444
|
+
return null
|
|
23445
|
+
}
|
|
23446
|
+
|
|
23447
|
+
process.stderr.write(
|
|
23448
|
+
`telegram gateway: voice-in: local sidecar transcribed ${audioBytes.length} bytes in ${result.durationMs}ms ` +
|
|
23449
|
+
`lang=${result.language ?? '?'} audio_s=${result.audioSeconds ?? '?'} chars=${result.text.length}\n`,
|
|
23450
|
+
)
|
|
23451
|
+
return result.text
|
|
23452
|
+
}
|
|
23453
|
+
|
|
23454
|
+
async function maybeTranscribeVoice(
|
|
23455
|
+
fileId: string,
|
|
23456
|
+
mimeType: string | undefined,
|
|
23457
|
+
language: string | undefined,
|
|
23458
|
+
apiKeyRef: string | undefined,
|
|
23459
|
+
): Promise<string | null> {
|
|
23460
|
+
// Resolve the STT key through the vault broker at use-time (PR-A:
|
|
23461
|
+
// voice STT vault-unify). The configured `voice_in.api_key` is a
|
|
23462
|
+
// `vault:<key>` reference (default `vault:openai/api-key`); the
|
|
23463
|
+
// resolved value is held in memory only — never written to disk or
|
|
23464
|
+
// surfaced into the agent prompt. On any failure we return null and
|
|
23465
|
+
// the caller falls back to the legacy "(voice message)" envelope.
|
|
23466
|
+
const apiKey = await materializeVoiceKey({ apiKeyRef })
|
|
23467
|
+
if (!apiKey) {
|
|
23468
|
+
// materializeVoiceKey already logged the specific reason.
|
|
23469
|
+
return null
|
|
23470
|
+
}
|
|
23471
|
+
|
|
23472
|
+
const audioBytes = await downloadVoiceBytes(fileId)
|
|
23473
|
+
if (!audioBytes) return null
|
|
22179
23474
|
|
|
22180
23475
|
const result = await transcribeViaWhisper({
|
|
22181
23476
|
apiKey,
|
|
22182
23477
|
audio: audioBytes,
|
|
22183
|
-
filename: `voice.${
|
|
23478
|
+
filename: `voice.${voiceFilenameExt(mimeType)}`,
|
|
22184
23479
|
language,
|
|
22185
23480
|
})
|
|
22186
23481
|
|
|
@@ -22627,6 +23922,66 @@ bot.on('message:checklist_tasks_added' as Parameters<typeof bot.on>[0], (ctx) =>
|
|
|
22627
23922
|
handleChecklistUpdate(ctx as unknown as Context, 'checklist_tasks_added')
|
|
22628
23923
|
})
|
|
22629
23924
|
|
|
23925
|
+
// Suppress the "pinned a message" service message Telegram inserts when OUR
|
|
23926
|
+
// silent status-pin fires. The pin call passes `disable_notification: true`,
|
|
23927
|
+
// which kills the PUSH notification but NOT the in-chat service message — so
|
|
23928
|
+
// delete that service message as it lands, but ONLY for pins we own (tracked
|
|
23929
|
+
// in `statusPinState`). Manual/operator pins are never silent and are never
|
|
23930
|
+
// touched. Only silent status pins reach here as an OUR-pin match, so the
|
|
23931
|
+
// ownership check is the guard.
|
|
23932
|
+
//
|
|
23933
|
+
// Race tolerance: the service update can arrive before `reconcileStatusPin`
|
|
23934
|
+
// has stored the new PinState (the pin API call resolves, Telegram emits the
|
|
23935
|
+
// service message, and only then does the reconcile write the Map). A single
|
|
23936
|
+
// short retry covers that window; if it's still not one of ours, we leave the
|
|
23937
|
+
// service message alone.
|
|
23938
|
+
bot.on('message:pinned_message', async ctx => {
|
|
23939
|
+
const pinnedId = ctx.msg.pinned_message?.message_id
|
|
23940
|
+
if (pinnedId == null) return
|
|
23941
|
+
const chatId = String(ctx.chat.id)
|
|
23942
|
+
const serviceMsgId = ctx.msg.message_id
|
|
23943
|
+
|
|
23944
|
+
// Chat-scoped ownership (see pinnedMessageIsOurs): the match requires BOTH
|
|
23945
|
+
// the messageId AND that the tracked entry lives in THIS chat, so a pin id
|
|
23946
|
+
// colliding across chats can't delete a foreign (e.g. operator-manual) pin
|
|
23947
|
+
// notice. statusPinChatIds is the companion pinKey→chatId map written on
|
|
23948
|
+
// every desired-pinned reconcile.
|
|
23949
|
+
const trackedPins = (): TrackedStatusPin[] => {
|
|
23950
|
+
const out: TrackedStatusPin[] = []
|
|
23951
|
+
for (const [pinKey, state] of statusPinState) {
|
|
23952
|
+
const c = statusPinChatIds.get(pinKey)
|
|
23953
|
+
if (c != null) out.push({ chatId: c, messageId: state.messageId })
|
|
23954
|
+
}
|
|
23955
|
+
return out
|
|
23956
|
+
}
|
|
23957
|
+
const isOurs = () => pinnedMessageIsOurs(trackedPins(), chatId, pinnedId)
|
|
23958
|
+
|
|
23959
|
+
if (!isOurs()) {
|
|
23960
|
+
// Tolerate the reconcile-store race: wait briefly, then re-check once.
|
|
23961
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
23962
|
+
if (!isOurs()) return
|
|
23963
|
+
}
|
|
23964
|
+
|
|
23965
|
+
try {
|
|
23966
|
+
await robustApiCall(
|
|
23967
|
+
() => lockedBot.api.deleteMessage(chatId, serviceMsgId),
|
|
23968
|
+
{ chat_id: chatId, verb: 'status-pin.delete-service-message' },
|
|
23969
|
+
)
|
|
23970
|
+
} catch (err) {
|
|
23971
|
+
// Best-effort: a failure to delete the service message is cosmetic only —
|
|
23972
|
+
// the "pinned a message" line just stays. The most likely cause in a
|
|
23973
|
+
// supergroup/forum is the bot lacking can_delete_messages admin right, so
|
|
23974
|
+
// surface a concise one-liner (robustApiCall rethrows this case without
|
|
23975
|
+
// logging a reason) rather than swallowing silently — an operator sees WHY.
|
|
23976
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
23977
|
+
process.stderr.write(
|
|
23978
|
+
`telegram gateway: status-pin: could not delete pin service message ` +
|
|
23979
|
+
`(chat=${chatId} msg=${serviceMsgId}) — likely missing can_delete_messages ` +
|
|
23980
|
+
`admin right in this chat: ${msg}\n`,
|
|
23981
|
+
)
|
|
23982
|
+
}
|
|
23983
|
+
})
|
|
23984
|
+
|
|
22630
23985
|
// ─── Reaction-trigger runtime state (#1074) ──────────────────────────────
|
|
22631
23986
|
//
|
|
22632
23987
|
// Bot-message reactions in the configured allowlist trigger a synthetic
|
|
@@ -23562,6 +24917,40 @@ void (async () => {
|
|
|
23562
24917
|
// tracks the live turn from there.
|
|
23563
24918
|
try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
|
|
23564
24919
|
|
|
24920
|
+
// Strip stale permission cards from prior gateway session. Any entry
|
|
24921
|
+
// still in the store was never resolved (gateway died before the
|
|
24922
|
+
// operator tapped or the reaper ran). The operator might have seen
|
|
24923
|
+
// those cards and tapped them — if so, they got STALE_TAP_NOTICE
|
|
24924
|
+
// ("already resolved") which is misleading. Edit the messages to
|
|
24925
|
+
// remove the keyboard and show a clear "restarted" notice instead.
|
|
24926
|
+
void (async () => {
|
|
24927
|
+
const stale = permCardStore.loadAll()
|
|
24928
|
+
if (stale.length === 0) return
|
|
24929
|
+
process.stderr.write(
|
|
24930
|
+
`telegram gateway: boot-sweep: stripping ${stale.length} stale permission card(s) from prior gateway session\n`,
|
|
24931
|
+
)
|
|
24932
|
+
for (const card of stale) {
|
|
24933
|
+
const toolLabel = card.toolName ?? 'unknown tool'
|
|
24934
|
+
const notice = `🔒 **${toolLabel}**\n\n⚠️ *Gateway restarted — this request is no longer active. Ask your agent to try again if needed.*`
|
|
24935
|
+
try {
|
|
24936
|
+
// allow-raw-bot-api: targeted by message_id; no thread needed; fire-and-forget boot sweep
|
|
24937
|
+
await bot.api.editMessageText(
|
|
24938
|
+
card.chatId,
|
|
24939
|
+
card.messageId,
|
|
24940
|
+
richMessage(notice),
|
|
24941
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
24942
|
+
)
|
|
24943
|
+
} catch (err) {
|
|
24944
|
+
// Card may already be deleted, edited, or in an inaccessible chat — benign
|
|
24945
|
+
process.stderr.write(
|
|
24946
|
+
`telegram gateway: boot-sweep: stale-card strip failed ` +
|
|
24947
|
+
`${card.chatId}:${card.messageId}: ${(err as Error).message}\n`,
|
|
24948
|
+
)
|
|
24949
|
+
}
|
|
24950
|
+
}
|
|
24951
|
+
permCardStore.clear()
|
|
24952
|
+
})()
|
|
24953
|
+
|
|
23565
24954
|
// Boot-time pin sweep
|
|
23566
24955
|
try {
|
|
23567
24956
|
const bootAccess = loadAccess()
|
|
@@ -24147,6 +25536,8 @@ void (async () => {
|
|
|
24147
25536
|
elapsedMs: durationMs,
|
|
24148
25537
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
24149
25538
|
})
|
|
25539
|
+
// Status-pin: worker done — drop its pin.
|
|
25540
|
+
reconcileWorkerPin(agentId, null, false)
|
|
24150
25541
|
}
|
|
24151
25542
|
return
|
|
24152
25543
|
}
|
|
@@ -24164,6 +25555,8 @@ void (async () => {
|
|
|
24164
25555
|
elapsedMs: durationMs,
|
|
24165
25556
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
24166
25557
|
})
|
|
25558
|
+
// Status-pin: worker done — drop its pin.
|
|
25559
|
+
reconcileWorkerPin(agentId, null, false)
|
|
24167
25560
|
}
|
|
24168
25561
|
|
|
24169
25562
|
const handbackOrigin = resolveSubagentOriginChat(agentId)
|
|
@@ -24291,9 +25684,10 @@ void (async () => {
|
|
|
24291
25684
|
})
|
|
24292
25685
|
if (surface === 'worker-feed') {
|
|
24293
25686
|
const origin = resolveSubagentOriginChat(agentId)
|
|
25687
|
+
const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
|
|
24294
25688
|
void workerActivityFeed?.update(
|
|
24295
25689
|
agentId,
|
|
24296
|
-
|
|
25690
|
+
wkChat,
|
|
24297
25691
|
{
|
|
24298
25692
|
description: dispatch.feedDescription,
|
|
24299
25693
|
lastTool,
|
|
@@ -24303,7 +25697,7 @@ void (async () => {
|
|
|
24303
25697
|
state: 'running',
|
|
24304
25698
|
},
|
|
24305
25699
|
origin?.threadId,
|
|
24306
|
-
)
|
|
25700
|
+
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|
|
24307
25701
|
return
|
|
24308
25702
|
}
|
|
24309
25703
|
if (surface !== 'nest') return // 'skip' — orphan-status off
|
|
@@ -24425,9 +25819,10 @@ void (async () => {
|
|
|
24425
25819
|
// is gone — see resolveSubagentOriginChat).
|
|
24426
25820
|
if (workerFeedEnabled) {
|
|
24427
25821
|
const origin = resolveSubagentOriginChat(agentId)
|
|
25822
|
+
const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
|
|
24428
25823
|
void workerActivityFeed?.update(
|
|
24429
25824
|
agentId,
|
|
24430
|
-
|
|
25825
|
+
wkChat,
|
|
24431
25826
|
{
|
|
24432
25827
|
description: dispatch.feedDescription,
|
|
24433
25828
|
lastTool,
|
|
@@ -24437,7 +25832,7 @@ void (async () => {
|
|
|
24437
25832
|
state: 'running',
|
|
24438
25833
|
},
|
|
24439
25834
|
origin?.threadId,
|
|
24440
|
-
)
|
|
25835
|
+
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|
|
24441
25836
|
return
|
|
24442
25837
|
}
|
|
24443
25838
|
|