switchroom 0.16.28 → 0.16.38
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 +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60648 -59800
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- 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 +1293 -97
- 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/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/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/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -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 +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- 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 +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -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/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/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- 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/rollout-status-wiring.test.ts +81 -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/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- 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/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -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 +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -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 +13 -4
|
@@ -48,6 +48,26 @@ 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
|
+
|
|
66
|
+
/** Fleet default TTS speed when voice_out is enabled but speed is unset —
|
|
67
|
+
* slightly brisker than neutral (Ken's chosen default). */
|
|
68
|
+
const VOICE_OUT_DEFAULT_SPEED = 1.1
|
|
69
|
+
import { normalizeForSpeech } from '../voice-normalize-text.js'
|
|
70
|
+
import { synthesizeViaOpenAi } from '../voice-synthesize.js'
|
|
51
71
|
import {
|
|
52
72
|
createTelegraphAccount,
|
|
53
73
|
createTelegraphPage,
|
|
@@ -65,6 +85,9 @@ import {
|
|
|
65
85
|
import { StatusReactionController } from '../status-reactions.js'
|
|
66
86
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
67
87
|
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
88
|
+
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
89
|
+
import type { PinState, DesiredPin } from '../status-pin.js'
|
|
90
|
+
import { decidePinAction } from '../status-pin.js'
|
|
68
91
|
import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
|
|
69
92
|
import { parseSourceMessageId } from './source-message-id.js'
|
|
70
93
|
import {
|
|
@@ -73,11 +96,14 @@ import {
|
|
|
73
96
|
duplicateDenyMessage,
|
|
74
97
|
isRecentTimeoutDuplicate,
|
|
75
98
|
PERMISSION_TTL_MS,
|
|
99
|
+
approvalTtlMs,
|
|
76
100
|
ttlForTool,
|
|
77
101
|
buildTimedOutCardEdits,
|
|
78
102
|
STALE_TAP_NOTICE,
|
|
79
103
|
type PermissionCardRef,
|
|
80
104
|
} from './permission-timeout.js'
|
|
105
|
+
import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
|
|
106
|
+
import { createPermissionCardStore } from './permission-card-store.js'
|
|
81
107
|
import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
|
|
82
108
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
83
109
|
import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, type SessionActivityHeader } from '../tool-activity-summary.js'
|
|
@@ -140,6 +166,10 @@ import {
|
|
|
140
166
|
runMicrosoftConnectPoll,
|
|
141
167
|
} from './microsoft-connect-flow.js'
|
|
142
168
|
import { resolveAuthBrokerSocketPath } from '../../src/auth/broker/client.js'
|
|
169
|
+
import { materializeVoiceKey } from '../../src/telegram/materialize-voice-key.js'
|
|
170
|
+
import { materializeSidecarToken } from '../../src/telegram/materialize-sidecar-token.js'
|
|
171
|
+
import { loadHostCapabilities } from '../../src/setup/host-capabilities.js'
|
|
172
|
+
import type { VoiceEngine } from '../../src/setup/gpu-detect.js'
|
|
143
173
|
import { createFleetFallbackGate } from '../fleet-fallback-gate.js'
|
|
144
174
|
import { createFleetFallbackResumeGate } from '../fleet-fallback-resume.js'
|
|
145
175
|
import { resolveExhaustUntil } from './exhaust-until.js'
|
|
@@ -195,7 +225,7 @@ const REPLY_TO_TEXT_MAX = 200
|
|
|
195
225
|
const SILENT_END_FALLBACK_TEXT =
|
|
196
226
|
'⚠️ The agent finished working but didn’t send a reply — your last ' +
|
|
197
227
|
'message may not have been answered. Please try asking again.'
|
|
198
|
-
import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
228
|
+
import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
199
229
|
import { richMessage } from '../rich-send.js'
|
|
200
230
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
201
231
|
import {
|
|
@@ -341,6 +371,15 @@ import {
|
|
|
341
371
|
obligationEscalationText,
|
|
342
372
|
} from './obligation-ledger.js'
|
|
343
373
|
import { loadObligations, persistObligations } from './obligation-store.js'
|
|
374
|
+
import {
|
|
375
|
+
loadStatusPins,
|
|
376
|
+
pinnedMessageIsOurs,
|
|
377
|
+
reconcileAndPersistStatusPin,
|
|
378
|
+
runStatusPinBootCleanup,
|
|
379
|
+
type PersistedStatusPin,
|
|
380
|
+
type StatusPinPersistOp,
|
|
381
|
+
type TrackedStatusPin,
|
|
382
|
+
} from './status-pin-store.js'
|
|
344
383
|
import { driveEscalation } from './escalation-drive.js'
|
|
345
384
|
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
346
385
|
import { createInboundSpool } from './inbound-spool.js'
|
|
@@ -362,6 +401,9 @@ import {
|
|
|
362
401
|
} from './emission-authority.js'
|
|
363
402
|
import { CurrentTurnMap } from './current-turn-map.js'
|
|
364
403
|
import { resolveAnswerThreadId } from './answer-thread-resolve.js'
|
|
404
|
+
import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
405
|
+
import { decideObligationTurnEnd } from './obligation-turn-end.js'
|
|
406
|
+
import { maybeRotate } from './turns-jsonl-rotate.js'
|
|
365
407
|
import {
|
|
366
408
|
createDeliveryQueue,
|
|
367
409
|
trackDelivery,
|
|
@@ -385,6 +427,7 @@ import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-d
|
|
|
385
427
|
import { maybeFireWarmup } from './prefix-warmup.js'
|
|
386
428
|
import {
|
|
387
429
|
buildVaultGrantApprovedInbound,
|
|
430
|
+
buildVaultGrantApprovedCardText,
|
|
388
431
|
buildVaultGrantDeniedInbound,
|
|
389
432
|
buildVaultSaveCompletedInbound,
|
|
390
433
|
buildVaultSaveFailedInbound,
|
|
@@ -428,6 +471,8 @@ import type {
|
|
|
428
471
|
QuotaWallDetectedMessage,
|
|
429
472
|
PostSkillProposalMessage,
|
|
430
473
|
PermissionEvent,
|
|
474
|
+
RolloutStatusPostMessage,
|
|
475
|
+
RolloutStatusEditMessage,
|
|
431
476
|
} from './ipc-protocol.js'
|
|
432
477
|
import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
|
|
433
478
|
import { buildReactionDispatchInbound, evaluateReactionDispatch, resolveReactionDispatchConfig, type ReactionDispatchResolvedConfig } from './reaction-dispatch.js'
|
|
@@ -599,6 +644,7 @@ process.on('beforeExit', () => {
|
|
|
599
644
|
|
|
600
645
|
// ─── Env + state dir ──────────────────────────────────────────────────────
|
|
601
646
|
const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
647
|
+
const permCardStore = createPermissionCardStore(STATE_DIR)
|
|
602
648
|
const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
|
603
649
|
const APPROVED_DIR = join(STATE_DIR, 'approved')
|
|
604
650
|
const ENV_FILE = join(STATE_DIR, '.env')
|
|
@@ -950,12 +996,42 @@ type Access = {
|
|
|
950
996
|
/** Voice-in transcription config (#578 spike). When `enabled` is
|
|
951
997
|
* true and provider is 'openai', inbound voice/audio messages are
|
|
952
998
|
* downloaded and transcribed via Whisper, then surface as the
|
|
953
|
-
* user's inbound text. API key
|
|
954
|
-
*
|
|
999
|
+
* user's inbound text. The provider API key is a `vault:` reference
|
|
1000
|
+
* (`api_key`, default `vault:openai/api-key`) resolved through the
|
|
1001
|
+
* vault broker at use-time — never read from a plaintext file. Off by
|
|
1002
|
+
* default. */
|
|
955
1003
|
voice_in?: {
|
|
956
1004
|
enabled?: boolean
|
|
957
1005
|
provider?: 'openai'
|
|
958
1006
|
language?: string
|
|
1007
|
+
api_key?: string
|
|
1008
|
+
}
|
|
1009
|
+
/** Outbound spoken replies via TTS (PR-C2). When enabled, the agent's
|
|
1010
|
+
* text reply is synthesized into an OGG/Opus voice note and sent
|
|
1011
|
+
* alongside (or instead of) the text, per `reply_mode`. The 'kokoro'
|
|
1012
|
+
* engine uses the local voice sidecar (POST /tts) and is only active
|
|
1013
|
+
* when the host voice verdict is local (SWITCHROOM_VOICE_ENGINE ===
|
|
1014
|
+
* 'local'); 'openai' is an honest-exception cloud path gated on an
|
|
1015
|
+
* `api_key` vault ref. Voice is best-effort and fully non-fatal — any
|
|
1016
|
+
* TTS error falls back to the text reply. ONE voice note per response:
|
|
1017
|
+
* the reply is speech-normalized (markdown/symbols stripped) and, for the
|
|
1018
|
+
* kokoro sidecar, sent in a SINGLE /tts call that returns one concatenated
|
|
1019
|
+
* file. Off by default. */
|
|
1020
|
+
voice_out?: {
|
|
1021
|
+
enabled?: boolean
|
|
1022
|
+
engine?: 'kokoro' | 'openai'
|
|
1023
|
+
voice?: string
|
|
1024
|
+
reply_mode?: 'voice+text' | 'voice-only' | 'on-demand'
|
|
1025
|
+
/** Kokoro-engine playback speed. Clamped to 0.5–2.0; defaults to 1.1
|
|
1026
|
+
* when voice_out is enabled but speed is unset (the fleet default —
|
|
1027
|
+
* slightly brisker than neutral). Ignored by the OpenAI path. */
|
|
1028
|
+
speed?: number
|
|
1029
|
+
/** OpenAI-engine only: per-voice-note chunk size (default 600) used to
|
|
1030
|
+
* split a long reply across sequential notes, since OpenAI's TTS input
|
|
1031
|
+
* has a hard cap. Ignored by the kokoro path, which sends the whole
|
|
1032
|
+
* reply in one call (the sidecar owns length + concatenation). */
|
|
1033
|
+
max_chars?: number
|
|
1034
|
+
api_key?: string
|
|
959
1035
|
}
|
|
960
1036
|
/** Telegraph long-reply publishing (#579). When enabled, replies
|
|
961
1037
|
* above `threshold` chars publish to Telegraph and the agent's
|
|
@@ -1049,6 +1125,7 @@ function readAccessFile(): Access {
|
|
|
1049
1125
|
// `access.telegraph`, `access.stickers`) silently see undefined.
|
|
1050
1126
|
stickers: parsed.stickers,
|
|
1051
1127
|
voice_in: parsed.voice_in,
|
|
1128
|
+
voice_out: parsed.voice_out,
|
|
1052
1129
|
telegraph: parsed.telegraph,
|
|
1053
1130
|
}
|
|
1054
1131
|
} catch (err) {
|
|
@@ -3206,6 +3283,19 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
3206
3283
|
// successor turn; the timer's own turnId match is the second guard, this is
|
|
3207
3284
|
// the first. Idempotent — a no-op when no timer is registered.
|
|
3208
3285
|
stopEarlyLivenessOpen(key as string)
|
|
3286
|
+
// Status-pin: turn end is the canonical unpin point for the foreground
|
|
3287
|
+
// pin. `purgeReactionTracking` is the single turn-end owner (all normal /
|
|
3288
|
+
// abnormal exit branches funnel here), so this is the one place the
|
|
3289
|
+
// foreground status pin is dropped. Idempotent: a no-op when nothing was
|
|
3290
|
+
// pinned (trivial turn that never opened a status message). The
|
|
3291
|
+
// drop-on-unpin contract in reconcilePin guarantees state clears even if
|
|
3292
|
+
// the unpin API throws — a stuck pin can never outlive its turn.
|
|
3293
|
+
{
|
|
3294
|
+
const pinChatId = endingTurn != null
|
|
3295
|
+
? endingTurn.sessionChatId
|
|
3296
|
+
: chatIdOfChatKey(key as _ChatKey)
|
|
3297
|
+
void reconcileStatusPin(`fg:${key}`, pinChatId, { pinned: false })
|
|
3298
|
+
}
|
|
3209
3299
|
if (msgInfo) {
|
|
3210
3300
|
const agentDir = resolveAgentDirFromEnv()
|
|
3211
3301
|
if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
|
|
@@ -3401,7 +3491,20 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
|
3401
3491
|
status: turn.finalAnswerDelivered ? 'complete' : 'no_reply',
|
|
3402
3492
|
turn_id: turn.turnId,
|
|
3403
3493
|
}) + '\n'
|
|
3404
|
-
|
|
3494
|
+
const turnsPath = '/state/agent/turns.jsonl'
|
|
3495
|
+
// Size-cap rotation: keep at most one rotated generation so the file can't
|
|
3496
|
+
// grow unbounded on a long-lived agent. Best-effort (never throws).
|
|
3497
|
+
maybeRotate(turnsPath, {
|
|
3498
|
+
statSize: (p) => {
|
|
3499
|
+
try {
|
|
3500
|
+
return statSync(p).size
|
|
3501
|
+
} catch {
|
|
3502
|
+
return undefined
|
|
3503
|
+
}
|
|
3504
|
+
},
|
|
3505
|
+
rename: (from, to) => renameSync(from, to),
|
|
3506
|
+
})
|
|
3507
|
+
appendFileSync(turnsPath, rec)
|
|
3405
3508
|
} catch {
|
|
3406
3509
|
// best-effort — never let metrics emission break turn teardown
|
|
3407
3510
|
}
|
|
@@ -3457,7 +3560,7 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
|
3457
3560
|
// At turn_end with replyCalled=true the model explicitly signalled completion
|
|
3458
3561
|
// AND replied, so the obligation is satisfied regardless of finalAnswerDelivered.
|
|
3459
3562
|
if (OBLIGATION_LEDGER_ENABLED) {
|
|
3460
|
-
if (turn.finalAnswerDelivered
|
|
3563
|
+
if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === 'close') {
|
|
3461
3564
|
obligationLedger.close(turn.turnId)
|
|
3462
3565
|
} else {
|
|
3463
3566
|
// Turn ended WITHOUT any reply (no ack, no answer). If this turn was
|
|
@@ -4671,7 +4774,10 @@ interface PendingVaultRequestSave {
|
|
|
4671
4774
|
staged_at: number
|
|
4672
4775
|
}
|
|
4673
4776
|
const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
|
|
4674
|
-
|
|
4777
|
+
// Gateway-side reap window for a staged vault-save card. Tracks the operator
|
|
4778
|
+
// approval-card lifetime (config-driven, 60-min default) so the reap never
|
|
4779
|
+
// races ahead of the card the operator is still looking at.
|
|
4780
|
+
const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
|
|
4675
4781
|
function sweepPendingVaultRequestSaves(): void {
|
|
4676
4782
|
const cutoff = Date.now() - VAULT_REQUEST_SAVE_TTL_MS
|
|
4677
4783
|
for (const [k, v] of pendingVaultRequestSaves) {
|
|
@@ -4716,7 +4822,9 @@ interface PendingVaultRequestAccess {
|
|
|
4716
4822
|
staged_at: number
|
|
4717
4823
|
}
|
|
4718
4824
|
const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
|
|
4719
|
-
|
|
4825
|
+
// Gateway-side reap window for a staged vault-access card. Tracks the operator
|
|
4826
|
+
// approval-card lifetime (config-driven, 60-min default) — see approvalTtlMs.
|
|
4827
|
+
const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
|
|
4720
4828
|
function sweepPendingVaultRequestAccesses(): void {
|
|
4721
4829
|
const cutoff = Date.now() - VAULT_REQUEST_ACCESS_TTL_MS
|
|
4722
4830
|
for (const [k, v] of pendingVaultRequestAccesses) {
|
|
@@ -5024,6 +5132,7 @@ const pendingStateReaper = setInterval(() => {
|
|
|
5024
5132
|
`${timeoutMinutes}m)\n`,
|
|
5025
5133
|
)
|
|
5026
5134
|
pendingPermissions.delete(k)
|
|
5135
|
+
permCardStore.remove(k)
|
|
5027
5136
|
}
|
|
5028
5137
|
}
|
|
5029
5138
|
// Drop no-repeat suppression entries past the safety-cap window (the primary
|
|
@@ -5481,6 +5590,273 @@ const CLEAR_STATUS_ON_COMPLETION = (() => {
|
|
|
5481
5590
|
return v === '1' || v === 'true' || v === 'on' || v === 'yes'
|
|
5482
5591
|
})()
|
|
5483
5592
|
|
|
5593
|
+
// Whether to SILENTLY pin the already-rendered status message while its work
|
|
5594
|
+
// is in-flight (auto-unpinned on completion). Default ON (silent + low-risk):
|
|
5595
|
+
// keeps in-flight work in view when the conversation scrolls past it. This is
|
|
5596
|
+
// the ONE sanctioned pin under `chat-is-the-single-source-of-truth` — it pins
|
|
5597
|
+
// a message the chat ALREADY rendered (the per-turn activity/status message,
|
|
5598
|
+
// the `🛠 Worker` message), never a new parallel surface, never with a device
|
|
5599
|
+
// buzz. Opt OUT per agent via channels.telegram.pin_status_while_working: false
|
|
5600
|
+
// (→ this env). See status-pin.ts / status-pin-driver.ts and
|
|
5601
|
+
// `reference/invariants.md` § chat-is-the-single-source-of-truth.
|
|
5602
|
+
const PIN_STATUS_WHILE_WORKING = (() => {
|
|
5603
|
+
const raw = process.env.SWITCHROOM_TG_PIN_STATUS_WHILE_WORKING
|
|
5604
|
+
if (raw == null) return true // default ON
|
|
5605
|
+
const v = raw.trim().toLowerCase()
|
|
5606
|
+
return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
|
|
5607
|
+
})()
|
|
5608
|
+
|
|
5609
|
+
// ─── Status-pin: single-owner state + reconcile ────────────────────────────
|
|
5610
|
+
// EXACTLY ONE owner of pin state. Every desired-state change for a key routes
|
|
5611
|
+
// through `reconcileStatusPin`, which computes ONE action (pin|unpin|noop) via
|
|
5612
|
+
// the pure `decidePinAction` and executes it through `reconcilePin` (which
|
|
5613
|
+
// drops the claim on unpin even if the API throws — so pin state can never get
|
|
5614
|
+
// stuck). Two desired-state sources feed distinct keys:
|
|
5615
|
+
// - foreground: `fg:<statusKey>` — pinned while the per-turn activity message
|
|
5616
|
+
// is in-flight; unpinned at the canonical turn-end (purgeReactionTracking).
|
|
5617
|
+
// - background: `wk:<agentId>` — pinned while the `🛠 Worker` message runs;
|
|
5618
|
+
// unpinned on that worker's completion (worker-feed finish).
|
|
5619
|
+
// It does NOT touch the reply / stream_reply send handlers (the v1 bug was send
|
|
5620
|
+
// handlers unconditionally unpinning on every send) and runs NO polling
|
|
5621
|
+
// watchdog / getChat().pinned_message reconciler.
|
|
5622
|
+
const statusPinState = new Map<string, PinState>()
|
|
5623
|
+
// Companion registry: pinKey → chatId, so the pre-restart sweep can unpin
|
|
5624
|
+
// owned pins without threading the chat id through every call site. Written on
|
|
5625
|
+
// every desired-pinned reconcile, cleared alongside the state on unpin.
|
|
5626
|
+
const statusPinChatIds = new Map<string, string>()
|
|
5627
|
+
|
|
5628
|
+
// Durable snapshot of the pin claim set on the persistent per-agent volume
|
|
5629
|
+
// (STATE_DIR = /state/agent/telegram in prod). Closes the crash hole: the
|
|
5630
|
+
// in-memory Maps alone empty on restart, so a status pin left dangling by a
|
|
5631
|
+
// crashed session is never unpinned and the service-message-deletion handler
|
|
5632
|
+
// can't recognise it as ours. Every reconcile persists the current claim set;
|
|
5633
|
+
// boot cleanup (statusPinBootCleanup, wired after lockedBot is defined) unpins
|
|
5634
|
+
// each persisted entry and clears the store. STATIC mode and feature-off skip
|
|
5635
|
+
// disk. Mirrors obligation-store.ts.
|
|
5636
|
+
const STATUS_PIN_STORE_PATH = join(STATE_DIR, 'status-pins.json')
|
|
5637
|
+
const statusPinStoreFs = {
|
|
5638
|
+
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
5639
|
+
writeFileSync: (p: string, d: string) => writeFileSync(p, d),
|
|
5640
|
+
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
5641
|
+
existsSync: (p: string) => existsSync(p),
|
|
5642
|
+
}
|
|
5643
|
+
const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
|
|
5644
|
+
|
|
5645
|
+
// The full live claim set as persisted rows (confirmed pins), from the Maps.
|
|
5646
|
+
function snapshotStatusPins(): PersistedStatusPin[] {
|
|
5647
|
+
const snapshot: PersistedStatusPin[] = []
|
|
5648
|
+
for (const [pinKey, state] of statusPinState) {
|
|
5649
|
+
const chatId = statusPinChatIds.get(pinKey)
|
|
5650
|
+
if (chatId == null) continue
|
|
5651
|
+
snapshot.push({ pinKey, chatId, messageId: state.messageId })
|
|
5652
|
+
}
|
|
5653
|
+
return snapshot
|
|
5654
|
+
}
|
|
5655
|
+
|
|
5656
|
+
// The live claim set EXCLUDING one key — used by reconcileAndPersistStatusPin so
|
|
5657
|
+
// it can rewrite the whole set atomically while it flips that one key's record
|
|
5658
|
+
// between pending / confirmed / absent.
|
|
5659
|
+
function snapshotStatusPinsExcept(exceptKey: string): PersistedStatusPin[] {
|
|
5660
|
+
return snapshotStatusPins().filter((p) => p.pinKey !== exceptKey)
|
|
5661
|
+
}
|
|
5662
|
+
|
|
5663
|
+
// The Bot API surface the pin driver needs. `lockedBot` is defined later; wrap
|
|
5664
|
+
// lazily so this helper can be declared alongside the state it owns.
|
|
5665
|
+
function statusPinApi(): PinBotApi {
|
|
5666
|
+
return {
|
|
5667
|
+
pinChatMessage: (chat_id, message_id, opts) =>
|
|
5668
|
+
robustApiCall(
|
|
5669
|
+
() => lockedBot.api.pinChatMessage(chat_id, message_id, opts),
|
|
5670
|
+
{ chat_id: String(chat_id), verb: 'status-pin.pin' },
|
|
5671
|
+
),
|
|
5672
|
+
unpinChatMessage: (chat_id, message_id) =>
|
|
5673
|
+
robustApiCall(
|
|
5674
|
+
() => lockedBot.api.unpinChatMessage(chat_id, message_id),
|
|
5675
|
+
{ chat_id: String(chat_id), verb: 'status-pin.unpin' },
|
|
5676
|
+
),
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5679
|
+
|
|
5680
|
+
/**
|
|
5681
|
+
* Boot-time orphan cleanup — thin gateway wrapper over the pure
|
|
5682
|
+
* `runStatusPinBootCleanup` (which owns the load → best-effort-unpin →
|
|
5683
|
+
* empty-store contract). Binds the live fs seam, the robust unpin api, and the
|
|
5684
|
+
* gateway logger.
|
|
5685
|
+
*
|
|
5686
|
+
* MUST run ONLY after this gateway wins the startup mutex (the store is a
|
|
5687
|
+
* shared per-agent file; a losing double-boot would unpin the live holder's
|
|
5688
|
+
* legitimate pins). Runs before any new pin claim is written, so the emptied
|
|
5689
|
+
* store leaves the service-message handler's ownership check with no false
|
|
5690
|
+
* positives.
|
|
5691
|
+
*/
|
|
5692
|
+
async function statusPinBootCleanup(): Promise<void> {
|
|
5693
|
+
if (!statusPinPersistEnabled) return
|
|
5694
|
+
const api = statusPinApi()
|
|
5695
|
+
const { cleared, total } = await runStatusPinBootCleanup({
|
|
5696
|
+
path: STATUS_PIN_STORE_PATH,
|
|
5697
|
+
fs: statusPinStoreFs,
|
|
5698
|
+
unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
|
|
5699
|
+
})
|
|
5700
|
+
if (total > 0) {
|
|
5701
|
+
process.stderr.write(
|
|
5702
|
+
`telegram gateway: status-pin: cleared ${cleared}/${total} ` +
|
|
5703
|
+
`orphaned pin(s) from a prior session\n`,
|
|
5704
|
+
)
|
|
5705
|
+
}
|
|
5706
|
+
}
|
|
5707
|
+
// NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
|
|
5708
|
+
// The status-pin store is a SHARED per-agent file, and cleanup issues real
|
|
5709
|
+
// unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
|
|
5710
|
+
// that shared state — its unpins would strip pins the STILL-ALIVE holder
|
|
5711
|
+
// legitimately owns. So the call is gated on winning the startup mutex
|
|
5712
|
+
// (top-level await below); see the acquireStartupLock block.
|
|
5713
|
+
|
|
5714
|
+
/**
|
|
5715
|
+
* The single entry point that mutates pin state for `pinKey`. Serialises
|
|
5716
|
+
* nothing itself — callers fire-and-forget; the pure decision + drop-on-unpin
|
|
5717
|
+
* contract keep state consistent. No-op when the feature is disabled.
|
|
5718
|
+
*/
|
|
5719
|
+
async function reconcileStatusPin(
|
|
5720
|
+
pinKey: string,
|
|
5721
|
+
chatId: string,
|
|
5722
|
+
desired: DesiredPin,
|
|
5723
|
+
): Promise<void> {
|
|
5724
|
+
// Fire-and-forget hard boundary. Most callers invoke this as
|
|
5725
|
+
// `void reconcileStatusPin(...)` (auto status-pin is best-effort — it must
|
|
5726
|
+
// never affect turn flow). reconcilePin already swallows pin/unpin API
|
|
5727
|
+
// errors via onError, but the surrounding persistence + Map bookkeeping
|
|
5728
|
+
// (and any future edge) could still reject the returned promise. A rejected
|
|
5729
|
+
// fire-and-forget promise becomes a process-level `unhandledRejection` which
|
|
5730
|
+
// the gateway's handler crashes on — that is exactly how a benign
|
|
5731
|
+
// "not enough rights to manage pinned messages" 400 in a supergroup took the
|
|
5732
|
+
// whole gateway down (marko, 2026-07-01). Auto status-pin is cosmetic; it
|
|
5733
|
+
// must NEVER be able to crash the gateway. Any throw here is logged and
|
|
5734
|
+
// absorbed. (The `pin_message` MCP tool still surfaces failures to the agent
|
|
5735
|
+
// as a normal tool-error — that path is `executePinMessage`, not this one.)
|
|
5736
|
+
try {
|
|
5737
|
+
await reconcileStatusPinInner(pinKey, chatId, desired)
|
|
5738
|
+
} catch (err) {
|
|
5739
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
5740
|
+
process.stderr.write(
|
|
5741
|
+
`telegram gateway: status-pin reconcile absorbed error ` +
|
|
5742
|
+
`(key=${pinKey} chat=${chatId}): ${msg}\n`,
|
|
5743
|
+
)
|
|
5744
|
+
}
|
|
5745
|
+
}
|
|
5746
|
+
|
|
5747
|
+
async function reconcileStatusPinInner(
|
|
5748
|
+
pinKey: string,
|
|
5749
|
+
chatId: string,
|
|
5750
|
+
desired: DesiredPin,
|
|
5751
|
+
): Promise<void> {
|
|
5752
|
+
if (!PIN_STATUS_WHILE_WORKING) return
|
|
5753
|
+
if (chatId.length === 0) return
|
|
5754
|
+
const prev = statusPinState.get(pinKey) ?? null
|
|
5755
|
+
|
|
5756
|
+
const runReconcile = () =>
|
|
5757
|
+
reconcilePin({
|
|
5758
|
+
api: statusPinApi(),
|
|
5759
|
+
chatId,
|
|
5760
|
+
prevState: prev,
|
|
5761
|
+
desired,
|
|
5762
|
+
onError: (phase, err) => {
|
|
5763
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
5764
|
+
process.stderr.write(
|
|
5765
|
+
`telegram gateway: status-pin ${phase} failed (key=${pinKey} chat=${chatId}): ${msg}\n`,
|
|
5766
|
+
)
|
|
5767
|
+
},
|
|
5768
|
+
})
|
|
5769
|
+
|
|
5770
|
+
// Classify the action so we persist INTENT before the pin API call. Only a
|
|
5771
|
+
// fresh `pin` of a message that isn't already our claim opens the leak window
|
|
5772
|
+
// (the API call actually pins something new); everything else (unpin, noop,
|
|
5773
|
+
// re-pin of the same id) clears / leaves the record and is safe to persist
|
|
5774
|
+
// after. See reconcileAndPersistStatusPin for the ordering rationale.
|
|
5775
|
+
const action = decidePinAction(prev, desired)
|
|
5776
|
+
const op: StatusPinPersistOp =
|
|
5777
|
+
action.kind === 'pin'
|
|
5778
|
+
? { kind: 'pin', messageId: action.messageId }
|
|
5779
|
+
: { kind: 'clear' }
|
|
5780
|
+
|
|
5781
|
+
if (!statusPinPersistEnabled) {
|
|
5782
|
+
// Persistence off (STATIC / feature-off): just reconcile + update Maps.
|
|
5783
|
+
const next = await runReconcile()
|
|
5784
|
+
if (next == null) {
|
|
5785
|
+
statusPinState.delete(pinKey)
|
|
5786
|
+
statusPinChatIds.delete(pinKey)
|
|
5787
|
+
} else {
|
|
5788
|
+
statusPinState.set(pinKey, next)
|
|
5789
|
+
statusPinChatIds.set(pinKey, chatId)
|
|
5790
|
+
}
|
|
5791
|
+
return
|
|
5792
|
+
}
|
|
5793
|
+
|
|
5794
|
+
// Persist-BEFORE-pin ordering lives in reconcileAndPersistStatusPin: for a
|
|
5795
|
+
// pin it writes a `pending` record first, then confirms it after the API call
|
|
5796
|
+
// lands (or drops it on failure). A crash in the window leaves a pending
|
|
5797
|
+
// record boot cleanup will unpin. In-memory Maps are updated from the result.
|
|
5798
|
+
const next = await reconcileAndPersistStatusPin({
|
|
5799
|
+
path: STATUS_PIN_STORE_PATH,
|
|
5800
|
+
fs: statusPinStoreFs,
|
|
5801
|
+
pinKey,
|
|
5802
|
+
chatId,
|
|
5803
|
+
op,
|
|
5804
|
+
snapshotOthers: () => snapshotStatusPinsExcept(pinKey),
|
|
5805
|
+
applyPin: runReconcile,
|
|
5806
|
+
})
|
|
5807
|
+
if (next == null) {
|
|
5808
|
+
statusPinState.delete(pinKey)
|
|
5809
|
+
statusPinChatIds.delete(pinKey)
|
|
5810
|
+
} else {
|
|
5811
|
+
statusPinState.set(pinKey, next)
|
|
5812
|
+
statusPinChatIds.set(pinKey, chatId)
|
|
5813
|
+
}
|
|
5814
|
+
}
|
|
5815
|
+
|
|
5816
|
+
/**
|
|
5817
|
+
* Background-worker desired-pin, driven off the live `🛠 Worker` message.
|
|
5818
|
+
* Reads the worker feed's current message_id (the EXISTING message — we pin
|
|
5819
|
+
* what the feed already rendered, never a new send) and reconciles a silent
|
|
5820
|
+
* pin while it's running / an unpin on completion. No-op until the feed has
|
|
5821
|
+
* actually painted a message for this worker (trivial sub-second workers stay
|
|
5822
|
+
* silent and are never pinned). Keyed `wk:<agentId>`.
|
|
5823
|
+
*/
|
|
5824
|
+
function reconcileWorkerPin(
|
|
5825
|
+
agentId: string,
|
|
5826
|
+
chatId: string | null,
|
|
5827
|
+
running: boolean,
|
|
5828
|
+
): void {
|
|
5829
|
+
if (!PIN_STATUS_WHILE_WORKING) return
|
|
5830
|
+
const key = `wk:${agentId}`
|
|
5831
|
+
if (!running) {
|
|
5832
|
+
// Unpin: recover the chat we pinned in (caller may not have it at
|
|
5833
|
+
// completion). No-op when nothing was pinned for this worker.
|
|
5834
|
+
const unpinChat = chatId ?? statusPinChatIds.get(key)
|
|
5835
|
+
if (unpinChat == null) return
|
|
5836
|
+
void reconcileStatusPin(key, unpinChat, { pinned: false })
|
|
5837
|
+
return
|
|
5838
|
+
}
|
|
5839
|
+
if (chatId == null) return
|
|
5840
|
+
const messageId = workerActivityFeed?.messageIdOf(agentId) ?? null
|
|
5841
|
+
if (messageId == null) return // no message painted yet — nothing to pin
|
|
5842
|
+
void reconcileStatusPin(key, chatId, { pinned: true, messageId })
|
|
5843
|
+
}
|
|
5844
|
+
|
|
5845
|
+
/** Unpin every owned status pin — used by the pre-restart sweep so a
|
|
5846
|
+
* crash / interrupt never leaves a permanent pin behind. Best-effort;
|
|
5847
|
+
* clears the claim regardless of the unpin outcome (drop-on-unpin). */
|
|
5848
|
+
async function unpinAllStatusPins(): Promise<void> {
|
|
5849
|
+
const keys = [...statusPinState.keys()]
|
|
5850
|
+
for (const key of keys) {
|
|
5851
|
+
const st = statusPinState.get(key)
|
|
5852
|
+
if (st == null) continue
|
|
5853
|
+
// Recover the chat id from the state map's companion key registry.
|
|
5854
|
+
const chatId = statusPinChatIds.get(key)
|
|
5855
|
+
if (chatId == null) { statusPinState.delete(key); continue }
|
|
5856
|
+
await reconcileStatusPin(key, chatId, { pinned: false })
|
|
5857
|
+
}
|
|
5858
|
+
}
|
|
5859
|
+
|
|
5484
5860
|
// Activity feed. The gateway streams a live "what it's doing" tool-activity
|
|
5485
5861
|
// feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
|
|
5486
5862
|
// call (flush-independent, so it stays real-time on fast/clustered-tool
|
|
@@ -5628,6 +6004,12 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
5628
6004
|
process.stderr.write(
|
|
5629
6005
|
`telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS}\n`,
|
|
5630
6006
|
)
|
|
6007
|
+
// We WON the startup mutex — this gateway is the sole live owner of the
|
|
6008
|
+
// shared per-agent status-pin store, so it's now safe to clean up orphaned
|
|
6009
|
+
// pins from a prior (dead) session. Gated here (not at import time) so a
|
|
6010
|
+
// LOSING double-boot never unpins the live holder's legitimate pins.
|
|
6011
|
+
// Fire-and-forget: cleanup is best-effort and must not block boot.
|
|
6012
|
+
void statusPinBootCleanup()
|
|
5631
6013
|
} catch (err) {
|
|
5632
6014
|
process.stderr.write(
|
|
5633
6015
|
`telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
|
|
@@ -5639,6 +6021,11 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
5639
6021
|
try {
|
|
5640
6022
|
writePidFile(GATEWAY_PID_PATH, { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS })
|
|
5641
6023
|
process.stderr.write(`telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS} (mutex-fallback)\n`)
|
|
6024
|
+
// Mutex was unavailable (link() unsupported fs); the legacy pid-file
|
|
6025
|
+
// probe + 409-retry loop is still the liveness guard on this path. A
|
|
6026
|
+
// successful writePidFile here means no live holder was detected, so
|
|
6027
|
+
// running orphan cleanup is consistent with the pre-mutex behaviour.
|
|
6028
|
+
void statusPinBootCleanup()
|
|
5642
6029
|
} catch (writeErr) {
|
|
5643
6030
|
process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
|
|
5644
6031
|
}
|
|
@@ -7118,6 +7505,14 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7118
7505
|
const pend = pendingPermissions.get(requestId)
|
|
7119
7506
|
if (pend && sent && typeof sent.message_id === 'number') {
|
|
7120
7507
|
pend.cards.push({ chatId, messageId: sent.message_id })
|
|
7508
|
+
permCardStore.add({
|
|
7509
|
+
requestId,
|
|
7510
|
+
chatId,
|
|
7511
|
+
messageId: sent.message_id,
|
|
7512
|
+
startedAt: pend.startedAt,
|
|
7513
|
+
toolName: pend.tool_name,
|
|
7514
|
+
cardText: pend.card_text,
|
|
7515
|
+
})
|
|
7121
7516
|
}
|
|
7122
7517
|
}).catch(e => {
|
|
7123
7518
|
process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
|
|
@@ -7556,6 +7951,81 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7556
7951
|
})
|
|
7557
7952
|
},
|
|
7558
7953
|
|
|
7954
|
+
// #2726 — hostd-initiated rollout status message. Part 1 uses this only for
|
|
7955
|
+
// the terminal ping; Part 2 uses the same post as the FIRST narration
|
|
7956
|
+
// message, then EDITs it (onRolloutStatusEdit) as later phases arrive. This
|
|
7957
|
+
// is an ORDINARY operator-DM message, NOT a pinned card — the framework
|
|
7958
|
+
// speaking a plain progress line in the chat. We reply with the message_id so
|
|
7959
|
+
// hostd can edit it later; a post failure replies ok:false (hostd then just
|
|
7960
|
+
// won't edit — the durable audit log remains the record).
|
|
7961
|
+
async onRolloutStatusPost(client: IpcClient, msg: RolloutStatusPostMessage) {
|
|
7962
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
7963
|
+
if (self && msg.agentName !== self) {
|
|
7964
|
+
process.stderr.write(
|
|
7965
|
+
`telegram gateway: rollout_status_post rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
7966
|
+
)
|
|
7967
|
+
try {
|
|
7968
|
+
client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: 'agent mismatch' })
|
|
7969
|
+
} catch { /* best effort */ }
|
|
7970
|
+
return
|
|
7971
|
+
}
|
|
7972
|
+
const operator = loadAccess().allowFrom[0]
|
|
7973
|
+
if (operator === undefined) {
|
|
7974
|
+
process.stderr.write(`telegram gateway: rollout_status_post — no operator chat (allowFrom empty)\n`)
|
|
7975
|
+
try {
|
|
7976
|
+
client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: 'no operator chat' })
|
|
7977
|
+
} catch { /* best effort */ }
|
|
7978
|
+
return
|
|
7979
|
+
}
|
|
7980
|
+
try {
|
|
7981
|
+
const sent = await robustApiCall(
|
|
7982
|
+
() =>
|
|
7983
|
+
// allow-raw-bot-api: rich progress line, routed through robustApiCall.
|
|
7984
|
+
bot.api.sendRichMessage(operator, richMessage(msg.text), {}),
|
|
7985
|
+
{ chat_id: String(operator), verb: 'rollout-status-post' },
|
|
7986
|
+
)
|
|
7987
|
+
const messageId = (sent as { message_id: number }).message_id
|
|
7988
|
+
process.stderr.write(
|
|
7989
|
+
`telegram gateway: rollout_status_post agent=${msg.agentName} request=${msg.requestId} message_id=${messageId}\n`,
|
|
7990
|
+
)
|
|
7991
|
+
try {
|
|
7992
|
+
client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: true, messageId })
|
|
7993
|
+
} catch { /* best effort */ }
|
|
7994
|
+
} catch (err) {
|
|
7995
|
+
process.stderr.write(
|
|
7996
|
+
`telegram gateway: rollout_status_post send failed: ${(err as Error).message}\n`,
|
|
7997
|
+
)
|
|
7998
|
+
try {
|
|
7999
|
+
client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: (err as Error).message })
|
|
8000
|
+
} catch { /* best effort */ }
|
|
8001
|
+
}
|
|
8002
|
+
},
|
|
8003
|
+
|
|
8004
|
+
// #2726 Part 2 — edit the previously-posted rollout status message in place as
|
|
8005
|
+
// later phases arrive. Fire-and-forget: an edit failure (incl. Telegram 429)
|
|
8006
|
+
// is swallowed here and NEVER surfaced back toward the roll. hostd owns the
|
|
8007
|
+
// debounce + 429 retry cadence; this handler is a thin edit relay.
|
|
8008
|
+
onRolloutStatusEdit(_client: IpcClient, msg: RolloutStatusEditMessage) {
|
|
8009
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
8010
|
+
if (self && msg.agentName !== self) {
|
|
8011
|
+
process.stderr.write(
|
|
8012
|
+
`telegram gateway: rollout_status_edit rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
8013
|
+
)
|
|
8014
|
+
return
|
|
8015
|
+
}
|
|
8016
|
+
const operator = loadAccess().allowFrom[0]
|
|
8017
|
+
if (operator === undefined) {
|
|
8018
|
+
process.stderr.write(`telegram gateway: rollout_status_edit — no operator chat (allowFrom empty)\n`)
|
|
8019
|
+
return
|
|
8020
|
+
}
|
|
8021
|
+
void swallowingApiCall(
|
|
8022
|
+
() =>
|
|
8023
|
+
// allow-raw-bot-api: in-place edit of the ordinary status message.
|
|
8024
|
+
bot.api.editMessageText(operator, msg.messageId, richMessage(msg.text), {}),
|
|
8025
|
+
{ chat_id: String(operator), verb: 'rollout-status-edit' },
|
|
8026
|
+
)
|
|
8027
|
+
},
|
|
8028
|
+
|
|
7559
8029
|
onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
|
|
7560
8030
|
// Cron fires (incl. cheap-cron, whose session events are dropped before
|
|
7561
8031
|
// currentTurn is set) are real activity — re-arm idle auto-clear so a
|
|
@@ -8129,6 +8599,197 @@ function redactOutboundText(text: string, site: string): string {
|
|
|
8129
8599
|
return masked
|
|
8130
8600
|
}
|
|
8131
8601
|
|
|
8602
|
+
/** Default per-voice-note chunk size (chars). A long reply is split into
|
|
8603
|
+
* sequential voice notes of roughly this size on sentence/paragraph
|
|
8604
|
+
* boundaries. Only the OpenAI (cloud) engine still uses this — the kokoro
|
|
8605
|
+
* sidecar now takes the whole reply in one call and returns a single note. */
|
|
8606
|
+
const VOICE_OUT_DEFAULT_CHUNK_CHARS = 600
|
|
8607
|
+
|
|
8608
|
+
/** Hard ceiling for a single OpenAI TTS request. OpenAI's TTS input caps at
|
|
8609
|
+
* 4096 chars, so client-side chunks for that engine never exceed this; the
|
|
8610
|
+
* reply is spoken across sequential notes when it's longer. (The kokoro path
|
|
8611
|
+
* no longer chunks — the sidecar owns length and returns one file.) */
|
|
8612
|
+
const VOICE_OUT_HARD_CHUNK_CAP = 4096
|
|
8613
|
+
|
|
8614
|
+
/**
|
|
8615
|
+
* On-demand voice-out (reply_mode='on-demand'). Instead of synthesizing at
|
|
8616
|
+
* reply time, the gateway appends a single "🔊 Listen" inline button carrying
|
|
8617
|
+
* a reserved `voice:<token>` callback_data; audio is synthesized + sent only
|
|
8618
|
+
* when the user taps it. This keeps the voice pipeline subscription-honest and
|
|
8619
|
+
* visible — zero GPU/sidecar work happens behind the user's back.
|
|
8620
|
+
*
|
|
8621
|
+
* We cache the (speech-normalized text, voice, speed) needed to synthesize by
|
|
8622
|
+
* TOKEN, not message_id, because the message_id is not known at send time.
|
|
8623
|
+
* Bounded LRU with a TTL so a stale button (tapped an hour later) degrades to
|
|
8624
|
+
* a graceful "expired" toast rather than pinning reply text in memory forever.
|
|
8625
|
+
*/
|
|
8626
|
+
const voiceOnDemandCache = new VoiceOnDemandCache()
|
|
8627
|
+
|
|
8628
|
+
type VoiceOutAccess = NonNullable<Access['voice_out']>
|
|
8629
|
+
|
|
8630
|
+
/**
|
|
8631
|
+
* Resolve whether outbound TTS is active for this reply, and how
|
|
8632
|
+
* (engine + reply_mode + the plain-text TTS chunks). PR-C2.
|
|
8633
|
+
*
|
|
8634
|
+
* Mirrors the voice-IN engine-resolution precedence (message:voice
|
|
8635
|
+
* handler): the compose-injected SWITCHROOM_VOICE_ENGINE verdict gates the
|
|
8636
|
+
* local 'kokoro' engine, exactly like the STT sidecar. The 'openai' engine
|
|
8637
|
+
* is an honest-exception cloud path gated on an api_key vault ref — it does
|
|
8638
|
+
* NOT require a local verdict.
|
|
8639
|
+
*
|
|
8640
|
+
* Returns null when voice-out is off / not applicable; otherwise a plan the
|
|
8641
|
+
* caller acts on. `ttsChunks` is speech-normalized plain text (markdown and
|
|
8642
|
+
* TTS-mispronounced symbols removed — see normalizeForSpeech).
|
|
8643
|
+
*
|
|
8644
|
+
* ONE voice note per response: the kokoro (local sidecar) path now sends the
|
|
8645
|
+
* WHOLE normalized reply to `/tts` in a SINGLE call — the sidecar chunks +
|
|
8646
|
+
* concatenates internally on the GPU and returns a single ogg/opus file — so
|
|
8647
|
+
* `ttsChunks` is a single element for kokoro. `max_chars` is no longer a hard
|
|
8648
|
+
* client-side splitter for kokoro; it's retired for that path (the sidecar
|
|
8649
|
+
* owns length). The openai (cloud) engine keeps client-side chunking because
|
|
8650
|
+
* OpenAI's TTS input has its own hard cap; each chunk is one voice note.
|
|
8651
|
+
*/
|
|
8652
|
+
function resolveVoiceOutPlan(
|
|
8653
|
+
voiceOut: VoiceOutAccess | undefined,
|
|
8654
|
+
replyText: string,
|
|
8655
|
+
): {
|
|
8656
|
+
engine: 'kokoro' | 'openai'
|
|
8657
|
+
voice?: string
|
|
8658
|
+
speed: number
|
|
8659
|
+
apiKeyRef?: string
|
|
8660
|
+
replyMode: 'voice+text' | 'voice-only' | 'on-demand'
|
|
8661
|
+
ttsChunks: string[]
|
|
8662
|
+
} | null {
|
|
8663
|
+
if (voiceOut?.enabled !== true) return null
|
|
8664
|
+
|
|
8665
|
+
const engine = voiceOut.engine ?? 'kokoro'
|
|
8666
|
+
|
|
8667
|
+
// Engine gating mirrors the voice-in handler precedence: the
|
|
8668
|
+
// compose-injected env wins, then the persisted host-capabilities file,
|
|
8669
|
+
// then a fail-safe 'cloud'. The in-container ~/.switchroom is a read-only
|
|
8670
|
+
// view that does NOT carry host-capabilities.json, so the env is
|
|
8671
|
+
// load-bearing in-fleet (see message:voice). Kokoro requires 'local';
|
|
8672
|
+
// openai requires a resolvable api_key.
|
|
8673
|
+
if (engine === 'kokoro') {
|
|
8674
|
+
const envVoiceEngine = process.env.SWITCHROOM_VOICE_ENGINE
|
|
8675
|
+
const voiceEngine: VoiceEngine =
|
|
8676
|
+
envVoiceEngine === 'local' || envVoiceEngine === 'cloud'
|
|
8677
|
+
? envVoiceEngine
|
|
8678
|
+
: loadHostCapabilities()?.voice.engine ?? 'cloud'
|
|
8679
|
+
if (voiceEngine !== 'local') return null
|
|
8680
|
+
}
|
|
8681
|
+
|
|
8682
|
+
// Speech-normalized TTS input: strip markdown AND translate/drop the
|
|
8683
|
+
// symbols TTS mispronounces (~, backticks, arrows, tables, code fences,
|
|
8684
|
+
// link URLs) REGARDLESS of mode so the engine reads clean prose. Applied
|
|
8685
|
+
// for BOTH engines — the older partial stripMarkdown pass leaked `~`,
|
|
8686
|
+
// code fences and arrows.
|
|
8687
|
+
const ttsText = normalizeForSpeech(replyText)
|
|
8688
|
+
if (ttsText.length === 0) return null
|
|
8689
|
+
|
|
8690
|
+
// ONE voice note for kokoro: the sidecar accepts arbitrarily long text and
|
|
8691
|
+
// returns a single concatenated ogg/opus file, so we send the whole reply
|
|
8692
|
+
// in one call — no client-side splitting, no multi-sendVoice. `max_chars`
|
|
8693
|
+
// is retired as a hard splitter here.
|
|
8694
|
+
//
|
|
8695
|
+
// OpenAI still needs client-side chunking (its TTS input has a hard cap),
|
|
8696
|
+
// so for that engine we split at the engine cap; each chunk is one note.
|
|
8697
|
+
let ttsChunks: string[]
|
|
8698
|
+
if (engine === 'kokoro') {
|
|
8699
|
+
ttsChunks = [ttsText]
|
|
8700
|
+
} else {
|
|
8701
|
+
const chunkChars = Math.min(
|
|
8702
|
+
voiceOut.max_chars ?? VOICE_OUT_DEFAULT_CHUNK_CHARS,
|
|
8703
|
+
VOICE_OUT_HARD_CHUNK_CAP,
|
|
8704
|
+
)
|
|
8705
|
+
ttsChunks = chunkTtsText(ttsText, chunkChars)
|
|
8706
|
+
}
|
|
8707
|
+
if (ttsChunks.length === 0) return null
|
|
8708
|
+
|
|
8709
|
+
// Kokoro playback speed: default 1.1 (fleet default) when unset, clamped to
|
|
8710
|
+
// 0.5–2.0. Threaded into the /tts body by synthesizeVoiceOut. The OpenAI
|
|
8711
|
+
// path ignores it.
|
|
8712
|
+
const speed = clampTtsSpeed(voiceOut.speed, VOICE_OUT_DEFAULT_SPEED)
|
|
8713
|
+
|
|
8714
|
+
return {
|
|
8715
|
+
engine,
|
|
8716
|
+
voice: voiceOut.voice,
|
|
8717
|
+
speed,
|
|
8718
|
+
apiKeyRef: voiceOut.api_key,
|
|
8719
|
+
replyMode: voiceOut.reply_mode ?? 'voice+text',
|
|
8720
|
+
ttsChunks,
|
|
8721
|
+
}
|
|
8722
|
+
}
|
|
8723
|
+
|
|
8724
|
+
/**
|
|
8725
|
+
* Synthesize `ttsText` into OGG/Opus bytes via the configured engine.
|
|
8726
|
+
* Best-effort: returns null on ANY failure (logged to stderr), never
|
|
8727
|
+
* throws. The caller treats null as "voice unavailable, fall back to
|
|
8728
|
+
* text". PR-C2.
|
|
8729
|
+
*/
|
|
8730
|
+
async function synthesizeVoiceOut(plan: {
|
|
8731
|
+
engine: 'kokoro' | 'openai'
|
|
8732
|
+
voice?: string
|
|
8733
|
+
speed?: number
|
|
8734
|
+
apiKeyRef?: string
|
|
8735
|
+
ttsText: string
|
|
8736
|
+
}): Promise<Uint8Array | null> {
|
|
8737
|
+
try {
|
|
8738
|
+
if (plan.engine === 'kokoro') {
|
|
8739
|
+
const token = await materializeSidecarToken()
|
|
8740
|
+
if (!token) return null // materializeSidecarToken already logged why.
|
|
8741
|
+
const result = await synthesizeViaSidecar({
|
|
8742
|
+
token,
|
|
8743
|
+
text: plan.ttsText,
|
|
8744
|
+
voice: plan.voice,
|
|
8745
|
+
speed: plan.speed,
|
|
8746
|
+
})
|
|
8747
|
+
if (!result.ok) {
|
|
8748
|
+
process.stderr.write(
|
|
8749
|
+
`telegram gateway: voice-out: local sidecar synthesis failed reason=${result.reason}` +
|
|
8750
|
+
(result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
|
|
8751
|
+
'\n',
|
|
8752
|
+
)
|
|
8753
|
+
return null
|
|
8754
|
+
}
|
|
8755
|
+
process.stderr.write(
|
|
8756
|
+
`telegram gateway: voice-out: local sidecar synthesized ${result.audio.length} bytes in ${result.durationMs}ms ` +
|
|
8757
|
+
`voice=${result.voice ?? '?'} audio_s=${result.audioSeconds ?? '?'} chars=${plan.ttsText.length}\n`,
|
|
8758
|
+
)
|
|
8759
|
+
return result.audio
|
|
8760
|
+
}
|
|
8761
|
+
|
|
8762
|
+
// engine === 'openai' — honest-exception cloud path.
|
|
8763
|
+
const apiKey = await materializeVoiceKey({ apiKeyRef: plan.apiKeyRef })
|
|
8764
|
+
if (!apiKey) return null // materializeVoiceKey already logged why.
|
|
8765
|
+
const result = await synthesizeViaOpenAi({
|
|
8766
|
+
apiKey,
|
|
8767
|
+
text: plan.ttsText,
|
|
8768
|
+
voice: plan.voice,
|
|
8769
|
+
})
|
|
8770
|
+
if (!result.ok) {
|
|
8771
|
+
process.stderr.write(
|
|
8772
|
+
`telegram gateway: voice-out: openai synthesis failed reason=${result.reason}` +
|
|
8773
|
+
(result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
|
|
8774
|
+
'\n',
|
|
8775
|
+
)
|
|
8776
|
+
return null
|
|
8777
|
+
}
|
|
8778
|
+
process.stderr.write(
|
|
8779
|
+
`telegram gateway: voice-out: openai synthesized ${result.audio.length} bytes in ${result.durationMs}ms ` +
|
|
8780
|
+
`voice=${result.voice} chars=${plan.ttsText.length}\n`,
|
|
8781
|
+
)
|
|
8782
|
+
return result.audio
|
|
8783
|
+
} catch (err) {
|
|
8784
|
+
// Defence in depth: the synth helpers never throw, but the vault
|
|
8785
|
+
// materialize calls might. Voice is best-effort — swallow + fall back.
|
|
8786
|
+
process.stderr.write(
|
|
8787
|
+
`telegram gateway: voice-out: synthesis threw (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`,
|
|
8788
|
+
)
|
|
8789
|
+
return null
|
|
8790
|
+
}
|
|
8791
|
+
}
|
|
8792
|
+
|
|
8132
8793
|
async function executeReply(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
8133
8794
|
// #1664 — pin the turn this reply belongs to at entry. The
|
|
8134
8795
|
// finalAnswerDelivered write near the end of this function runs after
|
|
@@ -8149,18 +8810,20 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8149
8810
|
// null because clearTurnStarted fired ≥5 min of model silence, but the model
|
|
8150
8811
|
// finally called reply after the poke).
|
|
8151
8812
|
const chat_id = (() => {
|
|
8152
|
-
const
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8813
|
+
const resolved = resolveChatIdFallback(
|
|
8814
|
+
_rawChatId,
|
|
8815
|
+
loadAccess(),
|
|
8816
|
+
turn?.sessionChatId,
|
|
8817
|
+
lastActiveTurnChatId,
|
|
8818
|
+
turn != null,
|
|
8819
|
+
)
|
|
8820
|
+
if (resolved.tier !== 'raw') {
|
|
8157
8821
|
process.stderr.write(
|
|
8158
8822
|
`telegram gateway: reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
|
|
8159
|
-
`routing to ${tier} turn chat "${
|
|
8823
|
+
`routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
|
|
8160
8824
|
)
|
|
8161
|
-
return fallbackChatId
|
|
8162
8825
|
}
|
|
8163
|
-
return
|
|
8826
|
+
return resolved.chatId // raw tier → let assertAllowedChat throw the human-readable error
|
|
8164
8827
|
})()
|
|
8165
8828
|
const rawText = args.text as string | undefined
|
|
8166
8829
|
if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
|
|
@@ -8233,6 +8896,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8233
8896
|
const protectContent = args.protect_content === true
|
|
8234
8897
|
const quoteText = args.quote_text as string | undefined
|
|
8235
8898
|
const access = loadAccess()
|
|
8899
|
+
// Outbound TTS plan (PR-C2). Resolved once here (engine gating + mode +
|
|
8900
|
+
// plain-text TTS input); synthesis happens just before the send so a
|
|
8901
|
+
// voice-only reply can suppress the text chunk loop on success. Voice is
|
|
8902
|
+
// fully best-effort — every failure below falls back to the text reply.
|
|
8903
|
+
const voiceOutPlan = resolveVoiceOutPlan(access.voice_out, text)
|
|
8236
8904
|
const configParseMode = access.parseMode ?? 'html'
|
|
8237
8905
|
const format = (args.format as string | undefined) ?? configParseMode
|
|
8238
8906
|
const disableLinkPreview = args.disable_web_page_preview != null
|
|
@@ -8400,7 +9068,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8400
9068
|
// ships the raw GFM markdown via `sendRichMessage`. `effectiveText` is the
|
|
8401
9069
|
// raw text either way (no HTML/MarkdownV2 rendering happens here anymore).
|
|
8402
9070
|
const literalText = format === 'text'
|
|
8403
|
-
|
|
9071
|
+
// Paragraph-spacing fix (rich-message regression after #2669). The rich GFM
|
|
9072
|
+
// renderer collapses a `\n\n` gap TIGHT, so multi-paragraph replies render
|
|
9073
|
+
// jammed together — unlike the old HTML path. Inject a visible blank-line
|
|
9074
|
+
// spacer into prose `\n\n` gaps on the rich path only. The literal
|
|
9075
|
+
// (`format:'text'`) path must stay byte-exact, so it is left untouched.
|
|
9076
|
+
const effectiveText: string = literalText ? text : addParagraphSpacers(text)
|
|
8404
9077
|
|
|
8405
9078
|
assertAllowedChat(chat_id)
|
|
8406
9079
|
|
|
@@ -8469,6 +9142,60 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8469
9142
|
: splitMarkdownChunks(effectiveText, limit)
|
|
8470
9143
|
const sentIds: number[] = []
|
|
8471
9144
|
|
|
9145
|
+
// Outbound TTS synthesis (PR-C2). Done BEFORE the text send so a
|
|
9146
|
+
// voice-only reply can suppress the text chunks on success. ONE voice note
|
|
9147
|
+
// per response for the kokoro path (ttsChunks is a single element — the
|
|
9148
|
+
// whole normalized reply — synthesized in one /tts call). The OpenAI path
|
|
9149
|
+
// may still produce several ordered notes (one per chunk) because of its
|
|
9150
|
+
// input cap. Ken is often on a bike/driving and can't read the screen, so
|
|
9151
|
+
// the full answer must be SPOKEN. Best-effort: a chunk that fails to
|
|
9152
|
+
// synthesize is skipped; if NOTHING synthesizes the text path proceeds
|
|
9153
|
+
// unchanged so the answer is never dropped.
|
|
9154
|
+
// on-demand is a LOCAL-engine (kokoro) feature only: the tap handler
|
|
9155
|
+
// synthesizes via the local sidecar, so a Listen button is only meaningful
|
|
9156
|
+
// when the resolved engine is kokoro. resolveVoiceOutPlan already gated the
|
|
9157
|
+
// local host verdict for kokoro, so engine==='kokoro' here implies the
|
|
9158
|
+
// sidecar is available. For engine==='openai' + reply_mode='on-demand' we do
|
|
9159
|
+
// NOT inject a button (its taps would dead-end on the local sidecar) — we
|
|
9160
|
+
// fall through to the normal immediate-synth path so the openai reply behaves
|
|
9161
|
+
// exactly like a normal openai voice reply.
|
|
9162
|
+
const useOnDemandButton =
|
|
9163
|
+
voiceOutPlan != null &&
|
|
9164
|
+
voiceOutPlan.replyMode === 'on-demand' &&
|
|
9165
|
+
voiceOutPlan.engine === 'kokoro'
|
|
9166
|
+
|
|
9167
|
+
const voiceOggs: Uint8Array[] = []
|
|
9168
|
+
// Skip reply-time synthesis ONLY when we're actually deferring to a Listen
|
|
9169
|
+
// button (kokoro on-demand). An openai on-demand config still synthesizes
|
|
9170
|
+
// immediately below.
|
|
9171
|
+
if (voiceOutPlan != null && !useOnDemandButton) {
|
|
9172
|
+
for (const chunkText of voiceOutPlan.ttsChunks) {
|
|
9173
|
+
const ogg = await synthesizeVoiceOut({
|
|
9174
|
+
engine: voiceOutPlan.engine,
|
|
9175
|
+
voice: voiceOutPlan.voice,
|
|
9176
|
+
speed: voiceOutPlan.speed,
|
|
9177
|
+
apiKeyRef: voiceOutPlan.apiKeyRef,
|
|
9178
|
+
ttsText: chunkText,
|
|
9179
|
+
})
|
|
9180
|
+
// Skip a failed chunk but keep going — a partial spoken answer still
|
|
9181
|
+
// beats silence; full-fail (no oggs at all) falls back to text below.
|
|
9182
|
+
if (ogg != null) voiceOggs.push(ogg)
|
|
9183
|
+
}
|
|
9184
|
+
if (voiceOggs.length < voiceOutPlan.ttsChunks.length) {
|
|
9185
|
+
process.stderr.write(
|
|
9186
|
+
`telegram gateway: voice-out: synthesized ${voiceOggs.length}/${voiceOutPlan.ttsChunks.length} voice-note chunk(s)\n`,
|
|
9187
|
+
)
|
|
9188
|
+
}
|
|
9189
|
+
}
|
|
9190
|
+
// Suppress the text body ONLY when voice-only AND we synthesized the FULL
|
|
9191
|
+
// set of chunks (every part of the answer is spoken). A partial or total
|
|
9192
|
+
// synthesis failure leaves the text path running so the answer still
|
|
9193
|
+
// lands in full — never drop the user's answer silently.
|
|
9194
|
+
const suppressText =
|
|
9195
|
+
voiceOutPlan?.replyMode === 'voice-only' &&
|
|
9196
|
+
voiceOutPlan.ttsChunks.length > 0 &&
|
|
9197
|
+
voiceOggs.length === voiceOutPlan.ttsChunks.length
|
|
9198
|
+
|
|
8472
9199
|
// #271: validate inline_keyboard and namespace any callback_data with
|
|
8473
9200
|
// the `agent:` prefix so the gateway's callback_query dispatcher can
|
|
8474
9201
|
// round-trip taps back to this agent without colliding with
|
|
@@ -8490,6 +9217,50 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8490
9217
|
replyMarkup = { inline_keyboard: wrapAgentCallbacks(rawKeyboard) }
|
|
8491
9218
|
}
|
|
8492
9219
|
|
|
9220
|
+
// on-demand voice: append a single '🔊 Listen' button that synthesizes the
|
|
9221
|
+
// spoken reply only when tapped. The button carries a RAW `voice:<token>`
|
|
9222
|
+
// callback_data (NOT wrapped with the agent: prefix) so the dispatcher
|
|
9223
|
+
// handles it internally and never routes it to the agent as an inbound.
|
|
9224
|
+
//
|
|
9225
|
+
// Collision gate: inject ONLY when the reply carries no agent-authored
|
|
9226
|
+
// buttons. If the agent supplied its own keyboard, the callback dispatcher's
|
|
9227
|
+
// single_use strip (keyboardIsSingleUse) governs that whole message; adding
|
|
9228
|
+
// a foreign single_use:false button would flip that message to a mixed
|
|
9229
|
+
// keyboard and defeat the agent's double-fire protection. Keep it simple —
|
|
9230
|
+
// agent buttons present → skip the Listen button for this message.
|
|
9231
|
+
//
|
|
9232
|
+
// useOnDemandButton already gates on engine==='kokoro': an openai on-demand
|
|
9233
|
+
// config never reaches here (it synthesized immediately above), so a Listen
|
|
9234
|
+
// button is never minted for an engine whose taps would dead-end on the
|
|
9235
|
+
// local sidecar.
|
|
9236
|
+
if (
|
|
9237
|
+
useOnDemandButton &&
|
|
9238
|
+
voiceOutPlan!.ttsChunks.length > 0 &&
|
|
9239
|
+
voiceOutPlan!.ttsChunks[0]!.length > 0
|
|
9240
|
+
) {
|
|
9241
|
+
if (!mayInjectListenButton(rawKeyboard)) {
|
|
9242
|
+
process.stderr.write(
|
|
9243
|
+
'telegram gateway: voice-out on-demand: agent supplied inline_keyboard — skipping Listen button (single_use collision gate)\n',
|
|
9244
|
+
)
|
|
9245
|
+
} else {
|
|
9246
|
+
// Token is intentionally GLOBAL (not chat-keyed): under the single-tenant
|
|
9247
|
+
// invariant the operator is the only authorized sender across all chats,
|
|
9248
|
+
// and the tap handler re-checks access.allowFrom before synthesizing, so
|
|
9249
|
+
// a token needs no per-chat scoping to be safe.
|
|
9250
|
+
const token = mintVoiceOnDemandToken()
|
|
9251
|
+
voiceOnDemandCache.put(token, {
|
|
9252
|
+
// ttsChunks[0] is already normalizeForSpeech(reply) (kokoro path).
|
|
9253
|
+
text: voiceOutPlan.ttsChunks[0]!,
|
|
9254
|
+
...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
|
|
9255
|
+
speed: voiceOutPlan.speed,
|
|
9256
|
+
})
|
|
9257
|
+
// The keyboard stays after the tap (never stripped) so it can be
|
|
9258
|
+
// replayed. The gate above guarantees this is the ONLY button on the
|
|
9259
|
+
// message, so keeping it is safe (no agent buttons to protect).
|
|
9260
|
+
replyMarkup = buildListenKeyboard(token)
|
|
9261
|
+
}
|
|
9262
|
+
}
|
|
9263
|
+
|
|
8493
9264
|
const replySKey = streamKey(chat_id, threadId)
|
|
8494
9265
|
suppressPtyPreview.add(replySKey)
|
|
8495
9266
|
let previewMessageId: number | null = null
|
|
@@ -8739,6 +9510,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8739
9510
|
|
|
8740
9511
|
try {
|
|
8741
9512
|
for (let i = 0; i < chunks.length; i++) {
|
|
9513
|
+
// PR-C2: voice-only mode with a successful synthesis suppresses the
|
|
9514
|
+
// text body — the spoken voice note IS the reply. Bail before the
|
|
9515
|
+
// first chunk send (sentIds stays empty for text); the voice send
|
|
9516
|
+
// below lands the answer. Any other mode (voice+text, or voice-only
|
|
9517
|
+
// that fell back) sends the text chunks as normal.
|
|
9518
|
+
if (suppressText) break
|
|
8742
9519
|
const shouldReplyTo =
|
|
8743
9520
|
reply_to != null && replyMode !== 'off' && (replyMode === 'all' || i === 0)
|
|
8744
9521
|
const isLastChunk = i === chunks.length - 1
|
|
@@ -8894,6 +9671,76 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8894
9671
|
stopTypingLoop(chat_id, threadId ?? null)
|
|
8895
9672
|
}
|
|
8896
9673
|
|
|
9674
|
+
// Outbound voice notes (PR-C2). Sent IN ORDER, AFTER the text body
|
|
9675
|
+
// (voice+text) or INSTEAD of it (voice-only, where suppressText skipped
|
|
9676
|
+
// the chunk loop). A long reply produces SEVERAL notes — each spoken in
|
|
9677
|
+
// sequence so the whole answer is heard, never truncated. Best-effort and
|
|
9678
|
+
// fully non-fatal: a sendVoice failure must NEVER break the text reply
|
|
9679
|
+
// path. In voice-only mode a failure would leave the user with an
|
|
9680
|
+
// incomplete spoken answer, so on the FIRST send failure we recover by
|
|
9681
|
+
// sending the full text once and stop sending further notes.
|
|
9682
|
+
for (let v = 0; v < voiceOggs.length; v++) {
|
|
9683
|
+
const oggBytes = voiceOggs[v]!
|
|
9684
|
+
const voiceOpts: Record<string, unknown> = {
|
|
9685
|
+
// Quote the user's message only on the FIRST voice note (mirrors the
|
|
9686
|
+
// text chunk loop's first-chunk reply behaviour).
|
|
9687
|
+
...(v === 0 && reply_to != null && replyMode !== 'off'
|
|
9688
|
+
? { reply_parameters: { message_id: reply_to } }
|
|
9689
|
+
: {}),
|
|
9690
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
9691
|
+
...(disableNotification ? { disable_notification: true } : {}),
|
|
9692
|
+
}
|
|
9693
|
+
try {
|
|
9694
|
+
const sentVoice = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
|
|
9695
|
+
robustApiCall,
|
|
9696
|
+
(tid) => {
|
|
9697
|
+
const opts = { ...voiceOpts }
|
|
9698
|
+
if (tid != null) opts.message_thread_id = tid
|
|
9699
|
+
else delete opts.message_thread_id
|
|
9700
|
+
// allow-raw-bot-api: adapter callback INSIDE retryWithThreadFallback→robustApiCall; the THREAD_NOT_FOUND fallback is handled by the wrapper.
|
|
9701
|
+
return lockedBot.api.sendVoice(chat_id, new InputFile(Buffer.from(oggBytes)), opts as never)
|
|
9702
|
+
},
|
|
9703
|
+
{ threadId, chat_id, verb: 'sendVoice' },
|
|
9704
|
+
)
|
|
9705
|
+
sentIds.push(sentVoice.message_id)
|
|
9706
|
+
logOutbound(
|
|
9707
|
+
'reply',
|
|
9708
|
+
chat_id,
|
|
9709
|
+
sentVoice.message_id,
|
|
9710
|
+
voiceOutPlan?.ttsChunks[v]?.length ?? 0,
|
|
9711
|
+
`voice-note=${v + 1}/${voiceOggs.length}`,
|
|
9712
|
+
)
|
|
9713
|
+
} catch (err) {
|
|
9714
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
9715
|
+
process.stderr.write(
|
|
9716
|
+
`telegram gateway: voice-out: sendVoice ${v + 1}/${voiceOggs.length} failed (non-fatal): ${msg}\n`,
|
|
9717
|
+
)
|
|
9718
|
+
// voice-only fell over mid-stream with text suppressed → recover by
|
|
9719
|
+
// sending the FULL text body once so the answer still lands, then
|
|
9720
|
+
// stop sending further notes (the text now carries everything).
|
|
9721
|
+
if (suppressText) {
|
|
9722
|
+
try {
|
|
9723
|
+
const opts: Record<string, unknown> = {
|
|
9724
|
+
...(reply_to != null && replyMode !== 'off'
|
|
9725
|
+
? { reply_parameters: { message_id: reply_to } }
|
|
9726
|
+
: {}),
|
|
9727
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
9728
|
+
...(disableNotification ? { disable_notification: true } : {}),
|
|
9729
|
+
}
|
|
9730
|
+
// 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.
|
|
9731
|
+
const sent = await lockedBot.api.sendMessage(chat_id, effectiveText, opts as never)
|
|
9732
|
+
sentIds.push(sent.message_id)
|
|
9733
|
+
logOutbound('reply', chat_id, sent.message_id, effectiveText.length, 'voice-only-text-recovery')
|
|
9734
|
+
} catch (textErr) {
|
|
9735
|
+
process.stderr.write(
|
|
9736
|
+
`telegram gateway: voice-out: voice-only text recovery ALSO failed: ${textErr instanceof Error ? textErr.message : String(textErr)}\n`,
|
|
9737
|
+
)
|
|
9738
|
+
}
|
|
9739
|
+
break
|
|
9740
|
+
}
|
|
9741
|
+
}
|
|
9742
|
+
}
|
|
9743
|
+
|
|
8897
9744
|
// #710: remember per-button agent meta (ack_text / single_use) keyed
|
|
8898
9745
|
// by the message that actually carries the keyboard — that's the last
|
|
8899
9746
|
// text chunk, since the keyboard is attached only on isLastChunk.
|
|
@@ -9154,6 +10001,31 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
9154
10001
|
const turn = currentTurn
|
|
9155
10002
|
if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
|
|
9156
10003
|
if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
|
|
10004
|
+
// chat_id allowlist fallback — mirrors executeReply (~8725). stream_reply is
|
|
10005
|
+
// the primary final-answer path, so a wrong/late chat_id (int/string mismatch,
|
|
10006
|
+
// or the model echoing the wrong identifier after a silence poke flipped
|
|
10007
|
+
// currentTurn to null) would otherwise fail assertAllowedChat in the stream
|
|
10008
|
+
// controller → an invisible reply. Rewrite args.chat_id in place so every
|
|
10009
|
+
// downstream consumer (origin resolution, dedup key, the send) sees the
|
|
10010
|
+
// corrected value. Tier 1: live turn's sessionChatId. Tier 2: last-known
|
|
10011
|
+
// turn's chat (survives silence poke — Bug D fix; currentTurn is null).
|
|
10012
|
+
{
|
|
10013
|
+
const _rawChatId = String(args.chat_id ?? '')
|
|
10014
|
+
const resolved = resolveChatIdFallback(
|
|
10015
|
+
_rawChatId,
|
|
10016
|
+
loadAccess(),
|
|
10017
|
+
turn?.sessionChatId,
|
|
10018
|
+
lastActiveTurnChatId,
|
|
10019
|
+
turn != null,
|
|
10020
|
+
)
|
|
10021
|
+
if (resolved.tier !== 'raw') {
|
|
10022
|
+
process.stderr.write(
|
|
10023
|
+
`telegram gateway: stream_reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
|
|
10024
|
+
`routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
|
|
10025
|
+
)
|
|
10026
|
+
args.chat_id = resolved.chatId
|
|
10027
|
+
}
|
|
10028
|
+
}
|
|
9157
10029
|
// Thread precedence (matches executeReply; component 3 — turn-origin
|
|
9158
10030
|
// routing): when the model passes no explicit message_thread_id, inject
|
|
9159
10031
|
// the ORIGIN turn's thread (matched by origin_turn_id) — authoritative
|
|
@@ -9379,6 +10251,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
9379
10251
|
retry: robustApiCall,
|
|
9380
10252
|
repairEscapedWhitespace,
|
|
9381
10253
|
normalizeParagraphBreaks,
|
|
10254
|
+
addParagraphSpacers,
|
|
9382
10255
|
assertAllowedChat,
|
|
9383
10256
|
resolveThreadId,
|
|
9384
10257
|
disableLinkPreview: access.disableLinkPreview !== false,
|
|
@@ -10428,31 +11301,6 @@ function buildVaultRequestAccessKeyboard(stageId: string): { inline_keyboard: Ar
|
|
|
10428
11301
|
}
|
|
10429
11302
|
}
|
|
10430
11303
|
|
|
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
11304
|
/**
|
|
10457
11305
|
* `vault_request_access` tool — agent surfaces an approval card asking
|
|
10458
11306
|
* the operator to grant a vault ACL it doesn't yet have. See #1012.
|
|
@@ -10632,7 +11480,7 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
|
|
|
10632
11480
|
// paragraph breaks for the rich path. A literal-text edit (`format:'text'`)
|
|
10633
11481
|
// skips paragraph normalization — it must edit byte-for-byte as given.
|
|
10634
11482
|
let editRawText = repairEscapedWhitespace(args.text as string)
|
|
10635
|
-
if (!editLiteralText) editRawText = normalizeParagraphBreaks(editRawText)
|
|
11483
|
+
if (!editLiteralText) editRawText = addParagraphSpacers(normalizeParagraphBreaks(editRawText))
|
|
10636
11484
|
// Outbound secret scrub (#2044): an edit must not re-introduce a raw
|
|
10637
11485
|
// secret into a live bubble or the history row. Mask before scrub/send.
|
|
10638
11486
|
editRawText = redactOutboundText(editRawText, 'edit_message')
|
|
@@ -11133,6 +11981,16 @@ async function drainActivitySummary(
|
|
|
11133
11981
|
)
|
|
11134
11982
|
turn.activityMessageId = sent.message_id
|
|
11135
11983
|
turn.activityEverOpened = true
|
|
11984
|
+
// Status-pin: the per-turn status message just opened — it's the
|
|
11985
|
+
// in-flight "what it's doing" surface. Silently pin it so the turn
|
|
11986
|
+
// stays in view when the feed scrolls past. Keyed to the same
|
|
11987
|
+
// status-key the canonical turn-end (purgeReactionTracking) unpins.
|
|
11988
|
+
// Fire-and-forget; the single-owner reconcile keeps state consistent.
|
|
11989
|
+
void reconcileStatusPin(
|
|
11990
|
+
`fg:${statusKey(chat, thread)}`,
|
|
11991
|
+
chat,
|
|
11992
|
+
{ pinned: true, messageId: sent.message_id },
|
|
11993
|
+
)
|
|
11136
11994
|
} else {
|
|
11137
11995
|
const id = turn.activityMessageId
|
|
11138
11996
|
await robustApiCall(
|
|
@@ -15448,6 +16306,15 @@ async function sweepBeforeSelfRestart(): Promise<void> {
|
|
|
15448
16306
|
const agentDir = resolveAgentDirFromEnv()
|
|
15449
16307
|
if (agentDir == null) return
|
|
15450
16308
|
// #1122 PR3: pre-restart progress-card pin sweep removed with the card.
|
|
16309
|
+
// Status-pin: unpin every status pin we own before we hand control to the
|
|
16310
|
+
// restart, so a crash / interrupt / config-bounce never leaves a permanent
|
|
16311
|
+
// pin stuck at the top of the chat. Best-effort; the drop-on-unpin contract
|
|
16312
|
+
// clears the claim even if the API throws.
|
|
16313
|
+
try {
|
|
16314
|
+
await unpinAllStatusPins()
|
|
16315
|
+
} catch (err) {
|
|
16316
|
+
process.stderr.write(`telegram gateway: pre-restart status-pin sweep threw: ${(err as Error).message}\n`)
|
|
16317
|
+
}
|
|
15451
16318
|
try {
|
|
15452
16319
|
await sweepActiveReactions(
|
|
15453
16320
|
agentDir,
|
|
@@ -17159,6 +18026,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
|
|
|
17159
18026
|
action: naturalAction(details.tool_name, details.input_preview),
|
|
17160
18027
|
})
|
|
17161
18028
|
pendingPermissions.delete(request_id)
|
|
18029
|
+
permCardStore.remove(request_id)
|
|
17162
18030
|
process.stderr.write(
|
|
17163
18031
|
`[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
|
|
17164
18032
|
)
|
|
@@ -18861,9 +19729,16 @@ async function performVaultAccessApproval(
|
|
|
18861
19729
|
.editMessageText(
|
|
18862
19730
|
pending.chat_id,
|
|
18863
19731
|
pending.card_message_id,
|
|
18864
|
-
|
|
18865
|
-
|
|
18866
|
-
|
|
19732
|
+
richMessage(
|
|
19733
|
+
buildVaultGrantApprovedCardText({
|
|
19734
|
+
agentEscaped: escapeHtmlForTg(pending.agent),
|
|
19735
|
+
scope: pending.scope,
|
|
19736
|
+
key: pending.key,
|
|
19737
|
+
days,
|
|
19738
|
+
grantId: id,
|
|
19739
|
+
footer,
|
|
19740
|
+
}),
|
|
19741
|
+
),
|
|
18867
19742
|
{ reply_markup: { inline_keyboard: [] } },
|
|
18868
19743
|
)
|
|
18869
19744
|
.catch(() => {})
|
|
@@ -20855,7 +21730,7 @@ bot.command('usage', async ctx => {
|
|
|
20855
21730
|
}
|
|
20856
21731
|
return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
|
|
20857
21732
|
})
|
|
20858
|
-
const { renderAuthSnapshotFormat2, buildSnapshotsFromState } = await import(
|
|
21733
|
+
const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
|
|
20859
21734
|
'../auth-snapshot-format.js'
|
|
20860
21735
|
)
|
|
20861
21736
|
const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
|
|
@@ -20866,7 +21741,22 @@ bot.command('usage', async ctx => {
|
|
|
20866
21741
|
demo,
|
|
20867
21742
|
...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: Date.now() }),
|
|
20868
21743
|
})
|
|
20869
|
-
|
|
21744
|
+
// Preserve the Switch/Refresh/usage/Add inline keyboard on the
|
|
21745
|
+
// rich-message render — the table card carries the same actions the
|
|
21746
|
+
// /auth snapshot does. switchroomReply routes through the rich path
|
|
21747
|
+
// (replyWithRichMessage), which accepts reply_markup. Build a grammy
|
|
21748
|
+
// InlineKeyboard so the markup type matches switchroomReply's contract.
|
|
21749
|
+
const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date() })
|
|
21750
|
+
const keyboard = new InlineKeyboard()
|
|
21751
|
+
kbRows.forEach((row, ri) => {
|
|
21752
|
+
if (ri > 0) keyboard.row()
|
|
21753
|
+
for (const b of row) {
|
|
21754
|
+
if (b.callbackData) keyboard.text(b.text, b.callbackData)
|
|
21755
|
+
else if (b.insertText) keyboard.switchInlineCurrent(b.text, b.insertText)
|
|
21756
|
+
else keyboard.text(b.text, 'auth:noop')
|
|
21757
|
+
}
|
|
21758
|
+
})
|
|
21759
|
+
await switchroomReply(ctx, text, { html: true, reply_markup: keyboard })
|
|
20870
21760
|
return
|
|
20871
21761
|
}
|
|
20872
21762
|
}
|
|
@@ -21544,6 +22434,110 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21544
22434
|
return
|
|
21545
22435
|
}
|
|
21546
22436
|
|
|
22437
|
+
// on-demand voice: '🔊 Listen' buttons carry a reserved `voice:<token>`
|
|
22438
|
+
// callback_data. Handle INTERNALLY here — synthesize the cached reply text
|
|
22439
|
+
// and send it as a native voice note. This MUST run before the agent:
|
|
22440
|
+
// routing below so a tap never reaches the agent as an inbound message.
|
|
22441
|
+
if (isVoiceOnDemandCallback(data)) {
|
|
22442
|
+
const access = loadAccess()
|
|
22443
|
+
const senderId = String(ctx.from.id)
|
|
22444
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
22445
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
22446
|
+
return
|
|
22447
|
+
}
|
|
22448
|
+
const token = parseVoiceOnDemandToken(data)
|
|
22449
|
+
const entry = token != null ? voiceOnDemandCache.get(token) : null
|
|
22450
|
+
if (entry == null) {
|
|
22451
|
+
await ctx
|
|
22452
|
+
.answerCallbackQuery({ text: 'Voice expired — send again to hear it.' })
|
|
22453
|
+
.catch(() => {})
|
|
22454
|
+
return
|
|
22455
|
+
}
|
|
22456
|
+
await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
|
|
22457
|
+
const cbChatId = String(ctx.chat?.id ?? ctx.from.id)
|
|
22458
|
+
const cbMessageId = ctx.callbackQuery?.message?.message_id
|
|
22459
|
+
const cbThreadId = (() => {
|
|
22460
|
+
const msg = ctx.callbackQuery?.message
|
|
22461
|
+
if (msg && 'is_topic_message' in msg && msg.is_topic_message && 'message_thread_id' in msg) {
|
|
22462
|
+
const tid = (msg as { message_thread_id?: number }).message_thread_id
|
|
22463
|
+
return typeof tid === 'number' ? tid : undefined
|
|
22464
|
+
}
|
|
22465
|
+
return undefined
|
|
22466
|
+
})()
|
|
22467
|
+
// Local sidecar (kokoro) synthesis — same helper the immediate voice-out
|
|
22468
|
+
// path uses. On-demand is a local-engine feature; the cache is only
|
|
22469
|
+
// populated when resolveVoiceOutPlan gated the local verdict.
|
|
22470
|
+
const sidecarToken = await materializeSidecarToken()
|
|
22471
|
+
if (!sidecarToken) {
|
|
22472
|
+
await ctx
|
|
22473
|
+
.answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
|
|
22474
|
+
.catch(() => {})
|
|
22475
|
+
return
|
|
22476
|
+
}
|
|
22477
|
+
const result = await synthesizeViaSidecar({
|
|
22478
|
+
token: sidecarToken,
|
|
22479
|
+
text: entry.text,
|
|
22480
|
+
voice: entry.voice,
|
|
22481
|
+
speed: entry.speed,
|
|
22482
|
+
})
|
|
22483
|
+
if (!result.ok) {
|
|
22484
|
+
process.stderr.write(
|
|
22485
|
+
`telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
|
|
22486
|
+
)
|
|
22487
|
+
await ctx
|
|
22488
|
+
.answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
|
|
22489
|
+
.catch(() => {})
|
|
22490
|
+
return
|
|
22491
|
+
}
|
|
22492
|
+
try {
|
|
22493
|
+
// Native voice note (NOT a document), quote-replying the button's
|
|
22494
|
+
// message.
|
|
22495
|
+
await robustApiCall(
|
|
22496
|
+
() =>
|
|
22497
|
+
bot.api.sendVoice(
|
|
22498
|
+
cbChatId,
|
|
22499
|
+
// allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
|
|
22500
|
+
new InputFile(Buffer.from(result.audio)),
|
|
22501
|
+
{
|
|
22502
|
+
...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
|
|
22503
|
+
...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
|
|
22504
|
+
} as never,
|
|
22505
|
+
),
|
|
22506
|
+
{
|
|
22507
|
+
chat_id: cbChatId,
|
|
22508
|
+
verb: 'voice-ondemand.sendVoice',
|
|
22509
|
+
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
22510
|
+
},
|
|
22511
|
+
)
|
|
22512
|
+
// Single-use on SUCCESS: strip the '🔊 Listen' keyboard so the button
|
|
22513
|
+
// can't be re-tapped now that the audio has been delivered. Mirrors the
|
|
22514
|
+
// agent-button single_use strip (keyboardIsSingleUse) house style.
|
|
22515
|
+
// Best-effort + non-fatal — a failed strip only leaves a replayable
|
|
22516
|
+
// button, never drops the delivered audio. Only reached on a successful
|
|
22517
|
+
// sendVoice; expiry / synth-failure / sidecar-unavailable all return
|
|
22518
|
+
// earlier WITHOUT stripping, so the user can retry those.
|
|
22519
|
+
if (cbMessageId != null) {
|
|
22520
|
+
await robustApiCall(
|
|
22521
|
+
() =>
|
|
22522
|
+
bot.api.editMessageReplyMarkup(cbChatId, cbMessageId, {
|
|
22523
|
+
reply_markup: { inline_keyboard: [] },
|
|
22524
|
+
}),
|
|
22525
|
+
{
|
|
22526
|
+
chat_id: cbChatId,
|
|
22527
|
+
verb: 'voice-ondemand.strip-listen-keyboard',
|
|
22528
|
+
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
22529
|
+
},
|
|
22530
|
+
).catch(() => {})
|
|
22531
|
+
}
|
|
22532
|
+
} catch (err) {
|
|
22533
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
22534
|
+
process.stderr.write(
|
|
22535
|
+
`telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
|
|
22536
|
+
)
|
|
22537
|
+
}
|
|
22538
|
+
return
|
|
22539
|
+
}
|
|
22540
|
+
|
|
21547
22541
|
// #271: agent-emitted inline_keyboard callbacks. Namespaced with
|
|
21548
22542
|
// an `agent:` prefix in inline-keyboard-callbacks.ts so they can
|
|
21549
22543
|
// round-trip without colliding with infrastructure prefixes above.
|
|
@@ -21741,6 +22735,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21741
22735
|
}
|
|
21742
22736
|
|
|
21743
22737
|
pendingPermissions.delete(request_id)
|
|
22738
|
+
permCardStore.remove(request_id)
|
|
21744
22739
|
|
|
21745
22740
|
// (2) Dispatch the in-flight permission verdict IMMEDIATELY — before
|
|
21746
22741
|
// any host round-trip — so the turn never blocks on persistence.
|
|
@@ -21964,6 +22959,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21964
22959
|
: null
|
|
21965
22960
|
const grantAgent = selfAgentName()
|
|
21966
22961
|
pendingPermissions.delete(request_id)
|
|
22962
|
+
permCardStore.remove(request_id)
|
|
21967
22963
|
if (timeBox && grantAgent) {
|
|
21968
22964
|
recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
|
|
21969
22965
|
process.stderr.write(
|
|
@@ -22001,6 +22997,25 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22001
22997
|
const baseText = msg && 'text' in msg && msg.text
|
|
22002
22998
|
? escapeHtmlForTg(msg.text)
|
|
22003
22999
|
: ''
|
|
23000
|
+
// NO SPLIT (#card-ux): the agent-voiced "got it, continuing: <action>"
|
|
23001
|
+
// continuation folds into THIS card edit — below the verdict label — instead
|
|
23002
|
+
// of spawning a separate message. formatPermissionResumeMessage returns the
|
|
23003
|
+
// same GFM markdown the standalone message used, so the wording is
|
|
23004
|
+
// unchanged; it just rides in the card the operator already tapped. The
|
|
23005
|
+
// separate message went to EVERY resolvePermissionCardTargets() surface; the
|
|
23006
|
+
// in-place edit deliberately touches only the tapped card (a single, legible
|
|
23007
|
+
// card, no fan-out). The turn-resume trigger (dispatchPermissionVerdict) is
|
|
23008
|
+
// untouched below — only the USER-VISIBLE second message collapses. Honours
|
|
23009
|
+
// the SWITCHROOM_RESUME_MSG=0 kill-switch (suppresses the continuation line,
|
|
23010
|
+
// as it suppressed the separate message before).
|
|
23011
|
+
const resumeLine = process.env.SWITCHROOM_RESUME_MSG === '0'
|
|
23012
|
+
? ''
|
|
23013
|
+
: formatPermissionResumeMessage({
|
|
23014
|
+
agentName: process.env.SWITCHROOM_AGENT_NAME ?? null,
|
|
23015
|
+
behavior: behavior as 'allow' | 'deny',
|
|
23016
|
+
action: resumeAction,
|
|
23017
|
+
})
|
|
23018
|
+
const labelWithResume = resumeLine ? `${htmlLabel}\n\n${resumeLine}` : htmlLabel
|
|
22004
23019
|
// #1150 audit: P0 fix — was `editMessageText` WITHOUT reply_markup
|
|
22005
23020
|
// strip, leaving the [Allow][Deny][Always] keyboard live after the
|
|
22006
23021
|
// decision. Operator could re-tap and flip Deny → Allow after the
|
|
@@ -22008,7 +23023,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22008
23023
|
// strips the keyboard atomically with the status-line edit.
|
|
22009
23024
|
await finalizeCallback(ctx, {
|
|
22010
23025
|
ackText: ackText.slice(0, 200),
|
|
22011
|
-
newText: baseText ? `${baseText}\n\n${
|
|
23026
|
+
newText: baseText ? `${baseText}\n\n${labelWithResume}` : labelWithResume,
|
|
22012
23027
|
synthInbound: () => {
|
|
22013
23028
|
// No `rule` → the bridge does NOT cache this (truly once on the bridge);
|
|
22014
23029
|
// any 30-min stickiness lives only in scopedGrants (recorded above).
|
|
@@ -22020,10 +23035,11 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22020
23035
|
// Un-park the status reaction: 🙏 → working, re-arming the stall
|
|
22021
23036
|
// watchdog that setAwaiting() suspended.
|
|
22022
23037
|
resumeReactionAfterVerdict()
|
|
22023
|
-
|
|
22024
|
-
|
|
22025
|
-
|
|
22026
|
-
|
|
23038
|
+
// card-folded-resume: the visible "continuing…" line is folded into the
|
|
23039
|
+
// card edit above (labelWithResume), so we deliberately do NOT
|
|
23040
|
+
// postPermissionResumeMessage here — that would re-introduce the split
|
|
23041
|
+
// second message this fix (card-ux fix 3) removes. The turn still resumes
|
|
23042
|
+
// via dispatchPermissionVerdict + resumeReactionAfterVerdict above.
|
|
22027
23043
|
},
|
|
22028
23044
|
})
|
|
22029
23045
|
})
|
|
@@ -22091,12 +23107,42 @@ bot.on('message:voice', async ctx => {
|
|
|
22091
23107
|
// SOMETHING — better than silent drops.
|
|
22092
23108
|
const access = loadAccess()
|
|
22093
23109
|
const voiceIn = access.voice_in
|
|
22094
|
-
|
|
22095
|
-
|
|
22096
|
-
|
|
22097
|
-
|
|
22098
|
-
|
|
22099
|
-
|
|
23110
|
+
// Engine selection (PR-B2): the persisted host verdict decides HOW we
|
|
23111
|
+
// transcribe. `local` → the in-fleet GPU sidecar (no third-party key,
|
|
23112
|
+
// vision #3 + #4); anything else → the OpenAI cloud provider. The local
|
|
23113
|
+
// path needs no `provider === 'openai'` gate — it has no API key — so we
|
|
23114
|
+
// route to the sidecar whenever voice_in is enabled AND the host verdict
|
|
23115
|
+
// is `local`. The cloud path keeps its existing openai gate.
|
|
23116
|
+
// Source precedence: the compose-injected SWITCHROOM_VOICE_ENGINE env
|
|
23117
|
+
// (set per-agent by compose-gen from the host verdict — PR-B3) wins,
|
|
23118
|
+
// then the persisted host-capabilities file, then a fail-safe `cloud`.
|
|
23119
|
+
// The env is load-bearing in-fleet: the in-container `~/.switchroom`
|
|
23120
|
+
// is a read-only constructed view that does NOT carry the host's
|
|
23121
|
+
// host-capabilities.json, so without the env the file lookup always
|
|
23122
|
+
// misses and every agent silently falls back to `cloud`. Narrow the
|
|
23123
|
+
// env value to the VoiceEngine union so a bogus value can't leak
|
|
23124
|
+
// through — anything but 'local'/'cloud' is ignored and we fall back.
|
|
23125
|
+
const envVoiceEngine = process.env.SWITCHROOM_VOICE_ENGINE
|
|
23126
|
+
const voiceEngine: VoiceEngine =
|
|
23127
|
+
envVoiceEngine === 'local' || envVoiceEngine === 'cloud'
|
|
23128
|
+
? envVoiceEngine
|
|
23129
|
+
: loadHostCapabilities()?.voice.engine ?? 'cloud'
|
|
23130
|
+
const localEnabled = voiceIn?.enabled === true && voiceEngine === 'local'
|
|
23131
|
+
const cloudEnabled =
|
|
23132
|
+
voiceIn?.enabled === true && voiceEngine !== 'local' && voiceIn?.provider === 'openai'
|
|
23133
|
+
if (localEnabled || cloudEnabled) {
|
|
23134
|
+
const transcript = localEnabled
|
|
23135
|
+
? await maybeTranscribeVoiceLocal(
|
|
23136
|
+
voice.file_id,
|
|
23137
|
+
voice.mime_type,
|
|
23138
|
+
voiceIn?.language,
|
|
23139
|
+
)
|
|
23140
|
+
: await maybeTranscribeVoice(
|
|
23141
|
+
voice.file_id,
|
|
23142
|
+
voice.mime_type,
|
|
23143
|
+
voiceIn?.language,
|
|
23144
|
+
voiceIn?.api_key,
|
|
23145
|
+
)
|
|
22100
23146
|
if (transcript != null) {
|
|
22101
23147
|
const text = ctx.message.caption
|
|
22102
23148
|
? `${ctx.message.caption}\n\n[voice transcript] ${transcript}`
|
|
@@ -22121,33 +23167,24 @@ bot.on('message:voice', async ctx => {
|
|
|
22121
23167
|
* are logged to stderr but never thrown — voice-in is a UX
|
|
22122
23168
|
* enhancement, not a critical path.
|
|
22123
23169
|
*/
|
|
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
|
-
}
|
|
23170
|
+
/**
|
|
23171
|
+
* Filename hint for the multipart body. Telegram voice is OGG/Opus; agents
|
|
23172
|
+
* may also attach mp3/m4a/wav which arrive as message:audio. Match the mime
|
|
23173
|
+
* so the decoder (Whisper / faster-whisper) gets a usable extension.
|
|
23174
|
+
*/
|
|
23175
|
+
function voiceFilenameExt(mimeType: string | undefined): string {
|
|
23176
|
+
return mimeType?.includes('mp3') ? 'mp3'
|
|
23177
|
+
: mimeType?.includes('m4a') ? 'm4a'
|
|
23178
|
+
: mimeType?.includes('wav') ? 'wav'
|
|
23179
|
+
: 'ogg'
|
|
23180
|
+
}
|
|
22147
23181
|
|
|
22148
|
-
|
|
22149
|
-
|
|
22150
|
-
|
|
23182
|
+
/**
|
|
23183
|
+
* Download a Telegram voice attachment into memory. Shared by the cloud
|
|
23184
|
+
* (Whisper) and local (sidecar) transcription paths. Returns the bytes on
|
|
23185
|
+
* success, or null on any failure (caller falls back). Never throws.
|
|
23186
|
+
*/
|
|
23187
|
+
async function downloadVoiceBytes(fileId: string): Promise<Uint8Array | null> {
|
|
22151
23188
|
try {
|
|
22152
23189
|
const file = await bot.api.getFile(fileId)
|
|
22153
23190
|
if (!file.file_path) {
|
|
@@ -22160,7 +23197,7 @@ async function maybeTranscribeVoice(
|
|
|
22160
23197
|
process.stderr.write(`telegram gateway: voice-in: telegram download HTTP ${res.status}\n`)
|
|
22161
23198
|
return null
|
|
22162
23199
|
}
|
|
22163
|
-
|
|
23200
|
+
return new Uint8Array(await res.arrayBuffer())
|
|
22164
23201
|
} catch (err) {
|
|
22165
23202
|
// Sanitize: never let the bot token leak into log lines via the
|
|
22166
23203
|
// download URL — strip anything that looks like a token.
|
|
@@ -22168,19 +23205,78 @@ async function maybeTranscribeVoice(
|
|
|
22168
23205
|
process.stderr.write(`telegram gateway: voice-in: download failed: ${msg}\n`)
|
|
22169
23206
|
return null
|
|
22170
23207
|
}
|
|
23208
|
+
}
|
|
22171
23209
|
|
|
22172
|
-
|
|
22173
|
-
|
|
22174
|
-
|
|
22175
|
-
|
|
22176
|
-
|
|
22177
|
-
|
|
22178
|
-
|
|
23210
|
+
/**
|
|
23211
|
+
* Transcribe a Telegram voice note via the LOCAL GPU STT sidecar (PR-B2).
|
|
23212
|
+
* Used when the host voice verdict is `local`. Resolves the shared-secret
|
|
23213
|
+
* token from the vault (voice/sidecar-token), downloads the audio, and
|
|
23214
|
+
* POSTs it to the sidecar at loopback (the gateway is network_mode: host).
|
|
23215
|
+
* Returns the transcript on success, null on any failure (caller falls
|
|
23216
|
+
* back to the legacy "(voice message)" envelope). Never throws.
|
|
23217
|
+
*/
|
|
23218
|
+
async function maybeTranscribeVoiceLocal(
|
|
23219
|
+
fileId: string,
|
|
23220
|
+
mimeType: string | undefined,
|
|
23221
|
+
language: string | undefined,
|
|
23222
|
+
): Promise<string | null> {
|
|
23223
|
+
const token = await materializeSidecarToken()
|
|
23224
|
+
if (!token) {
|
|
23225
|
+
// materializeSidecarToken already logged the specific reason.
|
|
23226
|
+
return null
|
|
23227
|
+
}
|
|
23228
|
+
|
|
23229
|
+
const audioBytes = await downloadVoiceBytes(fileId)
|
|
23230
|
+
if (!audioBytes) return null
|
|
23231
|
+
|
|
23232
|
+
const result = await transcribeViaSidecar({
|
|
23233
|
+
token,
|
|
23234
|
+
audio: audioBytes,
|
|
23235
|
+
filename: `voice.${voiceFilenameExt(mimeType)}`,
|
|
23236
|
+
language,
|
|
23237
|
+
})
|
|
23238
|
+
|
|
23239
|
+
if (!result.ok) {
|
|
23240
|
+
process.stderr.write(
|
|
23241
|
+
`telegram gateway: voice-in: local sidecar transcription failed reason=${result.reason}` +
|
|
23242
|
+
(result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
|
|
23243
|
+
'\n',
|
|
23244
|
+
)
|
|
23245
|
+
return null
|
|
23246
|
+
}
|
|
23247
|
+
|
|
23248
|
+
process.stderr.write(
|
|
23249
|
+
`telegram gateway: voice-in: local sidecar transcribed ${audioBytes.length} bytes in ${result.durationMs}ms ` +
|
|
23250
|
+
`lang=${result.language ?? '?'} audio_s=${result.audioSeconds ?? '?'} chars=${result.text.length}\n`,
|
|
23251
|
+
)
|
|
23252
|
+
return result.text
|
|
23253
|
+
}
|
|
23254
|
+
|
|
23255
|
+
async function maybeTranscribeVoice(
|
|
23256
|
+
fileId: string,
|
|
23257
|
+
mimeType: string | undefined,
|
|
23258
|
+
language: string | undefined,
|
|
23259
|
+
apiKeyRef: string | undefined,
|
|
23260
|
+
): Promise<string | null> {
|
|
23261
|
+
// Resolve the STT key through the vault broker at use-time (PR-A:
|
|
23262
|
+
// voice STT vault-unify). The configured `voice_in.api_key` is a
|
|
23263
|
+
// `vault:<key>` reference (default `vault:openai/api-key`); the
|
|
23264
|
+
// resolved value is held in memory only — never written to disk or
|
|
23265
|
+
// surfaced into the agent prompt. On any failure we return null and
|
|
23266
|
+
// the caller falls back to the legacy "(voice message)" envelope.
|
|
23267
|
+
const apiKey = await materializeVoiceKey({ apiKeyRef })
|
|
23268
|
+
if (!apiKey) {
|
|
23269
|
+
// materializeVoiceKey already logged the specific reason.
|
|
23270
|
+
return null
|
|
23271
|
+
}
|
|
23272
|
+
|
|
23273
|
+
const audioBytes = await downloadVoiceBytes(fileId)
|
|
23274
|
+
if (!audioBytes) return null
|
|
22179
23275
|
|
|
22180
23276
|
const result = await transcribeViaWhisper({
|
|
22181
23277
|
apiKey,
|
|
22182
23278
|
audio: audioBytes,
|
|
22183
|
-
filename: `voice.${
|
|
23279
|
+
filename: `voice.${voiceFilenameExt(mimeType)}`,
|
|
22184
23280
|
language,
|
|
22185
23281
|
})
|
|
22186
23282
|
|
|
@@ -22627,6 +23723,66 @@ bot.on('message:checklist_tasks_added' as Parameters<typeof bot.on>[0], (ctx) =>
|
|
|
22627
23723
|
handleChecklistUpdate(ctx as unknown as Context, 'checklist_tasks_added')
|
|
22628
23724
|
})
|
|
22629
23725
|
|
|
23726
|
+
// Suppress the "pinned a message" service message Telegram inserts when OUR
|
|
23727
|
+
// silent status-pin fires. The pin call passes `disable_notification: true`,
|
|
23728
|
+
// which kills the PUSH notification but NOT the in-chat service message — so
|
|
23729
|
+
// delete that service message as it lands, but ONLY for pins we own (tracked
|
|
23730
|
+
// in `statusPinState`). Manual/operator pins are never silent and are never
|
|
23731
|
+
// touched. Only silent status pins reach here as an OUR-pin match, so the
|
|
23732
|
+
// ownership check is the guard.
|
|
23733
|
+
//
|
|
23734
|
+
// Race tolerance: the service update can arrive before `reconcileStatusPin`
|
|
23735
|
+
// has stored the new PinState (the pin API call resolves, Telegram emits the
|
|
23736
|
+
// service message, and only then does the reconcile write the Map). A single
|
|
23737
|
+
// short retry covers that window; if it's still not one of ours, we leave the
|
|
23738
|
+
// service message alone.
|
|
23739
|
+
bot.on('message:pinned_message', async ctx => {
|
|
23740
|
+
const pinnedId = ctx.msg.pinned_message?.message_id
|
|
23741
|
+
if (pinnedId == null) return
|
|
23742
|
+
const chatId = String(ctx.chat.id)
|
|
23743
|
+
const serviceMsgId = ctx.msg.message_id
|
|
23744
|
+
|
|
23745
|
+
// Chat-scoped ownership (see pinnedMessageIsOurs): the match requires BOTH
|
|
23746
|
+
// the messageId AND that the tracked entry lives in THIS chat, so a pin id
|
|
23747
|
+
// colliding across chats can't delete a foreign (e.g. operator-manual) pin
|
|
23748
|
+
// notice. statusPinChatIds is the companion pinKey→chatId map written on
|
|
23749
|
+
// every desired-pinned reconcile.
|
|
23750
|
+
const trackedPins = (): TrackedStatusPin[] => {
|
|
23751
|
+
const out: TrackedStatusPin[] = []
|
|
23752
|
+
for (const [pinKey, state] of statusPinState) {
|
|
23753
|
+
const c = statusPinChatIds.get(pinKey)
|
|
23754
|
+
if (c != null) out.push({ chatId: c, messageId: state.messageId })
|
|
23755
|
+
}
|
|
23756
|
+
return out
|
|
23757
|
+
}
|
|
23758
|
+
const isOurs = () => pinnedMessageIsOurs(trackedPins(), chatId, pinnedId)
|
|
23759
|
+
|
|
23760
|
+
if (!isOurs()) {
|
|
23761
|
+
// Tolerate the reconcile-store race: wait briefly, then re-check once.
|
|
23762
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
23763
|
+
if (!isOurs()) return
|
|
23764
|
+
}
|
|
23765
|
+
|
|
23766
|
+
try {
|
|
23767
|
+
await robustApiCall(
|
|
23768
|
+
() => lockedBot.api.deleteMessage(chatId, serviceMsgId),
|
|
23769
|
+
{ chat_id: chatId, verb: 'status-pin.delete-service-message' },
|
|
23770
|
+
)
|
|
23771
|
+
} catch (err) {
|
|
23772
|
+
// Best-effort: a failure to delete the service message is cosmetic only —
|
|
23773
|
+
// the "pinned a message" line just stays. The most likely cause in a
|
|
23774
|
+
// supergroup/forum is the bot lacking can_delete_messages admin right, so
|
|
23775
|
+
// surface a concise one-liner (robustApiCall rethrows this case without
|
|
23776
|
+
// logging a reason) rather than swallowing silently — an operator sees WHY.
|
|
23777
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
23778
|
+
process.stderr.write(
|
|
23779
|
+
`telegram gateway: status-pin: could not delete pin service message ` +
|
|
23780
|
+
`(chat=${chatId} msg=${serviceMsgId}) — likely missing can_delete_messages ` +
|
|
23781
|
+
`admin right in this chat: ${msg}\n`,
|
|
23782
|
+
)
|
|
23783
|
+
}
|
|
23784
|
+
})
|
|
23785
|
+
|
|
22630
23786
|
// ─── Reaction-trigger runtime state (#1074) ──────────────────────────────
|
|
22631
23787
|
//
|
|
22632
23788
|
// Bot-message reactions in the configured allowlist trigger a synthetic
|
|
@@ -23562,6 +24718,40 @@ void (async () => {
|
|
|
23562
24718
|
// tracks the live turn from there.
|
|
23563
24719
|
try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
|
|
23564
24720
|
|
|
24721
|
+
// Strip stale permission cards from prior gateway session. Any entry
|
|
24722
|
+
// still in the store was never resolved (gateway died before the
|
|
24723
|
+
// operator tapped or the reaper ran). The operator might have seen
|
|
24724
|
+
// those cards and tapped them — if so, they got STALE_TAP_NOTICE
|
|
24725
|
+
// ("already resolved") which is misleading. Edit the messages to
|
|
24726
|
+
// remove the keyboard and show a clear "restarted" notice instead.
|
|
24727
|
+
void (async () => {
|
|
24728
|
+
const stale = permCardStore.loadAll()
|
|
24729
|
+
if (stale.length === 0) return
|
|
24730
|
+
process.stderr.write(
|
|
24731
|
+
`telegram gateway: boot-sweep: stripping ${stale.length} stale permission card(s) from prior gateway session\n`,
|
|
24732
|
+
)
|
|
24733
|
+
for (const card of stale) {
|
|
24734
|
+
const toolLabel = card.toolName ?? 'unknown tool'
|
|
24735
|
+
const notice = `🔒 **${toolLabel}**\n\n⚠️ *Gateway restarted — this request is no longer active. Ask your agent to try again if needed.*`
|
|
24736
|
+
try {
|
|
24737
|
+
// allow-raw-bot-api: targeted by message_id; no thread needed; fire-and-forget boot sweep
|
|
24738
|
+
await bot.api.editMessageText(
|
|
24739
|
+
card.chatId,
|
|
24740
|
+
card.messageId,
|
|
24741
|
+
richMessage(notice),
|
|
24742
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
24743
|
+
)
|
|
24744
|
+
} catch (err) {
|
|
24745
|
+
// Card may already be deleted, edited, or in an inaccessible chat — benign
|
|
24746
|
+
process.stderr.write(
|
|
24747
|
+
`telegram gateway: boot-sweep: stale-card strip failed ` +
|
|
24748
|
+
`${card.chatId}:${card.messageId}: ${(err as Error).message}\n`,
|
|
24749
|
+
)
|
|
24750
|
+
}
|
|
24751
|
+
}
|
|
24752
|
+
permCardStore.clear()
|
|
24753
|
+
})()
|
|
24754
|
+
|
|
23565
24755
|
// Boot-time pin sweep
|
|
23566
24756
|
try {
|
|
23567
24757
|
const bootAccess = loadAccess()
|
|
@@ -24147,6 +25337,8 @@ void (async () => {
|
|
|
24147
25337
|
elapsedMs: durationMs,
|
|
24148
25338
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
24149
25339
|
})
|
|
25340
|
+
// Status-pin: worker done — drop its pin.
|
|
25341
|
+
reconcileWorkerPin(agentId, null, false)
|
|
24150
25342
|
}
|
|
24151
25343
|
return
|
|
24152
25344
|
}
|
|
@@ -24164,6 +25356,8 @@ void (async () => {
|
|
|
24164
25356
|
elapsedMs: durationMs,
|
|
24165
25357
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
24166
25358
|
})
|
|
25359
|
+
// Status-pin: worker done — drop its pin.
|
|
25360
|
+
reconcileWorkerPin(agentId, null, false)
|
|
24167
25361
|
}
|
|
24168
25362
|
|
|
24169
25363
|
const handbackOrigin = resolveSubagentOriginChat(agentId)
|
|
@@ -24291,9 +25485,10 @@ void (async () => {
|
|
|
24291
25485
|
})
|
|
24292
25486
|
if (surface === 'worker-feed') {
|
|
24293
25487
|
const origin = resolveSubagentOriginChat(agentId)
|
|
25488
|
+
const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
|
|
24294
25489
|
void workerActivityFeed?.update(
|
|
24295
25490
|
agentId,
|
|
24296
|
-
|
|
25491
|
+
wkChat,
|
|
24297
25492
|
{
|
|
24298
25493
|
description: dispatch.feedDescription,
|
|
24299
25494
|
lastTool,
|
|
@@ -24303,7 +25498,7 @@ void (async () => {
|
|
|
24303
25498
|
state: 'running',
|
|
24304
25499
|
},
|
|
24305
25500
|
origin?.threadId,
|
|
24306
|
-
)
|
|
25501
|
+
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|
|
24307
25502
|
return
|
|
24308
25503
|
}
|
|
24309
25504
|
if (surface !== 'nest') return // 'skip' — orphan-status off
|
|
@@ -24425,9 +25620,10 @@ void (async () => {
|
|
|
24425
25620
|
// is gone — see resolveSubagentOriginChat).
|
|
24426
25621
|
if (workerFeedEnabled) {
|
|
24427
25622
|
const origin = resolveSubagentOriginChat(agentId)
|
|
25623
|
+
const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
|
|
24428
25624
|
void workerActivityFeed?.update(
|
|
24429
25625
|
agentId,
|
|
24430
|
-
|
|
25626
|
+
wkChat,
|
|
24431
25627
|
{
|
|
24432
25628
|
description: dispatch.feedDescription,
|
|
24433
25629
|
lastTool,
|
|
@@ -24437,7 +25633,7 @@ void (async () => {
|
|
|
24437
25633
|
state: 'running',
|
|
24438
25634
|
},
|
|
24439
25635
|
origin?.threadId,
|
|
24440
|
-
)
|
|
25636
|
+
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|
|
24441
25637
|
return
|
|
24442
25638
|
}
|
|
24443
25639
|
|