switchroom 0.16.23 → 0.16.27
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 +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +804 -465
- package/dist/host-control/main.js +169 -163
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +4 -4
- package/telegram-plugin/answer-stream.ts +20 -49
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +113 -113
- package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
- package/telegram-plugin/dist/server.js +161 -161
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +427 -680
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +881 -633
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +39 -6
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/permission-timeout.ts +76 -0
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +39 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +43 -3
- package/telegram-plugin/retry-api-call.ts +42 -7
- package/telegram-plugin/rich-send.ts +86 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +29 -7
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +49 -98
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +88 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
|
@@ -72,6 +72,11 @@ import {
|
|
|
72
72
|
timeoutDenyMessage,
|
|
73
73
|
duplicateDenyMessage,
|
|
74
74
|
isRecentTimeoutDuplicate,
|
|
75
|
+
PERMISSION_TTL_MS,
|
|
76
|
+
ttlForTool,
|
|
77
|
+
buildTimedOutCardEdits,
|
|
78
|
+
STALE_TAP_NOTICE,
|
|
79
|
+
type PermissionCardRef,
|
|
75
80
|
} from './permission-timeout.js'
|
|
76
81
|
import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
|
|
77
82
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
@@ -89,6 +94,7 @@ import {
|
|
|
89
94
|
createSwallowingRetryApiCall,
|
|
90
95
|
retryWithThreadFallback,
|
|
91
96
|
isHtmlParseRejectError,
|
|
97
|
+
isMessageTooLongError,
|
|
92
98
|
} from '../retry-api-call.js'
|
|
93
99
|
import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
|
|
94
100
|
import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
|
|
@@ -189,8 +195,8 @@ const REPLY_TO_TEXT_MAX = 200
|
|
|
189
195
|
const SILENT_END_FALLBACK_TEXT =
|
|
190
196
|
'⚠️ The agent finished working but didn’t send a reply — your last ' +
|
|
191
197
|
'message may not have been answered. Please try asking again.'
|
|
192
|
-
import {
|
|
193
|
-
import {
|
|
198
|
+
import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
199
|
+
import { richMessage } from '../rich-send.js'
|
|
194
200
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
195
201
|
import {
|
|
196
202
|
validateInlineKeyboard,
|
|
@@ -384,6 +390,18 @@ import {
|
|
|
384
390
|
buildVaultSaveFailedInbound,
|
|
385
391
|
buildVaultSaveDiscardedInbound,
|
|
386
392
|
} from './vault-grant-inbound-builders.js'
|
|
393
|
+
import {
|
|
394
|
+
parseSkillProposalCallback,
|
|
395
|
+
buildSkillProposalApplyInbound,
|
|
396
|
+
renderSkillProposalCard,
|
|
397
|
+
skillProposalKeyboard,
|
|
398
|
+
} from './skill-proposal-card.js'
|
|
399
|
+
import {
|
|
400
|
+
getProposal as getSkillProposal,
|
|
401
|
+
setProposalStatus as setSkillProposalStatus,
|
|
402
|
+
enqueueProposal as enqueueSkillProposal,
|
|
403
|
+
isSuppressed as isSkillProposalSuppressed,
|
|
404
|
+
} from '../../src/self-improve/skill-proposals.js'
|
|
387
405
|
import { decideSubagentHandback } from './subagent-handback-inbound-builder.js'
|
|
388
406
|
import {
|
|
389
407
|
decideSubagentProgress,
|
|
@@ -408,6 +426,7 @@ import type {
|
|
|
408
426
|
InjectInboundMessage,
|
|
409
427
|
SendOutboundMessage,
|
|
410
428
|
QuotaWallDetectedMessage,
|
|
429
|
+
PostSkillProposalMessage,
|
|
411
430
|
PermissionEvent,
|
|
412
431
|
} from './ipc-protocol.js'
|
|
413
432
|
import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
|
|
@@ -934,7 +953,8 @@ function defaultAccess(): Access {
|
|
|
934
953
|
return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} }
|
|
935
954
|
}
|
|
936
955
|
|
|
937
|
-
|
|
956
|
+
// Rich-message wire cap (#2669): the rich path allows 32768 UTF-8 chars.
|
|
957
|
+
const MAX_CHUNK_LIMIT = RICH_MESSAGE_MAX_CHARS
|
|
938
958
|
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
|
|
939
959
|
|
|
940
960
|
function assertSendable(f: string): void {
|
|
@@ -1922,7 +1942,6 @@ interface OutstandingCompactCard {
|
|
|
1922
1942
|
let compactNotifyState: CompactNotifyState = idleCompactNotifyState()
|
|
1923
1943
|
let outstandingCompactCard: OutstandingCompactCard | null = null
|
|
1924
1944
|
const activeDraftStreams = new Map<string, DraftStreamHandle>()
|
|
1925
|
-
const activeDraftParseModes = new Map<string, 'HTML' | 'MarkdownV2' | undefined>()
|
|
1926
1945
|
const suppressPtyPreview = new Set<string>()
|
|
1927
1946
|
const lastPtyPreviewByChat = new Map<string, string>()
|
|
1928
1947
|
const progressUpdateLastSent = new Map<string, number>()
|
|
@@ -3662,15 +3681,14 @@ async function postCompactCard(occ: number, cap: number): Promise<void> {
|
|
|
3662
3681
|
supergroupChatId: resolveAgentSupergroupChatId(),
|
|
3663
3682
|
});
|
|
3664
3683
|
const text =
|
|
3665
|
-
`🗜️
|
|
3684
|
+
`🗜️ **Context compaction**\n` +
|
|
3666
3685
|
`Working context hit ~${occ.toLocaleString()} tokens ` +
|
|
3667
|
-
`(cap ${cap.toLocaleString()}) — running
|
|
3686
|
+
`(cap ${cap.toLocaleString()}) — running \`/compact\`. ` +
|
|
3668
3687
|
`Older detail moves to Hindsight; I'll confirm here once the ` +
|
|
3669
3688
|
`context has shrunk (may take a turn or two).`;
|
|
3670
3689
|
const sent = await swallowingApiCall(
|
|
3671
3690
|
() =>
|
|
3672
|
-
bot.api.
|
|
3673
|
-
parse_mode: 'HTML',
|
|
3691
|
+
bot.api.sendRichMessage(chatId, richMessage(text), {
|
|
3674
3692
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
3675
3693
|
}),
|
|
3676
3694
|
{ chat_id: chatId, verb: 'proactiveCompact.start' },
|
|
@@ -3720,7 +3738,7 @@ async function resolveCompactCard(
|
|
|
3720
3738
|
let text: string;
|
|
3721
3739
|
if (kind === 'finished') {
|
|
3722
3740
|
text =
|
|
3723
|
-
`✅
|
|
3741
|
+
`✅ **Context compacted**\n` +
|
|
3724
3742
|
`Working context reduced` +
|
|
3725
3743
|
(occNow != null
|
|
3726
3744
|
? ` (~${card.occAtStart.toLocaleString()} → ` +
|
|
@@ -3729,19 +3747,18 @@ async function resolveCompactCard(
|
|
|
3729
3747
|
`. Hindsight retains the detail.`;
|
|
3730
3748
|
} else if (kind === 'superseded') {
|
|
3731
3749
|
text =
|
|
3732
|
-
`↩️
|
|
3750
|
+
`↩️ **Context compaction superseded**\n` +
|
|
3733
3751
|
`A newer compaction started before this one confirmed.`;
|
|
3734
3752
|
} else {
|
|
3735
3753
|
text =
|
|
3736
|
-
`⚠️
|
|
3737
|
-
|
|
3754
|
+
`⚠️ **Compaction issued**\n` +
|
|
3755
|
+
`\`/compact\` was requested but the context isn't ` +
|
|
3738
3756
|
`confirmed reduced yet. Native compaction and Hindsight still apply.`;
|
|
3739
3757
|
}
|
|
3740
3758
|
try {
|
|
3741
3759
|
await swallowingApiCall(
|
|
3742
3760
|
() =>
|
|
3743
|
-
bot.api.editMessageText(card.chatId, card.messageId, text, {
|
|
3744
|
-
parse_mode: 'HTML',
|
|
3761
|
+
bot.api.editMessageText(card.chatId, card.messageId, richMessage(text), {
|
|
3745
3762
|
}),
|
|
3746
3763
|
{ chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
|
|
3747
3764
|
);
|
|
@@ -3945,8 +3962,7 @@ function postPermissionResumeMessage(opts: {
|
|
|
3945
3962
|
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
|
|
3946
3963
|
void swallowingApiCall(
|
|
3947
3964
|
() =>
|
|
3948
|
-
bot.api.
|
|
3949
|
-
parse_mode: 'HTML',
|
|
3965
|
+
bot.api.sendRichMessage(chatId, richMessage(text), {
|
|
3950
3966
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
3951
3967
|
}),
|
|
3952
3968
|
{ chat_id: chatId, verb: 'permission-resume', ...(threadId != null ? { threadId } : {}) },
|
|
@@ -4033,21 +4049,6 @@ function chunk(text: string, limit: number, mode: 'length' | 'newline'): string[
|
|
|
4033
4049
|
return out
|
|
4034
4050
|
}
|
|
4035
4051
|
|
|
4036
|
-
function escapeMarkdownV2(text: string): string {
|
|
4037
|
-
const specialChars = /[_*\[\]()~`>#+\-=|{}.!\\]/g
|
|
4038
|
-
const parts: string[] = []
|
|
4039
|
-
let last = 0
|
|
4040
|
-
const codeRe = /(```[\s\S]*?```|`[^`\n]+`)/g
|
|
4041
|
-
let m: RegExpExecArray | null
|
|
4042
|
-
while ((m = codeRe.exec(text)) !== null) {
|
|
4043
|
-
if (m.index > last) parts.push(text.slice(last, m.index).replace(specialChars, '\\$&'))
|
|
4044
|
-
parts.push(m[0])
|
|
4045
|
-
last = m.index + m[0].length
|
|
4046
|
-
}
|
|
4047
|
-
if (last < text.length) parts.push(text.slice(last).replace(specialChars, '\\$&'))
|
|
4048
|
-
return parts.join('')
|
|
4049
|
-
}
|
|
4050
|
-
|
|
4051
4052
|
// ─── Typing indicator ─────────────────────────────────────────────────────
|
|
4052
4053
|
// All four state maps re-keyed from `chat_id` to `chatKey(chat, thread)`
|
|
4053
4054
|
// in PR3 of the supergroup-mode rollout. In supergroup mode one agent
|
|
@@ -4193,14 +4194,17 @@ function wrapBootCardApi(
|
|
|
4193
4194
|
verb: 'boot-card',
|
|
4194
4195
|
...(threadId != null ? { threadId } : {}),
|
|
4195
4196
|
})
|
|
4197
|
+
// #2669: card bodies are raw GFM markdown — send them via the
|
|
4198
|
+
// rich-message path. The card modules pass plain `opts` (no parse_mode).
|
|
4196
4199
|
return {
|
|
4197
4200
|
sendMessage: async (cid, text, sendOpts) => {
|
|
4198
4201
|
const sent = await robustApiCall(
|
|
4199
4202
|
() =>
|
|
4200
|
-
|
|
4203
|
+
// allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
|
|
4204
|
+
lockedBot.api.sendRichMessage(
|
|
4201
4205
|
cid,
|
|
4202
|
-
text,
|
|
4203
|
-
sendOpts as Parameters<typeof lockedBot.api.
|
|
4206
|
+
richMessage(text),
|
|
4207
|
+
sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
|
|
4204
4208
|
),
|
|
4205
4209
|
opts(cid),
|
|
4206
4210
|
)
|
|
@@ -4212,7 +4216,7 @@ function wrapBootCardApi(
|
|
|
4212
4216
|
lockedBot.api.editMessageText(
|
|
4213
4217
|
cid,
|
|
4214
4218
|
mid,
|
|
4215
|
-
text,
|
|
4219
|
+
richMessage(text),
|
|
4216
4220
|
editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
|
|
4217
4221
|
),
|
|
4218
4222
|
opts(cid),
|
|
@@ -4226,7 +4230,7 @@ function wrapBootCardApi(
|
|
|
4226
4230
|
type EditOpts = Parameters<Api['editMessageText']>[3]
|
|
4227
4231
|
try {
|
|
4228
4232
|
// allow-raw-bot-api: boot-card edit-in-place probe — must detect a deleted target, which the shared retry policy swallows.
|
|
4229
|
-
await lockedBot.api.editMessageText(cid, mid, text, editOpts as EditOpts)
|
|
4233
|
+
await lockedBot.api.editMessageText(cid, mid, richMessage(text), editOpts as EditOpts)
|
|
4230
4234
|
return 'edited'
|
|
4231
4235
|
} catch (err) {
|
|
4232
4236
|
const desc =
|
|
@@ -4256,14 +4260,16 @@ function wrapIssuesCardApi(
|
|
|
4256
4260
|
verb: 'issues-card',
|
|
4257
4261
|
...(threadId != null ? { threadId } : {}),
|
|
4258
4262
|
})
|
|
4263
|
+
// #2669: issues-card body is raw GFM markdown — send via the rich path.
|
|
4259
4264
|
return {
|
|
4260
4265
|
sendMessage: async (cid, text, sendOpts) => {
|
|
4261
4266
|
const sent = await robustApiCall(
|
|
4262
4267
|
() =>
|
|
4263
|
-
|
|
4268
|
+
// allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
|
|
4269
|
+
lockedBot.api.sendRichMessage(
|
|
4264
4270
|
cid,
|
|
4265
|
-
text,
|
|
4266
|
-
sendOpts as Parameters<typeof lockedBot.api.
|
|
4271
|
+
richMessage(text),
|
|
4272
|
+
sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
|
|
4267
4273
|
),
|
|
4268
4274
|
opts(cid),
|
|
4269
4275
|
)
|
|
@@ -4275,7 +4281,7 @@ function wrapIssuesCardApi(
|
|
|
4275
4281
|
lockedBot.api.editMessageText(
|
|
4276
4282
|
cid,
|
|
4277
4283
|
mid,
|
|
4278
|
-
text,
|
|
4284
|
+
richMessage(text),
|
|
4279
4285
|
editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
|
|
4280
4286
|
),
|
|
4281
4287
|
opts(cid),
|
|
@@ -4312,8 +4318,10 @@ const STATUS_QUERY_RE = /^\s*status\??\s*$/i
|
|
|
4312
4318
|
|
|
4313
4319
|
// ─── Permission handling ──────────────────────────────────────────────────
|
|
4314
4320
|
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
|
|
4315
|
-
const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number }>()
|
|
4316
|
-
|
|
4321
|
+
const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number; card_text: string; cards: { chatId: string; messageId: number }[] }>()
|
|
4322
|
+
// PERMISSION_TTL_MS / ttlForTool / the timed-out card builder now live in
|
|
4323
|
+
// ./permission-timeout.ts (pure + unit-testable). hostd gated verbs get a
|
|
4324
|
+
// 30-min window; everything else keeps the 10-min default.
|
|
4317
4325
|
// No-repeat-on-timeout (marko Rentals-budget loop, 2026-06-17). When a card
|
|
4318
4326
|
// auto-denies on TTL, the model is told it was a TIMEOUT (not a denial) so it
|
|
4319
4327
|
// doesn't retry; if it retries the identical (tool, input) anyway while the
|
|
@@ -4944,7 +4952,10 @@ const pendingStateReaper = setInterval(() => {
|
|
|
4944
4952
|
if (now - v.startedAt > VAULT_INPUT_TTL_MS) pendingVaultOps.delete(k)
|
|
4945
4953
|
}
|
|
4946
4954
|
for (const [k, v] of pendingPermissions) {
|
|
4947
|
-
|
|
4955
|
+
// hostd gated fleet-mutation verbs get a longer (30-min) human-scale
|
|
4956
|
+
// decision window than the 10-min default (Bug 2 fix #2).
|
|
4957
|
+
const ttl = ttlForTool(v.tool_name)
|
|
4958
|
+
if (now - v.startedAt > ttl) {
|
|
4948
4959
|
// Don't just drop it: the claude turn is suspended INSIDE the MCP
|
|
4949
4960
|
// permission call waiting for a verdict. A silent delete left it
|
|
4950
4961
|
// wedged forever when the operator never tapped — permanent
|
|
@@ -4956,13 +4967,20 @@ const pendingStateReaper = setInterval(() => {
|
|
|
4956
4967
|
// Carry a TIMEOUT reason to the model (claude renders it as "…the user
|
|
4957
4968
|
// said: …") so it can tell a timeout from a real denial and not retry
|
|
4958
4969
|
// the identical call — the duplicate-card loop this series closes.
|
|
4959
|
-
const timeoutMinutes = Math.round(
|
|
4970
|
+
const timeoutMinutes = Math.round(ttl / 60000)
|
|
4960
4971
|
dispatchPermissionVerdict({
|
|
4961
4972
|
type: 'permission',
|
|
4962
4973
|
requestId: k,
|
|
4963
4974
|
behavior: 'deny',
|
|
4964
4975
|
message: timeoutDenyMessage(timeoutMinutes),
|
|
4965
4976
|
})
|
|
4977
|
+
// Strip the card's inline keyboard + mark it timed out (Bug 2 fix #1).
|
|
4978
|
+
// Before this, the reaper deleted the pending entry but left a LIVE
|
|
4979
|
+
// Approve button — a late operator tap then dispatched a verdict for an
|
|
4980
|
+
// already-resolved/dead request_id, which Claude Code ignores, so the
|
|
4981
|
+
// operator "approved" but work never continued. Stripping the keyboard
|
|
4982
|
+
// makes the timeout legible and removes the stale tappable button.
|
|
4983
|
+
void stripTimedOutPermissionCards(v.card_text, v.cards)
|
|
4966
4984
|
// The auto-deny un-parks the suspended turn — flip 🙏 → working so
|
|
4967
4985
|
// it doesn't sit on the awaiting glyph (or stall) after the timeout.
|
|
4968
4986
|
resumeReactionAfterVerdict()
|
|
@@ -5320,7 +5338,6 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
5320
5338
|
// grammy's Other<...> opts type is generated and stricter than our
|
|
5321
5339
|
// call shape; runtime accepts both. Cast through unknown.
|
|
5322
5340
|
const opts = {
|
|
5323
|
-
parse_mode: 'HTML' as const,
|
|
5324
5341
|
...(renderedKeyboard ? { reply_markup: renderedKeyboard } : {}),
|
|
5325
5342
|
...(opEventThread != null ? { message_thread_id: opEventThread } : {}),
|
|
5326
5343
|
}
|
|
@@ -5328,7 +5345,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
5328
5345
|
// very next line is what unlocks the raw bot.api call.
|
|
5329
5346
|
// Opts now includes message_thread_id when supergroup mode is on.
|
|
5330
5347
|
// allow-raw-bot-api: operator-event broadcast loop; topic-aware opts
|
|
5331
|
-
void bot.api.
|
|
5348
|
+
void bot.api.sendRichMessage(chat_id, richMessage(renderedText), opts as never).catch(e => {
|
|
5332
5349
|
process.stderr.write(
|
|
5333
5350
|
`telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
|
|
5334
5351
|
)
|
|
@@ -5356,9 +5373,7 @@ function postLegacyBanner(
|
|
|
5356
5373
|
retryWithThreadFallback(
|
|
5357
5374
|
robustApiCall,
|
|
5358
5375
|
(tid) =>
|
|
5359
|
-
lockedBot.api.
|
|
5360
|
-
parse_mode: 'HTML',
|
|
5361
|
-
link_preview_options: { is_disabled: true },
|
|
5376
|
+
lockedBot.api.sendRichMessage(chatId, richMessage(text), {
|
|
5362
5377
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
5363
5378
|
...(ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {}),
|
|
5364
5379
|
}),
|
|
@@ -5612,11 +5627,14 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
5612
5627
|
|
|
5613
5628
|
// #1122: framework safety-net for "model is silent to the user for >5min."
|
|
5614
5629
|
// Starts a single setInterval poll that walks active turns; at 300s of
|
|
5615
|
-
// silence the framework
|
|
5616
|
-
// still thinking…" message
|
|
5617
|
-
//
|
|
5618
|
-
//
|
|
5619
|
-
//
|
|
5630
|
+
// silence the framework unwedges the turn. The generic "still working… /
|
|
5631
|
+
// still thinking…" stall message it used to send was a stop-gap from
|
|
5632
|
+
// before the live draft carried the progress beats and has been retired
|
|
5633
|
+
// (the stall-stop-gap PR) — the surviving user-visible sends are the
|
|
5634
|
+
// deterministic update_apply phase line and the approval-blocked re-ping.
|
|
5635
|
+
// The model-targeted nudge ladder (ack/soft/firm) and the 60s awareness
|
|
5636
|
+
// ping were retired earlier once the live-updating reply/draft took over
|
|
5637
|
+
// the pacing job — only this single unwedge fallback remains. Honours
|
|
5620
5638
|
// SWITCHROOM_DISABLE_SILENCE_POKE=1 kill switch (no-op if set).
|
|
5621
5639
|
// Set when this gateway dispatches an `update_apply` to hostd that
|
|
5622
5640
|
// returns `started`; cleared when the dispatch poll resolves (terminal
|
|
@@ -5752,6 +5770,14 @@ silencePoke.startTimer({
|
|
|
5752
5770
|
ctx.inFlightTools,
|
|
5753
5771
|
blockedOnApproval,
|
|
5754
5772
|
)
|
|
5773
|
+
// The stall-notice stop-gap is retired: `formatFrameworkFallbackText`
|
|
5774
|
+
// returns null for the pure-liveness "still working / running <Tool>"
|
|
5775
|
+
// beat (the only string it still emits is the approval-blocked re-ping).
|
|
5776
|
+
// The early quiet floor beat existed ONLY to surface that stall notice, so
|
|
5777
|
+
// with the notice gone there is nothing to send here unless the turn is
|
|
5778
|
+
// parked on an approval card. Skip silently otherwise; the live draft +
|
|
5779
|
+
// the model's own pacing beats carry progress.
|
|
5780
|
+
if (text == null) return
|
|
5755
5781
|
try {
|
|
5756
5782
|
await robustApiCall(
|
|
5757
5783
|
() => bot.api.sendMessage(ctx.chatId, text, {
|
|
@@ -5849,7 +5875,10 @@ silencePoke.startTimer({
|
|
|
5849
5875
|
)
|
|
5850
5876
|
}
|
|
5851
5877
|
// #2527: log the actual poke fire with structured data before sending,
|
|
5852
|
-
// so the event is visible even if the send fails.
|
|
5878
|
+
// so the event is visible even if the send fails. The stall-notice
|
|
5879
|
+
// stop-gap is retired (the user-visible send below is now gated), but the
|
|
5880
|
+
// fire event is still logged so observability + the unwedge accounting are
|
|
5881
|
+
// unchanged — mirrors PR #2641 (drop the chat badge, keep the log).
|
|
5853
5882
|
logStreamingEvent({
|
|
5854
5883
|
kind: 'silence_poke_fire',
|
|
5855
5884
|
chatId: ctx.chatId,
|
|
@@ -5857,24 +5886,34 @@ silencePoke.startTimer({
|
|
|
5857
5886
|
silenceMs: ctx.silenceMs,
|
|
5858
5887
|
fallbackKind: ctx.fallbackKind,
|
|
5859
5888
|
})
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5889
|
+
// Send a user-visible message ONLY when there's something honest to say:
|
|
5890
|
+
// - a deterministic in-flight update_apply phase (`text` set above), or
|
|
5891
|
+
// - the approval-blocked re-ping (`formatFrameworkFallbackText` returns a
|
|
5892
|
+
// string only for that case).
|
|
5893
|
+
// The generic "still working… (no update from agent in N min)" stall notice
|
|
5894
|
+
// is retired — `formatFrameworkFallbackText` returns null for it, so `text`
|
|
5895
|
+
// stays null and we skip the send. The turn-teardown below (the unwedge —
|
|
5896
|
+
// the one job the live draft can't do, per the conversational-pacing RFC)
|
|
5897
|
+
// still runs unconditionally.
|
|
5898
|
+
if (text != null) {
|
|
5899
|
+
try {
|
|
5900
|
+
await robustApiCall(
|
|
5901
|
+
() => bot.api.sendMessage(ctx.chatId, text, {
|
|
5902
|
+
...(ctx.threadId != null ? { message_thread_id: ctx.threadId } : {}),
|
|
5903
|
+
// Conditional: when the turn is parked on an approval card, this
|
|
5904
|
+
// fallback carries a user-gating re-ping ("waiting for your
|
|
5905
|
+
// approval — tap Approve or Deny …") — that must PING, because the
|
|
5906
|
+
// user is the one being waited on. The deterministic update-status
|
|
5907
|
+
// line stays SILENT (a status surface). Gate on `blockedOnApproval`.
|
|
5908
|
+
disable_notification: blockedOnApproval ? false : true,
|
|
5909
|
+
}),
|
|
5910
|
+
{ chat_id: ctx.chatId, ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}) },
|
|
5911
|
+
)
|
|
5912
|
+
} catch (err) {
|
|
5913
|
+
process.stderr.write(
|
|
5914
|
+
`silence-poke fallback sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
|
|
5915
|
+
)
|
|
5916
|
+
}
|
|
5878
5917
|
}
|
|
5879
5918
|
// #1122 follow-up: end the wedged turn AFTER the fallback fires.
|
|
5880
5919
|
// Without this, `activeTurnStartedAt` stays set, every subsequent
|
|
@@ -6197,19 +6236,18 @@ _deliveryConfirmSweep.unref?.()
|
|
|
6197
6236
|
// `SWITCHROOM_DISABLE_PENDING_PROGRESS=1`.
|
|
6198
6237
|
pendingProgress.startTimer({
|
|
6199
6238
|
editMessage: async (ctx) => {
|
|
6200
|
-
// #
|
|
6201
|
-
// the
|
|
6202
|
-
//
|
|
6203
|
-
//
|
|
6204
|
-
//
|
|
6205
|
-
const editOpts = ctx.parseMode != null ? { parse_mode: ctx.parseMode } : undefined
|
|
6239
|
+
// #2669: re-edit via the SAME shape the anchor was sent with. A rich
|
|
6240
|
+
// anchor (the reply tool's default) re-edits via the rich-markdown
|
|
6241
|
+
// path so the body keeps rendering its markdown; a literal
|
|
6242
|
+
// `format:'text'` anchor re-edits as a plain string. This is the
|
|
6243
|
+
// single-rich-path successor to the #1698 parse-mode contract.
|
|
6206
6244
|
await swallowingApiCall(
|
|
6207
6245
|
() =>
|
|
6208
6246
|
lockedBot.api.editMessageText(
|
|
6209
6247
|
ctx.chatId,
|
|
6210
6248
|
ctx.messageId,
|
|
6211
|
-
ctx.newText,
|
|
6212
|
-
|
|
6249
|
+
ctx.literalText ? ctx.newText : richMessage(ctx.newText),
|
|
6250
|
+
undefined,
|
|
6213
6251
|
),
|
|
6214
6252
|
{
|
|
6215
6253
|
chat_id: ctx.chatId,
|
|
@@ -6545,6 +6583,32 @@ function buildPermissionActionRow(
|
|
|
6545
6583
|
return kb
|
|
6546
6584
|
}
|
|
6547
6585
|
|
|
6586
|
+
/**
|
|
6587
|
+
* Strip the inline keyboard from one or more timed-out permission cards and
|
|
6588
|
+
* append a "timed out — re-request to act" line (Bug 2 fix #1). Called from
|
|
6589
|
+
* the pendingStateReaper, which has no grammy `ctx`, so it edits via the
|
|
6590
|
+
* bot.api directly — routed through `swallowingApiCall` so a deleted card
|
|
6591
|
+
* (operator removed the message) is swallowed rather than crashing the sweep.
|
|
6592
|
+
*
|
|
6593
|
+
* A single `editMessageText` re-renders the original card body plus a
|
|
6594
|
+
* timed-out footer; omitting `reply_markup` drops the keyboard atomically
|
|
6595
|
+
* with the text edit (same approach the normal tap finalizer uses, which
|
|
6596
|
+
* strips the keyboard alongside its status-line edit). Best-effort: a failed
|
|
6597
|
+
* edit never blocks the auto-deny that already fired.
|
|
6598
|
+
*/
|
|
6599
|
+
async function stripTimedOutPermissionCards(
|
|
6600
|
+
cardText: string,
|
|
6601
|
+
cards: PermissionCardRef[],
|
|
6602
|
+
): Promise<void> {
|
|
6603
|
+
for (const edit of buildTimedOutCardEdits(cardText, cards)) {
|
|
6604
|
+
await swallowingApiCall(
|
|
6605
|
+
// allow-raw-bot-api: routed through swallowingApiCall (retry policy); message-id-targeted edit (no thread to lose). Passing {} as opts (no reply_markup) strips the stale Allow/Deny keyboard atomically with the text edit.
|
|
6606
|
+
() => bot.api.editMessageText(edit.chatId, edit.messageId, richMessage(edit.text), {}),
|
|
6607
|
+
{ chat_id: edit.chatId, verb: 'permission_timeout.strip' },
|
|
6608
|
+
)
|
|
6609
|
+
}
|
|
6610
|
+
}
|
|
6611
|
+
|
|
6548
6612
|
function dispatchPermissionVerdict(ev: PermissionEvent): void {
|
|
6549
6613
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
6550
6614
|
const delivered = ipcServer.sendToAgent(selfAgent, ev)
|
|
@@ -6792,7 +6856,6 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6792
6856
|
activeTurnStartedAt,
|
|
6793
6857
|
claudeBusyKeys,
|
|
6794
6858
|
activeDraftStreams,
|
|
6795
|
-
activeDraftParseModes,
|
|
6796
6859
|
clearActiveReactions: () => {
|
|
6797
6860
|
const ad = resolveAgentDirFromEnv()
|
|
6798
6861
|
if (ad) clearActiveReactions(ad)
|
|
@@ -6977,18 +7040,21 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6977
7040
|
return
|
|
6978
7041
|
}
|
|
6979
7042
|
}
|
|
6980
|
-
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
|
|
6981
7043
|
// Natural-language card body — a plain sentence ("Gymbro wants to
|
|
6982
7044
|
// edit: supplement-log.md" + a why-line), never a raw tool id.
|
|
6983
7045
|
// The operator sees what is being requested and why at a glance.
|
|
6984
|
-
// Mirrors the `vault_request_access` card layout. Sent
|
|
6985
|
-
//
|
|
7046
|
+
// Mirrors the `vault_request_access` card layout. Sent via the
|
|
7047
|
+
// rich-markdown path below.
|
|
6986
7048
|
const text = formatPermissionCardBody({
|
|
6987
7049
|
toolName,
|
|
6988
7050
|
inputPreview,
|
|
6989
7051
|
description,
|
|
6990
7052
|
agentName: _client.agentName,
|
|
6991
7053
|
})
|
|
7054
|
+
// `card_text` is retained so the TTL reaper can re-edit the SAME body
|
|
7055
|
+
// with a "timed out" footer while stripping the keyboard atomically
|
|
7056
|
+
// (Bug 2 fix #1) — the reaper has no grammy ctx to read the live text.
|
|
7057
|
+
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now(), card_text: text, cards: [] })
|
|
6992
7058
|
// Compact action row: ❌ Deny · ✅ Allow · 🔁 Always… — the scope of an
|
|
6993
7059
|
// "always" grant stays hidden until the operator taps "🔁 Always…",
|
|
6994
7060
|
// which swaps the row for a scope choice (this file / any file ⚠️). The
|
|
@@ -7009,8 +7075,8 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7009
7075
|
const activeTurn = currentTurn
|
|
7010
7076
|
const targets = resolvePermissionCardTargets()
|
|
7011
7077
|
for (const { chatId, threadId } of targets) {
|
|
7012
|
-
//
|
|
7013
|
-
//
|
|
7078
|
+
// The rich-markdown path pairs with formatPermissionCardBody (#1790)
|
|
7079
|
+
// so its bold/italic render. retryWithThreadFallback: if the topic was
|
|
7014
7080
|
// deleted/recreated (stale thread id → 400 "message thread not
|
|
7015
7081
|
// found"), re-send thread-less into the main chat so the card still
|
|
7016
7082
|
// ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
|
|
@@ -7018,13 +7084,22 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7018
7084
|
void retryWithThreadFallback<{ message_id: number }>(
|
|
7019
7085
|
robustApiCall,
|
|
7020
7086
|
(tid) =>
|
|
7021
|
-
bot.api.
|
|
7022
|
-
parse_mode: 'HTML',
|
|
7087
|
+
bot.api.sendRichMessage(chatId, richMessage(text), {
|
|
7023
7088
|
reply_markup: keyboard,
|
|
7024
7089
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
7025
7090
|
}),
|
|
7026
7091
|
{ threadId, chat_id: chatId, verb: 'permission_request' },
|
|
7027
|
-
).
|
|
7092
|
+
).then(sent => {
|
|
7093
|
+
// Record the live card's (chat, message) so the TTL reaper can strip
|
|
7094
|
+
// its inline keyboard on auto-deny — a stale Approve button left
|
|
7095
|
+
// tappable dispatches a verdict for a dead request_id (Bug 2). The
|
|
7096
|
+
// entry may already be gone (operator tapped before this resolved);
|
|
7097
|
+
// guard the lookup.
|
|
7098
|
+
const pend = pendingPermissions.get(requestId)
|
|
7099
|
+
if (pend && sent && typeof sent.message_id === 'number') {
|
|
7100
|
+
pend.cards.push({ chatId, messageId: sent.message_id })
|
|
7101
|
+
}
|
|
7102
|
+
}).catch(e => {
|
|
7028
7103
|
process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
|
|
7029
7104
|
})
|
|
7030
7105
|
}
|
|
@@ -7198,8 +7273,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7198
7273
|
try {
|
|
7199
7274
|
const sent = await robustApiCall(
|
|
7200
7275
|
() =>
|
|
7201
|
-
bot.api.
|
|
7202
|
-
parse_mode: 'HTML',
|
|
7276
|
+
bot.api.sendRichMessage(args.chatId, richMessage(args.text), {
|
|
7203
7277
|
...(args.threadId !== undefined
|
|
7204
7278
|
? { message_thread_id: args.threadId }
|
|
7205
7279
|
: {}),
|
|
@@ -7349,8 +7423,8 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7349
7423
|
try {
|
|
7350
7424
|
const sent = await robustApiCall(
|
|
7351
7425
|
() =>
|
|
7352
|
-
bot
|
|
7353
|
-
|
|
7426
|
+
// allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
|
|
7427
|
+
bot.api.sendRichMessage(args.chatId, richMessage(args.text), {
|
|
7354
7428
|
...(args.threadId !== undefined
|
|
7355
7429
|
? { message_thread_id: args.threadId }
|
|
7356
7430
|
: {}),
|
|
@@ -7374,8 +7448,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7374
7448
|
try {
|
|
7375
7449
|
await robustApiCall(
|
|
7376
7450
|
() =>
|
|
7377
|
-
bot.api.editMessageText(args.chatId, args.messageId, args.text, {
|
|
7378
|
-
parse_mode: 'HTML',
|
|
7451
|
+
bot.api.editMessageText(args.chatId, args.messageId, richMessage(args.text), {
|
|
7379
7452
|
// Strip the inline keyboard on a terminal/interim edit so the
|
|
7380
7453
|
// [Approve]/[Deny] buttons stop being tappable on a resolved card.
|
|
7381
7454
|
...(args.stripKeyboard ? { reply_markup: { inline_keyboard: [] } } : {}),
|
|
@@ -7446,8 +7519,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7446
7519
|
try {
|
|
7447
7520
|
await robustApiCall(
|
|
7448
7521
|
() =>
|
|
7449
|
-
bot.api.editMessageText(args.chatId, args.messageId, args.text, {
|
|
7450
|
-
parse_mode: 'HTML',
|
|
7522
|
+
bot.api.editMessageText(args.chatId, args.messageId, richMessage(args.text), {
|
|
7451
7523
|
// Finalize is terminal — drop the keyboard so buttons are gone.
|
|
7452
7524
|
...(args.stripKeyboard ? { reply_markup: { inline_keyboard: [] } } : {}),
|
|
7453
7525
|
}),
|
|
@@ -7544,15 +7616,18 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7544
7616
|
return
|
|
7545
7617
|
}
|
|
7546
7618
|
const threadId = msg.threadId
|
|
7547
|
-
|
|
7619
|
+
// #2669: literal `text` mode → plain send; everything else → rich markdown.
|
|
7620
|
+
const literalText = msg.parseMode === 'text'
|
|
7621
|
+
const outOpts = {
|
|
7622
|
+
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
7623
|
+
}
|
|
7548
7624
|
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send.
|
|
7549
7625
|
// General topic (thread 1) sends omit message_thread_id per the outbound convention.
|
|
7550
7626
|
void swallowingApiCall(
|
|
7551
|
-
() =>
|
|
7552
|
-
|
|
7553
|
-
|
|
7554
|
-
|
|
7555
|
-
}),
|
|
7627
|
+
(): Promise<{ message_id: number }> =>
|
|
7628
|
+
literalText
|
|
7629
|
+
? bot.api.sendMessage(msg.chatId, msg.text, outOpts)
|
|
7630
|
+
: bot.api.sendRichMessage(msg.chatId, richMessage(msg.text), outOpts),
|
|
7556
7631
|
{ chat_id: msg.chatId, verb: 'cron-action-send', ...(threadId != null ? { threadId } : {}) },
|
|
7557
7632
|
)
|
|
7558
7633
|
process.stderr.write(
|
|
@@ -7580,6 +7655,78 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7580
7655
|
void fireFleetAutoFallback(msg.agentName, untilMs)
|
|
7581
7656
|
},
|
|
7582
7657
|
|
|
7658
|
+
// #2670 one-tap self-improvement — persist a skill-improvement proposal and
|
|
7659
|
+
// post its Approve/Dismiss card. The store transition + apply-injection on
|
|
7660
|
+
// Approve are owned by handleSkillProposalCallback (so a gateway restart
|
|
7661
|
+
// between post and tap still resolves correctly). Placed AFTER
|
|
7662
|
+
// onQuotaWallDetected so it doesn't fall inside the source slice that
|
|
7663
|
+
// send-outbound-wiring.test.ts takes between onSendOutbound and
|
|
7664
|
+
// onQuotaWallDetected.
|
|
7665
|
+
onPostSkillProposal(_client: IpcClient, msg: PostSkillProposalMessage) {
|
|
7666
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
7667
|
+
if (self && msg.agentName !== self) {
|
|
7668
|
+
process.stderr.write(
|
|
7669
|
+
`telegram gateway: post_skill_proposal rejected — agent mismatch (${msg.agentName} != ${self})\n`,
|
|
7670
|
+
)
|
|
7671
|
+
return
|
|
7672
|
+
}
|
|
7673
|
+
try {
|
|
7674
|
+
assertAllowedChat(msg.chatId)
|
|
7675
|
+
} catch (err) {
|
|
7676
|
+
process.stderr.write(
|
|
7677
|
+
`telegram gateway: post_skill_proposal rejected — ${(err as Error).message}\n`,
|
|
7678
|
+
)
|
|
7679
|
+
return
|
|
7680
|
+
}
|
|
7681
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
7682
|
+
if (stateDir == null || stateDir.length === 0) {
|
|
7683
|
+
process.stderr.write(`telegram gateway: post_skill_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
|
|
7684
|
+
return
|
|
7685
|
+
}
|
|
7686
|
+
// Dedup against still-live rejection fingerprints — never re-surface a
|
|
7687
|
+
// proposal the operator already dismissed.
|
|
7688
|
+
if (isSkillProposalSuppressed(stateDir, {
|
|
7689
|
+
lesson: msg.lesson,
|
|
7690
|
+
draft: msg.draft,
|
|
7691
|
+
skill_slug: msg.skillSlug,
|
|
7692
|
+
})) {
|
|
7693
|
+
process.stderr.write(
|
|
7694
|
+
`telegram gateway: post_skill_proposal suppressed (rejected before) slug=${msg.skillSlug}\n`,
|
|
7695
|
+
)
|
|
7696
|
+
return
|
|
7697
|
+
}
|
|
7698
|
+
const proposal = enqueueSkillProposal(stateDir, {
|
|
7699
|
+
skill_slug: msg.skillSlug,
|
|
7700
|
+
is_new: msg.isNew,
|
|
7701
|
+
lesson: msg.lesson,
|
|
7702
|
+
draft: msg.draft,
|
|
7703
|
+
evidence: msg.evidence,
|
|
7704
|
+
chat_id: Number(msg.chatId),
|
|
7705
|
+
})
|
|
7706
|
+
const cardText = renderSkillProposalCard({
|
|
7707
|
+
id: proposal.id,
|
|
7708
|
+
skill_slug: proposal.skill_slug,
|
|
7709
|
+
is_new: proposal.is_new,
|
|
7710
|
+
lesson: proposal.lesson,
|
|
7711
|
+
evidence: proposal.evidence,
|
|
7712
|
+
skill_md: proposal.draft['SKILL.md'],
|
|
7713
|
+
})
|
|
7714
|
+
const threadId = msg.threadId
|
|
7715
|
+
void swallowingApiCall(
|
|
7716
|
+
() =>
|
|
7717
|
+
bot.api.sendMessage(msg.chatId, cardText, {
|
|
7718
|
+
parse_mode: 'HTML',
|
|
7719
|
+
reply_markup: skillProposalKeyboard(proposal.id),
|
|
7720
|
+
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
7721
|
+
}),
|
|
7722
|
+
{ chat_id: msg.chatId, verb: 'skill-proposal-card', ...(threadId != null ? { threadId } : {}) },
|
|
7723
|
+
)
|
|
7724
|
+
process.stderr.write(
|
|
7725
|
+
`telegram gateway: post_skill_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
|
|
7726
|
+
`proposal=${proposal.id} slug=${proposal.skill_slug} new=${proposal.is_new}\n`,
|
|
7727
|
+
)
|
|
7728
|
+
},
|
|
7729
|
+
|
|
7583
7730
|
log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
|
|
7584
7731
|
})
|
|
7585
7732
|
|
|
@@ -7891,8 +8038,8 @@ function notifyLinearAuthDead(info: { agent: string; reason: LinearAuthDeadReaso
|
|
|
7891
8038
|
const text = buildLinearAuthDeadMessage(info.agent, info.reason)
|
|
7892
8039
|
await swallowingApiCall(
|
|
7893
8040
|
() =>
|
|
7894
|
-
bot
|
|
7895
|
-
|
|
8041
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
|
|
8042
|
+
bot.api.sendRichMessage(chatId, richMessage(text), {
|
|
7896
8043
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
7897
8044
|
}),
|
|
7898
8045
|
{ chat_id: chatId, verb: 'linearAuthDead' },
|
|
@@ -7997,18 +8144,19 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
7997
8144
|
})()
|
|
7998
8145
|
const rawText = args.text as string | undefined
|
|
7999
8146
|
if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
|
|
8000
|
-
|
|
8147
|
+
// Repair LLM JSON-escape bungles, then promote lone prose paragraph breaks
|
|
8148
|
+
// into GFM hard breaks so the rich path doesn't collapse them (lists/tables/
|
|
8149
|
+
// code are left untouched — see normalizeParagraphBreaks).
|
|
8150
|
+
let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
|
|
8001
8151
|
// Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
|
|
8002
8152
|
// the stderr preview below, the dedup key, the send, and the history
|
|
8003
8153
|
// record. Mutates `text` so every downstream consumer sees the masked
|
|
8004
8154
|
// value, exactly like the voice scrub that follows.
|
|
8005
8155
|
text = redactOutboundText(text, 'reply')
|
|
8006
8156
|
// Voice scrub (#1683): replace em / en dashes with commas / periods.
|
|
8007
|
-
// Runs BEFORE outboundDedup so retries see the scrubbed key, and
|
|
8008
|
-
//
|
|
8009
|
-
//
|
|
8010
|
-
// converter would have already escaped/parked code, and the scrub
|
|
8011
|
-
// would see only the parked placeholders). Kill switch:
|
|
8157
|
+
// Runs BEFORE outboundDedup so retries see the scrubbed key, and on the
|
|
8158
|
+
// raw markdown text (the scrubber does its own code-region parking so
|
|
8159
|
+
// dashes inside fences/inline code are preserved). Kill switch:
|
|
8012
8160
|
// `SWITCHROOM_DISABLE_VOICE_SCRUB=1`.
|
|
8013
8161
|
{
|
|
8014
8162
|
const scrub = scrubVoice(text)
|
|
@@ -8220,24 +8368,19 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8220
8368
|
// Replace the local text with a one-line link. The first line
|
|
8221
8369
|
// is the chosen title (so the user sees the topic at a glance);
|
|
8222
8370
|
// the second line is the URL Telegram will Instant-View.
|
|
8223
|
-
text =
|
|
8371
|
+
text = `**${escapeMarkdown(title)}**\n${url}`
|
|
8224
8372
|
}
|
|
8225
8373
|
// url null → fall through and chunk; the user still gets the
|
|
8226
8374
|
// long reply, just split across messages.
|
|
8227
8375
|
}
|
|
8228
8376
|
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
effectiveText = escapeMarkdownV2(text)
|
|
8237
|
-
} else {
|
|
8238
|
-
parseMode = undefined
|
|
8239
|
-
effectiveText = text
|
|
8240
|
-
}
|
|
8377
|
+
// Single rich-markdown path (#2669). The only fork is `format:'text'`,
|
|
8378
|
+
// a literal/no-markdown send: the body bypasses the rich parser entirely
|
|
8379
|
+
// (plain `sendMessage`, no rich wrapper). Everything else — the default —
|
|
8380
|
+
// ships the raw GFM markdown via `sendRichMessage`. `effectiveText` is the
|
|
8381
|
+
// raw text either way (no HTML/MarkdownV2 rendering happens here anymore).
|
|
8382
|
+
const literalText = format === 'text'
|
|
8383
|
+
const effectiveText: string = text
|
|
8241
8384
|
|
|
8242
8385
|
assertAllowedChat(chat_id)
|
|
8243
8386
|
|
|
@@ -8299,11 +8442,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8299
8442
|
}
|
|
8300
8443
|
}
|
|
8301
8444
|
|
|
8302
|
-
const limit = Math.max(1, Math.min(access.textChunkLimit ??
|
|
8445
|
+
const limit = Math.max(1, Math.min(access.textChunkLimit ?? RICH_MESSAGE_MAX_CHARS, MAX_CHUNK_LIMIT))
|
|
8303
8446
|
const replyMode = access.replyToMode ?? 'first'
|
|
8304
|
-
const chunks =
|
|
8305
|
-
?
|
|
8306
|
-
:
|
|
8447
|
+
const chunks = literalText
|
|
8448
|
+
? chunk(effectiveText, limit, access.chunkMode ?? 'length')
|
|
8449
|
+
: splitMarkdownChunks(effectiveText, limit)
|
|
8307
8450
|
const sentIds: number[] = []
|
|
8308
8451
|
|
|
8309
8452
|
// #271: validate inline_keyboard and namespace any callback_data with
|
|
@@ -8335,7 +8478,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8335
8478
|
await openStream.finalize().catch(() => {})
|
|
8336
8479
|
previewMessageId = openStream.getMessageId()
|
|
8337
8480
|
activeDraftStreams.delete(replySKey)
|
|
8338
|
-
activeDraftParseModes.delete(replySKey)
|
|
8339
8481
|
lastPtyPreviewByChat.delete(replySKey)
|
|
8340
8482
|
}
|
|
8341
8483
|
|
|
@@ -8446,13 +8588,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8446
8588
|
})
|
|
8447
8589
|
if (decision.kind === 'edit-anchor') {
|
|
8448
8590
|
const editParams: {
|
|
8449
|
-
parse_mode?: 'HTML' | 'MarkdownV2'
|
|
8450
8591
|
message_thread_id?: number
|
|
8451
8592
|
link_preview_options?: { is_disabled: boolean }
|
|
8452
8593
|
} = {
|
|
8453
8594
|
link_preview_options: { is_disabled: disableLinkPreview },
|
|
8454
8595
|
}
|
|
8455
|
-
if (parseMode != null) editParams.parse_mode = parseMode
|
|
8456
8596
|
if (threadId != null) editParams.message_thread_id = threadId
|
|
8457
8597
|
try {
|
|
8458
8598
|
await robustApiCall(
|
|
@@ -8460,7 +8600,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8460
8600
|
lockedBot.api.editMessageText(
|
|
8461
8601
|
chat_id,
|
|
8462
8602
|
decision.messageId,
|
|
8463
|
-
decision.mergedText,
|
|
8603
|
+
literalText ? decision.mergedText : richMessage(decision.mergedText),
|
|
8464
8604
|
editParams,
|
|
8465
8605
|
),
|
|
8466
8606
|
{
|
|
@@ -8504,7 +8644,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8504
8644
|
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
8505
8645
|
messageId: decision.messageId,
|
|
8506
8646
|
text: decision.mergedText,
|
|
8507
|
-
|
|
8647
|
+
literalText,
|
|
8508
8648
|
})
|
|
8509
8649
|
if (HISTORY_ENABLED) {
|
|
8510
8650
|
try {
|
|
@@ -8591,7 +8731,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8591
8731
|
},
|
|
8592
8732
|
}
|
|
8593
8733
|
: {}),
|
|
8594
|
-
...(parseMode ? { parse_mode: parseMode } : {}),
|
|
8595
8734
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
8596
8735
|
...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
|
|
8597
8736
|
...(replyMarkup != null && isLastChunk ? { reply_markup: replyMarkup } : {}),
|
|
@@ -8599,14 +8738,17 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8599
8738
|
...(disableNotification ? { disable_notification: true } : {}),
|
|
8600
8739
|
}
|
|
8601
8740
|
|
|
8741
|
+
// The body argument for send/edit: a rich-markdown wrapper on the
|
|
8742
|
+
// default path, or the literal string for `format:'text'` (#2669).
|
|
8743
|
+
const richOrPlain = (s: string) => (literalText ? s : richMessage(s))
|
|
8744
|
+
|
|
8602
8745
|
if (i === 0 && previewMessageId != null) {
|
|
8603
8746
|
const editOpts: Record<string, unknown> = {}
|
|
8604
|
-
if (parseMode) editOpts.parse_mode = parseMode
|
|
8605
8747
|
if (disableLinkPreview) editOpts.link_preview_options = { is_disabled: true }
|
|
8606
8748
|
if (replyMarkup != null && isLastChunk) editOpts.reply_markup = replyMarkup
|
|
8607
8749
|
try {
|
|
8608
8750
|
await robustApiCall(
|
|
8609
|
-
() => lockedBot.api.editMessageText(chat_id, previewMessageId!, chunks[i], editOpts),
|
|
8751
|
+
() => lockedBot.api.editMessageText(chat_id, previewMessageId!, richOrPlain(chunks[i]), editOpts),
|
|
8610
8752
|
{ threadId, chat_id },
|
|
8611
8753
|
)
|
|
8612
8754
|
sentIds.push(previewMessageId!)
|
|
@@ -8625,37 +8767,80 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8625
8767
|
}
|
|
8626
8768
|
}
|
|
8627
8769
|
|
|
8628
|
-
// Last-resort: resend this chunk as plain text (
|
|
8629
|
-
// Keeps thread / reply / markup
|
|
8630
|
-
// sacrificed. Used when Telegram
|
|
8631
|
-
// unformatted answer than a
|
|
8770
|
+
// Last-resort: resend this chunk as plain text (no rich wrapper, so
|
|
8771
|
+
// the markdown parser never runs). Keeps thread / reply / markup
|
|
8772
|
+
// params; only the formatting is sacrificed. Used when Telegram
|
|
8773
|
+
// rejects our markdown — better an unformatted answer than a
|
|
8774
|
+
// vanished one. The raw markdown source is itself readable prose, so
|
|
8775
|
+
// we send it verbatim rather than strip anything.
|
|
8632
8776
|
const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
|
|
8633
|
-
const plainOpts = { ...opts }
|
|
8634
|
-
delete (plainOpts as { parse_mode?: unknown }).parse_mode
|
|
8635
|
-
// A chunk that was pure markup (no text content) strips to ''.
|
|
8636
|
-
// Sending '' is a Telegram 400 "message text is empty" — i.e.
|
|
8637
|
-
// the answer would still vanish, in the exact path that exists
|
|
8638
|
-
// to prevent that. Substitute an honest placeholder so the
|
|
8639
|
-
// user at least sees that a fragment was unrenderable.
|
|
8640
|
-
const stripped = telegramHtmlToPlainText(chunks[i])
|
|
8641
8777
|
const plain =
|
|
8642
|
-
|
|
8643
|
-
?
|
|
8644
|
-
: '⚠️ (a
|
|
8645
|
-
// allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse
|
|
8646
|
-
const sent = await lockedBot.api.sendMessage(chat_id, plain,
|
|
8778
|
+
chunks[i].length > 0
|
|
8779
|
+
? chunks[i]
|
|
8780
|
+
: '⚠️ (a fragment could not be rendered for Telegram)'
|
|
8781
|
+
// allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse policy that just rejected the payload
|
|
8782
|
+
const sent = await lockedBot.api.sendMessage(chat_id, plain, opts as never)
|
|
8647
8783
|
sentIds.push(sent.message_id)
|
|
8648
8784
|
logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
|
|
8649
8785
|
process.stderr.write(
|
|
8650
|
-
`telegram gateway:
|
|
8786
|
+
`telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
|
|
8651
8787
|
)
|
|
8652
8788
|
}
|
|
8653
8789
|
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8790
|
+
// Literal `format:'text'` sends bypass the rich parser entirely
|
|
8791
|
+
// (plain sendMessage, no markdown). The default path ships rich
|
|
8792
|
+
// markdown via sendRichMessage. Both resolve to a Message with a
|
|
8793
|
+
// message_id, which is all the caller reads.
|
|
8794
|
+
const sendChunk = (opts: Record<string, unknown>): Promise<{ message_id: number }> => {
|
|
8795
|
+
if (literalText) {
|
|
8796
|
+
// allow-raw-bot-api: literal format:'text' send; the chunk-loop's own THREAD_NOT_FOUND + parse-reject handling wraps this
|
|
8797
|
+
return lockedBot.api.sendMessage(chat_id, chunks[i], opts as never)
|
|
8798
|
+
}
|
|
8799
|
+
// sendRichMessage does NOT accept link_preview_options (rich messages
|
|
8800
|
+
// control previews via entity detection) — drop it for the rich path.
|
|
8801
|
+
const richOpts = { ...opts }
|
|
8802
|
+
delete (richOpts as { link_preview_options?: unknown }).link_preview_options
|
|
8803
|
+
// allow-raw-bot-api: sendRichMessage is not in the THREAD_NOT_FOUND blast pattern; thread fallback handled in the catch below
|
|
8804
|
+
return lockedBot.api.sendRichMessage(chat_id, richMessage(chunks[i]), richOpts as never)
|
|
8805
|
+
}
|
|
8806
|
+
|
|
8807
|
+
// Length-error recovery: a single pre-computed chunk can still exceed the
|
|
8808
|
+
// wire cap when splitMarkdownChunks hit an indivisible region and emitted
|
|
8809
|
+
// it whole (a giant fenced block, a no-boundary blob). Telegram answers
|
|
8810
|
+
// with RICH_MESSAGE_TEXT_TOO_LONG / MESSAGE_TOO_LONG. Re-split this chunk
|
|
8811
|
+
// at a harder boundary and send each piece, rather than misclassifying it
|
|
8812
|
+
// as a parse-reject (which would resend the same oversized payload as
|
|
8813
|
+
// plain text) or surfacing the raw 400.
|
|
8814
|
+
const sendChunkResplit = async (opts: Record<string, unknown>): Promise<void> => {
|
|
8815
|
+
// Re-split at the same cap; for a truly indivisible block this still
|
|
8816
|
+
// yields one oversized piece, but a hard character-cut on the rendered
|
|
8817
|
+
// markdown at least keeps each delivered piece under the wire cap.
|
|
8818
|
+
const subPieces = splitMarkdownChunks(chunks[i], RICH_MESSAGE_MAX_CHARS)
|
|
8819
|
+
const pieces =
|
|
8820
|
+
subPieces.length > 1
|
|
8821
|
+
? subPieces
|
|
8822
|
+
: hardSliceToCap(chunks[i], RICH_MESSAGE_MAX_CHARS)
|
|
8823
|
+
for (let p = 0; p < pieces.length; p++) {
|
|
8824
|
+
let sent: { message_id: number }
|
|
8825
|
+
if (literalText) {
|
|
8826
|
+
// allow-raw-bot-api: length-error re-split last resort (literal text); wrapping would re-enter the chunk-loop's own classification on an already-classified length failure.
|
|
8827
|
+
sent = await lockedBot.api.sendMessage(chat_id, pieces[p], opts as never)
|
|
8828
|
+
} else {
|
|
8829
|
+
const ro = { ...opts }
|
|
8830
|
+
delete (ro as { link_preview_options?: unknown }).link_preview_options
|
|
8831
|
+
// allow-raw-bot-api: length-error re-split last resort (rich); wrapping would re-enter the chunk-loop's own classification on an already-classified length failure.
|
|
8832
|
+
sent = await lockedBot.api.sendRichMessage(chat_id, richMessage(pieces[p]), ro as never)
|
|
8833
|
+
}
|
|
8834
|
+
sentIds.push(sent.message_id)
|
|
8835
|
+
logOutbound('reply', chat_id, sent.message_id, pieces[p].length, `chunk=${i + 1}/${chunks.length} resplit=${p + 1}/${pieces.length}`)
|
|
8836
|
+
}
|
|
8837
|
+
process.stderr.write(
|
|
8838
|
+
`telegram gateway: rich body too long — re-split chunk ${i + 1}/${chunks.length} into ${pieces.length} piece(s)\n`,
|
|
8658
8839
|
)
|
|
8840
|
+
}
|
|
8841
|
+
|
|
8842
|
+
try {
|
|
8843
|
+
const sent = await robustApiCall(() => sendChunk(sendOpts), { threadId, chat_id })
|
|
8659
8844
|
sentIds.push(sent.message_id)
|
|
8660
8845
|
logOutbound('reply', chat_id, sent.message_id, chunks[i].length, `chunk=${i + 1}/${chunks.length}`)
|
|
8661
8846
|
} catch (err) {
|
|
@@ -8664,14 +8849,17 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8664
8849
|
const retryOpts = { ...sendOpts }
|
|
8665
8850
|
delete (retryOpts as any).message_thread_id
|
|
8666
8851
|
try {
|
|
8667
|
-
|
|
8668
|
-
const sent = await lockedBot.api.sendMessage(chat_id, chunks[i], retryOpts as never)
|
|
8852
|
+
const sent = await sendChunk(retryOpts)
|
|
8669
8853
|
sentIds.push(sent.message_id)
|
|
8670
8854
|
} catch (retryErr) {
|
|
8671
|
-
// Thread dropped,
|
|
8672
|
-
|
|
8855
|
+
// Thread dropped, AND another failure: length → re-split,
|
|
8856
|
+
// parse-reject → plain text, else propagate.
|
|
8857
|
+
if (isMessageTooLongError(retryErr)) await sendChunkResplit(retryOpts)
|
|
8858
|
+
else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
|
|
8673
8859
|
else throw retryErr
|
|
8674
8860
|
}
|
|
8861
|
+
} else if (isMessageTooLongError(err)) {
|
|
8862
|
+
await sendChunkResplit(sendOpts)
|
|
8675
8863
|
} else if (isHtmlParseRejectError(err)) {
|
|
8676
8864
|
await sendChunkPlainText(sendOpts)
|
|
8677
8865
|
} else {
|
|
@@ -8716,7 +8904,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8716
8904
|
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
8717
8905
|
messageId: anchorMsgId,
|
|
8718
8906
|
text: chunks[chunks.length - 1],
|
|
8719
|
-
|
|
8907
|
+
literalText,
|
|
8720
8908
|
})
|
|
8721
8909
|
}
|
|
8722
8910
|
}
|
|
@@ -9161,7 +9349,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
9161
9349
|
...(streamReplyMarkup != null ? { reply_markup: streamReplyMarkup } : {}),
|
|
9162
9350
|
...(args.disable_notification === true ? { disable_notification: true } : {}),
|
|
9163
9351
|
},
|
|
9164
|
-
{ activeDraftStreams,
|
|
9352
|
+
{ activeDraftStreams, suppressPtyPreview },
|
|
9165
9353
|
{
|
|
9166
9354
|
// grammy's Bot<Context, Api<RawApi>> has a wider api shape than the
|
|
9167
9355
|
// local StreamBotApi interface, but is structurally compatible at
|
|
@@ -9169,9 +9357,8 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
9169
9357
|
// bypass the structural-typing strictness.
|
|
9170
9358
|
bot: lockedBot as unknown as { api: import('../stream-controller.js').StreamBotApi },
|
|
9171
9359
|
retry: robustApiCall,
|
|
9172
|
-
markdownToHtml,
|
|
9173
|
-
escapeMarkdownV2,
|
|
9174
9360
|
repairEscapedWhitespace,
|
|
9361
|
+
normalizeParagraphBreaks,
|
|
9175
9362
|
assertAllowedChat,
|
|
9176
9363
|
resolveThreadId,
|
|
9177
9364
|
disableLinkPreview: access.disableLinkPreview !== false,
|
|
@@ -9257,16 +9444,11 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
9257
9444
|
// async dispatch, the framework edits THIS message in place at
|
|
9258
9445
|
// intervals.
|
|
9259
9446
|
//
|
|
9260
|
-
// #
|
|
9261
|
-
// the cross-turn edit tick
|
|
9262
|
-
//
|
|
9263
|
-
// "still working (Nm)" tick edits HTML content as plain text and
|
|
9264
|
-
// <b>/<code>/<a> render as literal tags.
|
|
9447
|
+
// #2669 — capture whether the stream-reply-handler sent literally
|
|
9448
|
+
// (format:'text') so the cross-turn edit tick re-edits with the same
|
|
9449
|
+
// shape. The default rich-markdown path re-edits via the rich path.
|
|
9265
9450
|
const streamFormat = (args.format as string | undefined) ?? (access.parseMode ?? 'html')
|
|
9266
|
-
const
|
|
9267
|
-
streamFormat === 'html' ? 'HTML'
|
|
9268
|
-
: streamFormat === 'markdownv2' ? 'MarkdownV2'
|
|
9269
|
-
: undefined
|
|
9451
|
+
const streamLiteralText = streamFormat === 'text'
|
|
9270
9452
|
// #1760 primary fix — clear any stale prior-turn ticker before
|
|
9271
9453
|
// re-anchoring on stream_reply done. See the matching comment at
|
|
9272
9454
|
// the executeReply finalize site.
|
|
@@ -9274,7 +9456,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
9274
9456
|
pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
|
|
9275
9457
|
messageId: result.messageId,
|
|
9276
9458
|
text: args.text as string,
|
|
9277
|
-
|
|
9459
|
+
literalText: streamLiteralText,
|
|
9278
9460
|
})
|
|
9279
9461
|
}
|
|
9280
9462
|
// #1664 — mark the turn's final answer as delivered. For stream_reply a
|
|
@@ -9434,19 +9616,22 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
|
|
|
9434
9616
|
// Otherwise fall through to message-send below.
|
|
9435
9617
|
}
|
|
9436
9618
|
|
|
9437
|
-
// Send plain message (no quote-reply)
|
|
9619
|
+
// Send plain message (no quote-reply). Single rich-markdown path (#2669):
|
|
9620
|
+
// `parseMode:'text'` config opts into a literal plain send; everything
|
|
9621
|
+
// else ships raw GFM markdown via sendRichMessage.
|
|
9438
9622
|
const access = loadAccess()
|
|
9439
|
-
const
|
|
9440
|
-
const parseMode = configParseMode === 'html' ? 'HTML' : undefined
|
|
9441
|
-
const effectiveText = configParseMode === 'html' ? markdownToHtml(text) : text
|
|
9623
|
+
const literalText = (access.parseMode ?? 'html') === 'text'
|
|
9442
9624
|
|
|
9443
9625
|
const sendOpts = {
|
|
9444
|
-
...(parseMode ? { parse_mode: parseMode } : {}),
|
|
9445
9626
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
9446
9627
|
}
|
|
9447
9628
|
|
|
9448
9629
|
const sent = await robustApiCall(
|
|
9449
|
-
()
|
|
9630
|
+
(): Promise<{ message_id: number }> =>
|
|
9631
|
+
literalText
|
|
9632
|
+
// allow-raw-bot-api: literal progress-update send routed through robustApiCall
|
|
9633
|
+
? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
|
|
9634
|
+
: lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
|
|
9450
9635
|
{ verb: 'sendMessage', chat_id, threadId },
|
|
9451
9636
|
)
|
|
9452
9637
|
|
|
@@ -9537,7 +9722,6 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
|
|
|
9537
9722
|
// alive (button tap turns into "✅ <choice>" inline) rather than a
|
|
9538
9723
|
// graveyard of unanswered questions in the chat history.
|
|
9539
9724
|
const sendOpts: Record<string, unknown> = {
|
|
9540
|
-
parse_mode: 'HTML',
|
|
9541
9725
|
reply_markup: keyboard,
|
|
9542
9726
|
}
|
|
9543
9727
|
if (threadId != null) sendOpts.message_thread_id = threadId
|
|
@@ -9553,7 +9737,7 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
|
|
|
9553
9737
|
const opts = { ...sendOpts }
|
|
9554
9738
|
if (tid == null) delete opts.message_thread_id
|
|
9555
9739
|
else opts.message_thread_id = tid
|
|
9556
|
-
return lockedBot.api.
|
|
9740
|
+
return lockedBot.api.sendRichMessage(args.chatId, richMessage(args.question), opts)
|
|
9557
9741
|
},
|
|
9558
9742
|
{ threadId, chat_id: args.chatId, verb: 'ask_user.sendMessage' },
|
|
9559
9743
|
)
|
|
@@ -9581,8 +9765,7 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
|
|
|
9581
9765
|
lockedBot.api.editMessageText(
|
|
9582
9766
|
args.chatId,
|
|
9583
9767
|
entry.messageId!,
|
|
9584
|
-
`${args.question}\n\
|
|
9585
|
-
{ parse_mode: 'HTML' },
|
|
9768
|
+
richMessage(`${args.question}\n\n_⏱ no response within ${Math.round(args.timeoutMs / 1000)}s_`),
|
|
9586
9769
|
),
|
|
9587
9770
|
{
|
|
9588
9771
|
chat_id: args.chatId,
|
|
@@ -9816,13 +9999,13 @@ function buildVaultRequestSaveKeyboard(stageId: string): { inline_keyboard: Arra
|
|
|
9816
9999
|
|
|
9817
10000
|
function renderVaultRequestSaveCard(req: PendingVaultRequestSave, agentSlug: string): string {
|
|
9818
10001
|
const lines: string[] = []
|
|
9819
|
-
lines.push(`🔐
|
|
9820
|
-
lines.push(`key:
|
|
10002
|
+
lines.push(`🔐 **${escapeHtmlForTg(agentSlug)}** wants to save a secret`)
|
|
10003
|
+
lines.push(`key: \`${req.key}\``)
|
|
9821
10004
|
if (req.why && req.why.length > 0) {
|
|
9822
|
-
lines.push(`why:
|
|
10005
|
+
lines.push(`why: _${escapeHtmlForTg(req.why)}_`)
|
|
9823
10006
|
}
|
|
9824
10007
|
lines.push('')
|
|
9825
|
-
lines.push(
|
|
10008
|
+
lines.push(`_Tap Save to write to the host vault, Rename to change the key name, or Discard to drop it. The value is held in this chat's gateway memory until you decide._`)
|
|
9826
10009
|
return lines.join('\n')
|
|
9827
10010
|
}
|
|
9828
10011
|
|
|
@@ -9885,8 +10068,7 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
|
|
|
9885
10068
|
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
9886
10069
|
robustApiCall,
|
|
9887
10070
|
(tid) =>
|
|
9888
|
-
lockedBot.api.
|
|
9889
|
-
parse_mode: 'HTML',
|
|
10071
|
+
lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
|
|
9890
10072
|
reply_markup: buildVaultRequestSaveKeyboard(stageId),
|
|
9891
10073
|
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
9892
10074
|
}),
|
|
@@ -9959,13 +10141,13 @@ function buildSecretRequestKeyboard(stageId: string): { inline_keyboard: Array<A
|
|
|
9959
10141
|
|
|
9960
10142
|
function renderSecretRequestCard(req: PendingSecretRequest): string {
|
|
9961
10143
|
const lines: string[] = [
|
|
9962
|
-
`🔒
|
|
9963
|
-
|
|
10144
|
+
`🔒 **${escapeHtmlForTg(req.agent)}** needs a secret:`,
|
|
10145
|
+
`\`${req.key}\``,
|
|
9964
10146
|
]
|
|
9965
|
-
if (req.reason) lines.push(
|
|
10147
|
+
if (req.reason) lines.push(`_${escapeHtmlForTg(req.reason)}_`)
|
|
9966
10148
|
lines.push(
|
|
9967
10149
|
'',
|
|
9968
|
-
'Tap
|
|
10150
|
+
'Tap **Provide securely**, then send the value as your next message. I’ll delete it instantly and store it in the vault — it is never shown in chat or to the agent.',
|
|
9969
10151
|
)
|
|
9970
10152
|
return lines.join('\n')
|
|
9971
10153
|
}
|
|
@@ -10005,8 +10187,7 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
|
|
|
10005
10187
|
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
10006
10188
|
robustApiCall,
|
|
10007
10189
|
(tid) =>
|
|
10008
|
-
lockedBot.api.
|
|
10009
|
-
parse_mode: 'HTML',
|
|
10190
|
+
lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
|
|
10010
10191
|
reply_markup: buildSecretRequestKeyboard(stageId),
|
|
10011
10192
|
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
10012
10193
|
}),
|
|
@@ -10069,8 +10250,8 @@ async function captureProvidedSecret(
|
|
|
10069
10250
|
if (!write.ok) {
|
|
10070
10251
|
const parsed = parseVaultCliError(write.output)
|
|
10071
10252
|
const rendered = renderVaultCliError(parsed, { verb: 'save', key: armed.key })
|
|
10072
|
-
const body = rendered.suppressRaw ? rendered.html : `⚠️ vault write failed:\n
|
|
10073
|
-
await switchroomReply(ctx, `${body}\n\
|
|
10253
|
+
const body = rendered.suppressRaw ? rendered.html : `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
|
|
10254
|
+
await switchroomReply(ctx, `${body}\n\n_The secret was NOT saved. The agent can re-request with \`request_secret\`._`, { html: true })
|
|
10074
10255
|
// Wake the agent too (review #2047 finding #3): it ended its turn
|
|
10075
10256
|
// waiting for a synthetic inbound; without this it stalls silently.
|
|
10076
10257
|
const fts = Date.now()
|
|
@@ -10097,7 +10278,7 @@ async function captureProvidedSecret(
|
|
|
10097
10278
|
|
|
10098
10279
|
await switchroomReply(
|
|
10099
10280
|
ctx,
|
|
10100
|
-
`✅ saved as
|
|
10281
|
+
`✅ saved as \`vault:${escapeHtmlForTg(armed.key)}\` (masked: \`${escapeHtmlForTg(maskToken(value))}\`). The agent can now reference it.`,
|
|
10101
10282
|
{ html: true },
|
|
10102
10283
|
)
|
|
10103
10284
|
|
|
@@ -10166,8 +10347,8 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
|
|
|
10166
10347
|
.editMessageText(
|
|
10167
10348
|
pending.chat_id,
|
|
10168
10349
|
pending.card_message_id,
|
|
10169
|
-
`🔐 Send the value for
|
|
10170
|
-
{
|
|
10350
|
+
richMessage(`🔐 Send the value for \`${pending.key}\` as your next message — a single message, exactly as-is (don't add other text). I’ll delete it instantly and store it in the vault.`),
|
|
10351
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
10171
10352
|
)
|
|
10172
10353
|
.catch(() => {})
|
|
10173
10354
|
}
|
|
@@ -10180,8 +10361,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
|
|
|
10180
10361
|
await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
|
|
10181
10362
|
if (pending.card_message_id != null) {
|
|
10182
10363
|
await ctx.api
|
|
10183
|
-
.editMessageText(pending.chat_id, pending.card_message_id, `🚫 Declined —
|
|
10184
|
-
parse_mode: 'HTML',
|
|
10364
|
+
.editMessageText(pending.chat_id, pending.card_message_id, richMessage(`🚫 Declined — \`${pending.key}\` not provided.`), {
|
|
10185
10365
|
reply_markup: { inline_keyboard: [] },
|
|
10186
10366
|
})
|
|
10187
10367
|
.catch(() => {})
|
|
@@ -10233,9 +10413,9 @@ function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
|
|
|
10233
10413
|
const scopeLabel = req.scope === 'write' ? 'write' : 'read'
|
|
10234
10414
|
const days = Math.round(req.ttl_seconds / 86400)
|
|
10235
10415
|
const durationLabel = days >= 1 ? `${days}d` : `${Math.round(req.ttl_seconds / 3600)}h`
|
|
10236
|
-
lines.push(`🔐
|
|
10237
|
-
lines.push(`key:
|
|
10238
|
-
lines.push(`scope:
|
|
10416
|
+
lines.push(`🔐 **${escapeHtmlForTg(req.agent)}** wants vault access`)
|
|
10417
|
+
lines.push(`key: \`${req.key}\``)
|
|
10418
|
+
lines.push(`scope: \`${scopeLabel}\` · duration: \`${durationLabel}\``)
|
|
10239
10419
|
// #1790 — always render the why-line, even when the agent omitted
|
|
10240
10420
|
// `reason`. Rendering "not provided" makes a missing rationale
|
|
10241
10421
|
// visibly an agent-side failure (the tool description nudges the
|
|
@@ -10244,12 +10424,12 @@ function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
|
|
|
10244
10424
|
// template choice, which the operator couldn't tell apart from a
|
|
10245
10425
|
// legitimate "no reason needed" case.
|
|
10246
10426
|
if (req.reason && req.reason.length > 0) {
|
|
10247
|
-
lines.push(`why:
|
|
10427
|
+
lines.push(`why: _${escapeHtmlForTg(req.reason)}_`)
|
|
10248
10428
|
} else {
|
|
10249
|
-
lines.push(`why:
|
|
10429
|
+
lines.push(`why: _not provided_`)
|
|
10250
10430
|
}
|
|
10251
10431
|
lines.push('')
|
|
10252
|
-
lines.push(
|
|
10432
|
+
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._`)
|
|
10253
10433
|
return lines.join('\n')
|
|
10254
10434
|
}
|
|
10255
10435
|
|
|
@@ -10355,8 +10535,7 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
10355
10535
|
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
10356
10536
|
robustApiCall,
|
|
10357
10537
|
(tid) =>
|
|
10358
|
-
lockedBot.api.
|
|
10359
|
-
parse_mode: 'HTML',
|
|
10538
|
+
lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
|
|
10360
10539
|
reply_markup: buildVaultRequestAccessKeyboard(stageId),
|
|
10361
10540
|
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
10362
10541
|
}),
|
|
@@ -10425,9 +10604,15 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
|
|
|
10425
10604
|
if (args.text == null || args.text === '') throw new Error('edit_message: text is required and cannot be empty')
|
|
10426
10605
|
assertAllowedChat(String(args.chat_id ?? ''))
|
|
10427
10606
|
const editAccess = loadAccess()
|
|
10428
|
-
const
|
|
10429
|
-
|
|
10607
|
+
const editFormat = (args.format as string | undefined) ?? (editAccess.parseMode ?? 'html')
|
|
10608
|
+
// Single rich-markdown path (#2669): `format:'text'` edits as a literal
|
|
10609
|
+
// plain string; everything else edits via the rich-markdown path.
|
|
10610
|
+
const editLiteralText = editFormat === 'text'
|
|
10611
|
+
// Match the reply path: repair JSON-escape bungles, then promote lone prose
|
|
10612
|
+
// paragraph breaks for the rich path. A literal-text edit (`format:'text'`)
|
|
10613
|
+
// skips paragraph normalization — it must edit byte-for-byte as given.
|
|
10430
10614
|
let editRawText = repairEscapedWhitespace(args.text as string)
|
|
10615
|
+
if (!editLiteralText) editRawText = normalizeParagraphBreaks(editRawText)
|
|
10431
10616
|
// Outbound secret scrub (#2044): an edit must not re-introduce a raw
|
|
10432
10617
|
// secret into a live bubble or the history row. Mask before scrub/send.
|
|
10433
10618
|
editRawText = redactOutboundText(editRawText, 'edit_message')
|
|
@@ -10447,22 +10632,10 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
|
|
|
10447
10632
|
})
|
|
10448
10633
|
}
|
|
10449
10634
|
}
|
|
10450
|
-
let editParseMode: 'HTML' | 'MarkdownV2' | undefined
|
|
10451
|
-
let editText: string
|
|
10452
|
-
if (editFormat === 'html') {
|
|
10453
|
-
editParseMode = 'HTML'
|
|
10454
|
-
editText = markdownToHtml(editRawText)
|
|
10455
|
-
} else if (editFormat === 'markdownv2') {
|
|
10456
|
-
editParseMode = 'MarkdownV2'
|
|
10457
|
-
editText = escapeMarkdownV2(editRawText)
|
|
10458
|
-
} else {
|
|
10459
|
-
editParseMode = undefined
|
|
10460
|
-
editText = editRawText
|
|
10461
|
-
}
|
|
10462
10635
|
const edited = await robustApiCall(
|
|
10463
10636
|
() => lockedBot.api.editMessageText(
|
|
10464
|
-
String(args.chat_id ?? ''), Number(args.message_id),
|
|
10465
|
-
|
|
10637
|
+
String(args.chat_id ?? ''), Number(args.message_id),
|
|
10638
|
+
editLiteralText ? editRawText : richMessage(editRawText),
|
|
10466
10639
|
),
|
|
10467
10640
|
)
|
|
10468
10641
|
const id = typeof edited === 'object' && edited ? (edited as any).message_id : args.message_id
|
|
@@ -10696,7 +10869,6 @@ function closeActivityLane(chatId: string, threadId: number | undefined): void {
|
|
|
10696
10869
|
const stream = activeDraftStreams.get(key)
|
|
10697
10870
|
if (stream == null) return
|
|
10698
10871
|
activeDraftStreams.delete(key)
|
|
10699
|
-
activeDraftParseModes.delete(key)
|
|
10700
10872
|
void stream.finalize().catch(() => {})
|
|
10701
10873
|
}
|
|
10702
10874
|
|
|
@@ -10708,7 +10880,6 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
|
|
|
10708
10880
|
for (const [key, stream] of activeDraftStreams) {
|
|
10709
10881
|
if (key.startsWith(prefix)) {
|
|
10710
10882
|
activeDraftStreams.delete(key)
|
|
10711
|
-
activeDraftParseModes.delete(key)
|
|
10712
10883
|
void stream.finalize().catch(() => {})
|
|
10713
10884
|
}
|
|
10714
10885
|
}
|
|
@@ -10916,7 +11087,7 @@ async function drainActivitySummary(
|
|
|
10916
11087
|
break
|
|
10917
11088
|
}
|
|
10918
11089
|
// `renderActivityFeed` already emitted ready Telegram HTML with per-line
|
|
10919
|
-
// markup (
|
|
11090
|
+
// markup (**→ current** / _✓ done_) and escaped each label's
|
|
10920
11091
|
// <,>,& itself (#1942 class) — send verbatim, do NOT re-escape or
|
|
10921
11092
|
// re-wrap (double-escaping would surface literal tags).
|
|
10922
11093
|
const html = target
|
|
@@ -10932,9 +11103,9 @@ async function drainActivitySummary(
|
|
|
10932
11103
|
try {
|
|
10933
11104
|
if (turn.activityMessageId == null) {
|
|
10934
11105
|
const sent = await robustApiCall(
|
|
10935
|
-
|
|
11106
|
+
// allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
|
|
11107
|
+
() => bot.api.sendRichMessage(chat, richMessage(html), {
|
|
10936
11108
|
...(thread != null ? { message_thread_id: thread } : {}),
|
|
10937
|
-
parse_mode: 'HTML',
|
|
10938
11109
|
disable_notification: true,
|
|
10939
11110
|
...replyAnchor,
|
|
10940
11111
|
}),
|
|
@@ -10945,7 +11116,7 @@ async function drainActivitySummary(
|
|
|
10945
11116
|
} else {
|
|
10946
11117
|
const id = turn.activityMessageId
|
|
10947
11118
|
await robustApiCall(
|
|
10948
|
-
() => bot.api.editMessageText(chat, id, html, {
|
|
11119
|
+
() => bot.api.editMessageText(chat, id, richMessage(html), {}),
|
|
10949
11120
|
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
|
|
10950
11121
|
)
|
|
10951
11122
|
}
|
|
@@ -11257,7 +11428,7 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
11257
11428
|
if (finalHtml == null) return
|
|
11258
11429
|
try {
|
|
11259
11430
|
await robustApiCall(
|
|
11260
|
-
() => bot.api.editMessageText(chat, id, finalHtml, {
|
|
11431
|
+
() => bot.api.editMessageText(chat, id, richMessage(finalHtml), {}),
|
|
11261
11432
|
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
|
|
11262
11433
|
)
|
|
11263
11434
|
} catch (err) {
|
|
@@ -11781,15 +11952,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
11781
11952
|
// (no flash). The draft transport is permanently retired — both modes
|
|
11782
11953
|
// use sendMessage + editMessageText for any message that does open.
|
|
11783
11954
|
minInitialChars: ANSWER_LANE.minInitialChars,
|
|
11784
|
-
//
|
|
11785
|
-
//
|
|
11786
|
-
//
|
|
11787
|
-
//
|
|
11788
|
-
// shipped raw text — `**bold**` arrived as literal asterisks and
|
|
11789
|
-
// agent narration read unformatted (the answer-stream-raw-markdown
|
|
11790
|
-
// bug). Injected as a dependency (same pattern as the PTY partial
|
|
11791
|
-
// handler's `renderText`) so answer-stream.ts stays format-free.
|
|
11792
|
-
renderText: (text: string) => sanitizeTelegramHtml(markdownToHtml(text)),
|
|
11955
|
+
// #2669: the answer-stream ships the RAW transcript markdown; the
|
|
11956
|
+
// sendMessage/editMessageText wrappers below send it via the
|
|
11957
|
+
// rich-message path (`sendRichMessage` / `editMessageText({ markdown })`),
|
|
11958
|
+
// matching every other outbound lane. No render step needed.
|
|
11793
11959
|
// #1075: route through robustApiCall so flood-wait,
|
|
11794
11960
|
// benign-400, and THREAD_NOT_FOUND are handled uniformly
|
|
11795
11961
|
// instead of crashing the answer-stream loop on a deleted
|
|
@@ -11825,13 +11991,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
11825
11991
|
const silent = params?.purpose !== 'materialize'
|
|
11826
11992
|
const msg = await robustApiCall(
|
|
11827
11993
|
() =>
|
|
11828
|
-
bot
|
|
11829
|
-
|
|
11994
|
+
// allow-raw-bot-api: sendRichMessage is not in the THREAD_NOT_FOUND blast pattern; answer-stream tolerates failures via its own try/catch
|
|
11995
|
+
// sendRichMessage doesn't accept link_preview_options — omit it.
|
|
11996
|
+
bot.api.sendRichMessage(chatId, richMessage(text), {
|
|
11830
11997
|
disable_notification: silent,
|
|
11831
11998
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
11832
|
-
...(params?.link_preview_options != null
|
|
11833
|
-
? { link_preview_options: params.link_preview_options }
|
|
11834
|
-
: {}),
|
|
11835
11999
|
...(params?.reply_parameters != null
|
|
11836
12000
|
? { reply_parameters: params.reply_parameters }
|
|
11837
12001
|
: {}),
|
|
@@ -11848,8 +12012,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
11848
12012
|
const tid = params?.message_thread_id
|
|
11849
12013
|
return robustApiCall(
|
|
11850
12014
|
() =>
|
|
11851
|
-
bot.api.editMessageText(chatId, messageId, text, {
|
|
11852
|
-
parse_mode: params?.parse_mode,
|
|
12015
|
+
bot.api.editMessageText(chatId, messageId, richMessage(text), {
|
|
11853
12016
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
11854
12017
|
...(params?.link_preview_options != null
|
|
11855
12018
|
? { link_preview_options: params.link_preview_options }
|
|
@@ -11945,16 +12108,16 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
11945
12108
|
lastContextExhaustionWarningAt = now
|
|
11946
12109
|
process.stderr.write(`telegram gateway: context exhaustion detected — notifying user\n`)
|
|
11947
12110
|
const warnOpts = {
|
|
11948
|
-
parse_mode: 'HTML' as const,
|
|
11949
12111
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
11950
12112
|
}
|
|
11951
12113
|
// #1075: thread-id-bearing, fire-and-forget — swallow on
|
|
11952
12114
|
// THREAD_NOT_FOUND so a deleted topic doesn't crash the gateway.
|
|
11953
12115
|
void swallowingApiCall(
|
|
11954
12116
|
() =>
|
|
11955
|
-
bot
|
|
12117
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
|
|
12118
|
+
bot.api.sendRichMessage(
|
|
11956
12119
|
chatId,
|
|
11957
|
-
'⚠️
|
|
12120
|
+
richMessage('⚠️ **Context window full** — send \`/restart\` to start a fresh session.'),
|
|
11958
12121
|
warnOpts,
|
|
11959
12122
|
),
|
|
11960
12123
|
{
|
|
@@ -12313,7 +12476,6 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12313
12476
|
for (const [key] of activeDraftStreams) {
|
|
12314
12477
|
if (key.startsWith(suppressPrefix)) {
|
|
12315
12478
|
activeDraftStreams.delete(key)
|
|
12316
|
-
activeDraftParseModes.delete(key)
|
|
12317
12479
|
}
|
|
12318
12480
|
}
|
|
12319
12481
|
// Unpin without editing the message so no orphaned card lingers.
|
|
@@ -12414,9 +12576,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12414
12576
|
// that fires when the model emits raw transcript text WITHOUT
|
|
12415
12577
|
// calling reply / stream_reply. That captured text bypasses
|
|
12416
12578
|
// PR #1683's executeReply scrub site entirely and is delivered
|
|
12417
|
-
// via
|
|
12418
|
-
//
|
|
12419
|
-
//
|
|
12579
|
+
// via the rich-message path directly. Scrub the capturedText on the
|
|
12580
|
+
// raw markdown so em-dashes never reach the wire. Kill switch:
|
|
12581
|
+
// SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
12420
12582
|
{
|
|
12421
12583
|
const scrub = scrubVoice(capturedText)
|
|
12422
12584
|
if (scrub.replaced > 0) {
|
|
@@ -12517,13 +12679,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12517
12679
|
`(chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? 'none'})\n`,
|
|
12518
12680
|
)
|
|
12519
12681
|
const sendOpts = {
|
|
12520
|
-
parse_mode: 'HTML' as const,
|
|
12521
12682
|
...(backstopThreadId != null ? { message_thread_id: backstopThreadId } : {}),
|
|
12522
12683
|
link_preview_options: { is_disabled: true },
|
|
12523
12684
|
}
|
|
12524
|
-
const
|
|
12525
|
-
const
|
|
12526
|
-
const htmlChunks = splitHtmlChunks(renderedText, limit)
|
|
12685
|
+
const limit = RICH_MESSAGE_MAX_CHARS
|
|
12686
|
+
const htmlChunks = splitMarkdownChunks(capturedText, limit)
|
|
12527
12687
|
const sentIds: number[] = []
|
|
12528
12688
|
try {
|
|
12529
12689
|
// #654 deterministic double-message fix. If the progress
|
|
@@ -12552,7 +12712,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12552
12712
|
bot.api.editMessageText(
|
|
12553
12713
|
backstopChatId,
|
|
12554
12714
|
backstopCardMessageId,
|
|
12555
|
-
htmlChunks[0],
|
|
12715
|
+
richMessage(htmlChunks[0]),
|
|
12556
12716
|
sendOpts,
|
|
12557
12717
|
),
|
|
12558
12718
|
{
|
|
@@ -12578,11 +12738,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12578
12738
|
robustApiCall,
|
|
12579
12739
|
(tid) => {
|
|
12580
12740
|
const opts = {
|
|
12581
|
-
parse_mode: 'HTML' as const,
|
|
12582
12741
|
link_preview_options: { is_disabled: true },
|
|
12583
12742
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
12584
12743
|
}
|
|
12585
|
-
return bot.api.
|
|
12744
|
+
return bot.api.sendRichMessage(backstopChatId, richMessage(c), opts)
|
|
12586
12745
|
},
|
|
12587
12746
|
{
|
|
12588
12747
|
threadId: liveThreadId,
|
|
@@ -12832,7 +12991,6 @@ function handlePtyPartial(text: string): void {
|
|
|
12832
12991
|
currentSessionThreadId: turn?.sessionThreadId,
|
|
12833
12992
|
pendingPtyPartial: pendingPtyPartial != null ? { text: pendingPtyPartial } : null,
|
|
12834
12993
|
activeDraftStreams,
|
|
12835
|
-
activeDraftParseModes,
|
|
12836
12994
|
suppressPtyPreview,
|
|
12837
12995
|
lastPtyPreviewByChat,
|
|
12838
12996
|
}
|
|
@@ -12841,7 +12999,6 @@ function handlePtyPartial(text: string): void {
|
|
|
12841
12999
|
// interface; runtime-compatible. Cast through never.
|
|
12842
13000
|
bot: bot as never,
|
|
12843
13001
|
retry: robustApiCall,
|
|
12844
|
-
renderText: markdownToHtml,
|
|
12845
13002
|
logEvent: logStreamingEvent,
|
|
12846
13003
|
onStreamSend: (chatId, messageId, charCount) => {
|
|
12847
13004
|
logOutbound('pty_preview', chatId, messageId, charCount, 'initial_send')
|
|
@@ -12876,13 +13033,11 @@ function handlePtyActivity(text: string): void {
|
|
|
12876
13033
|
format: 'text',
|
|
12877
13034
|
lane: 'activity',
|
|
12878
13035
|
},
|
|
12879
|
-
{ activeDraftStreams
|
|
13036
|
+
{ activeDraftStreams },
|
|
12880
13037
|
{
|
|
12881
13038
|
// See StreamBotApi cast comment above (line ~2522 area).
|
|
12882
13039
|
bot: bot as never,
|
|
12883
13040
|
retry: robustApiCall,
|
|
12884
|
-
markdownToHtml,
|
|
12885
|
-
escapeMarkdownV2,
|
|
12886
13041
|
repairEscapedWhitespace,
|
|
12887
13042
|
assertAllowedChat,
|
|
12888
13043
|
resolveThreadId,
|
|
@@ -13573,9 +13728,9 @@ async function handleInbound(
|
|
|
13573
13728
|
cleanAuthAddScratchDir(pendingAdd.scratchDir)
|
|
13574
13729
|
await switchroomReply(
|
|
13575
13730
|
ctx,
|
|
13576
|
-
`✓ Account
|
|
13731
|
+
`✓ Account \`${pendingAdd.label}\` added.\n` +
|
|
13577
13732
|
`The fleet's active account hasn't changed. Send ` +
|
|
13578
|
-
|
|
13733
|
+
`\`/auth use ${escapeHtmlForTg(pendingAdd.label)}\` to switch to it.`,
|
|
13579
13734
|
{ html: true },
|
|
13580
13735
|
)
|
|
13581
13736
|
} catch (brokerErr) {
|
|
@@ -13585,7 +13740,7 @@ async function handleInbound(
|
|
|
13585
13740
|
cleanAuthAddScratchDir(pendingAdd.scratchDir)
|
|
13586
13741
|
await switchroomReply(
|
|
13587
13742
|
ctx,
|
|
13588
|
-
|
|
13743
|
+
`**/auth add failed at broker:** ${escapeHtmlForTg((brokerErr as Error)?.message ?? String(brokerErr))}`,
|
|
13589
13744
|
{ html: true },
|
|
13590
13745
|
)
|
|
13591
13746
|
}
|
|
@@ -13594,7 +13749,7 @@ async function handleInbound(
|
|
|
13594
13749
|
// failure paths (timeout, child exit, stdin broken).
|
|
13595
13750
|
await switchroomReply(
|
|
13596
13751
|
ctx,
|
|
13597
|
-
|
|
13752
|
+
`**/auth add code failed:** ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
|
|
13598
13753
|
{ html: true },
|
|
13599
13754
|
)
|
|
13600
13755
|
}
|
|
@@ -13616,7 +13771,7 @@ async function handleInbound(
|
|
|
13616
13771
|
pendingReauthFlows.delete(interceptKey)
|
|
13617
13772
|
const { result, errorText } = execAuthCode(pendingReauth.agent, text.trim())
|
|
13618
13773
|
if (errorText) {
|
|
13619
|
-
await switchroomReply(ctx,
|
|
13774
|
+
await switchroomReply(ctx, `**auth code failed:**\n${preBlock(formatSwitchroomOutput(errorText))}`, { html: true })
|
|
13620
13775
|
} else if (result) {
|
|
13621
13776
|
const outcomeMsg = renderAuthCodeOutcome(result.outcome)
|
|
13622
13777
|
if (outcomeMsg) {
|
|
@@ -13668,7 +13823,7 @@ async function handleInbound(
|
|
|
13668
13823
|
if (result.ok) {
|
|
13669
13824
|
await switchroomReply(ctx, '🔓 Vault broker unlocked.', { html: true })
|
|
13670
13825
|
} else {
|
|
13671
|
-
await switchroomReply(ctx,
|
|
13826
|
+
await switchroomReply(ctx, `**vault unlock failed:** ${escapeHtmlForTg(result.msg ?? 'unknown error')}`, { html: true })
|
|
13672
13827
|
}
|
|
13673
13828
|
} else if (pendingVault.kind === 'passphrase-for-deferred') {
|
|
13674
13829
|
// Issue #44: passphrase entered after tapping "🔓 Unlock vault &
|
|
@@ -13717,8 +13872,8 @@ async function handleInbound(
|
|
|
13717
13872
|
.editMessageText(
|
|
13718
13873
|
item.cardChatId,
|
|
13719
13874
|
item.cardMessageId,
|
|
13720
|
-
`⌛
|
|
13721
|
-
{
|
|
13875
|
+
richMessage(`⌛ _Vault unlocked, but this access-request card expired or was denied before you replied. Ask the agent to re-issue if still needed._`),
|
|
13876
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
13722
13877
|
)
|
|
13723
13878
|
.catch(() => {})
|
|
13724
13879
|
continue
|
|
@@ -13732,7 +13887,7 @@ async function handleInbound(
|
|
|
13732
13887
|
if (ttlSeconds === null) {
|
|
13733
13888
|
// Re-set state so user can try again
|
|
13734
13889
|
pendingVaultOps.set(chat_id, { ...pendingVault, startedAt: Date.now() })
|
|
13735
|
-
await switchroomReply(ctx, '⚠️ Invalid duration. Use
|
|
13890
|
+
await switchroomReply(ctx, '⚠️ Invalid duration. Use \`Nd\` (days) or \`Nh\` (hours), e.g. \`30d\` or \`12h\`.', { html: true })
|
|
13736
13891
|
return
|
|
13737
13892
|
}
|
|
13738
13893
|
const newState = { ...pendingVault, ttlSeconds, awaitingCustomDuration: false }
|
|
@@ -13759,7 +13914,7 @@ async function handleInbound(
|
|
|
13759
13914
|
if (!VAULT_KEY_REGEX.test(newKey)) {
|
|
13760
13915
|
// Re-arm the pending state so the user can try again.
|
|
13761
13916
|
pendingVaultOps.set(chat_id, { ...pendingVault, startedAt: Date.now() })
|
|
13762
|
-
await switchroomReply(ctx, `⚠️ Key must match
|
|
13917
|
+
await switchroomReply(ctx, `⚠️ Key must match \`${VAULT_KEY_REGEX_LABEL}\`. Send a different name.`, { html: true })
|
|
13763
13918
|
return
|
|
13764
13919
|
}
|
|
13765
13920
|
staged.key = newKey
|
|
@@ -13770,8 +13925,8 @@ async function handleInbound(
|
|
|
13770
13925
|
.editMessageText(
|
|
13771
13926
|
staged.chat_id,
|
|
13772
13927
|
staged.card_message_id,
|
|
13773
|
-
renderVaultRequestSaveCard(staged, staged.agent),
|
|
13774
|
-
{
|
|
13928
|
+
richMessage(renderVaultRequestSaveCard(staged, staged.agent)),
|
|
13929
|
+
{ reply_markup: buildVaultRequestSaveKeyboard(pendingVault.stageId) },
|
|
13775
13930
|
)
|
|
13776
13931
|
.catch(() => {})
|
|
13777
13932
|
}
|
|
@@ -13806,7 +13961,7 @@ async function handleInbound(
|
|
|
13806
13961
|
if (verb === 'stash' || verb === 'rename') {
|
|
13807
13962
|
const cached = vaultPassphraseCache.get(chat_id)
|
|
13808
13963
|
if (!cached || cached.expiresAt <= Date.now()) {
|
|
13809
|
-
await switchroomReply(ctx, 'No vault passphrase cached. Run
|
|
13964
|
+
await switchroomReply(ctx, 'No vault passphrase cached. Run \`/vault list\` first (or any /vault command) to unlock, then re-send \`stash NAME\`.', { html: true })
|
|
13810
13965
|
return
|
|
13811
13966
|
}
|
|
13812
13967
|
const slugBase = arg && arg.length > 0 ? arg : staged.detection.suggested_slug
|
|
@@ -13824,14 +13979,14 @@ async function handleInbound(
|
|
|
13824
13979
|
const rendered = renderVaultCliError(parsed, { verb: 'save', key: slug })
|
|
13825
13980
|
const body = rendered.suppressRaw
|
|
13826
13981
|
? rendered.html
|
|
13827
|
-
:
|
|
13982
|
+
: `**vault write failed:**\n${preBlock(write.output)}`
|
|
13828
13983
|
await switchroomReply(ctx, body, { html: true })
|
|
13829
13984
|
return
|
|
13830
13985
|
}
|
|
13831
13986
|
secretStaging.delete(staged.chat_id, staged.message_id)
|
|
13832
13987
|
if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'stash command')
|
|
13833
13988
|
await deleteSensitiveMessage(chat_id, staged.message_id, 'detected secret')
|
|
13834
|
-
await switchroomReply(ctx, `✅ stored as
|
|
13989
|
+
await switchroomReply(ctx, `✅ stored as \`vault:${slug}\` (masked: \`${maskToken(staged.detection.matched_text)}\`)`, { html: true })
|
|
13835
13990
|
return
|
|
13836
13991
|
}
|
|
13837
13992
|
}
|
|
@@ -13921,11 +14076,11 @@ async function handleInbound(
|
|
|
13921
14076
|
await deleteSensitiveMessage(chat_id, msgId, 'detected secret')
|
|
13922
14077
|
}
|
|
13923
14078
|
const lines = pipeRes.stored.map((s) =>
|
|
13924
|
-
`•
|
|
14079
|
+
`• \`${s.masked}\` → \`vault:${s.actual_slug}\``,
|
|
13925
14080
|
)
|
|
13926
14081
|
await switchroomReply(
|
|
13927
14082
|
ctx,
|
|
13928
|
-
[`🔒 captured ${pipeRes.stored.length} secret${pipeRes.stored.length === 1 ? '' : 's'}:`, ...lines, '', 'reply
|
|
14083
|
+
[`🔒 captured ${pipeRes.stored.length} secret${pipeRes.stored.length === 1 ? '' : 's'}:`, ...lines, '', 'reply \`rename X\` or \`forget\`.'].join('\n'),
|
|
13929
14084
|
{ html: true },
|
|
13930
14085
|
)
|
|
13931
14086
|
for (const s of pipeRes.stored) {
|
|
@@ -13978,7 +14133,7 @@ async function handleInbound(
|
|
|
13978
14133
|
const top = pipeRes.ambiguous[0]!
|
|
13979
14134
|
await switchroomReply(
|
|
13980
14135
|
ctx,
|
|
13981
|
-
`👀 looks like a high-entropy string (rule:
|
|
14136
|
+
`👀 looks like a high-entropy string (rule: \`${top.rule_id}\`). reply \`stash NAME\` to store in vault, or \`ignore\`.`,
|
|
13982
14137
|
{ html: true },
|
|
13983
14138
|
)
|
|
13984
14139
|
// For ambiguous, we do NOT delete the message or rewrite — let the
|
|
@@ -14127,7 +14282,6 @@ async function handleInbound(
|
|
|
14127
14282
|
// content bug. Delete from the map FIRST so any concurrent
|
|
14128
14283
|
// reads can't see the stale stream while we await.
|
|
14129
14284
|
activeDraftStreams.delete(sKey)
|
|
14130
|
-
activeDraftParseModes.delete(sKey)
|
|
14131
14285
|
await priorStream.finalize().catch(() => {})
|
|
14132
14286
|
}
|
|
14133
14287
|
suppressPtyPreview.delete(sKey)
|
|
@@ -14637,7 +14791,10 @@ function switchroomExecCombined(args: string[], timeoutMs = 15000): string {
|
|
|
14637
14791
|
})
|
|
14638
14792
|
}
|
|
14639
14793
|
|
|
14640
|
-
|
|
14794
|
+
// Default truncation budget for CLI output bound for Telegram. The rich-message
|
|
14795
|
+
// wire cap is RICH_MESSAGE_MAX_CHARS (32768) post-#2669, not the legacy 4096
|
|
14796
|
+
// plain-text limit. Mirrors shared/bot-runtime.ts formatSwitchroomOutput.
|
|
14797
|
+
function formatSwitchroomOutput(output: string, maxLen = RICH_MESSAGE_MAX_CHARS): string {
|
|
14641
14798
|
const trimmed = output.trim()
|
|
14642
14799
|
if (trimmed.length <= maxLen) return trimmed
|
|
14643
14800
|
return trimmed.slice(0, maxLen - 20) + '\n... (truncated)'
|
|
@@ -14647,12 +14804,15 @@ function stripAnsi(text: string): string {
|
|
|
14647
14804
|
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
|
14648
14805
|
}
|
|
14649
14806
|
|
|
14807
|
+
// #2669: escape GFM-markdown specials in dynamic values interpolated into
|
|
14808
|
+
// rich-message bodies (kept under the legacy name to avoid churn).
|
|
14650
14809
|
function escapeHtmlForTg(text: string): string {
|
|
14651
|
-
return text.replace(
|
|
14810
|
+
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
14652
14811
|
}
|
|
14653
14812
|
|
|
14813
|
+
// Wrap CLI/command output in a fenced code block (content is literal there).
|
|
14654
14814
|
function preBlock(text: string): string {
|
|
14655
|
-
return '
|
|
14815
|
+
return '```\n' + text.replace(/```/g, '```') + '\n```'
|
|
14656
14816
|
}
|
|
14657
14817
|
|
|
14658
14818
|
type SwitchroomReplyMarkup =
|
|
@@ -14682,11 +14842,17 @@ async function switchroomReply(
|
|
|
14682
14842
|
? slashCommandReplyOpts(ctx, options.classification)
|
|
14683
14843
|
: {}
|
|
14684
14844
|
const threadId = routedOpts.message_thread_id ?? baseThreadId
|
|
14685
|
-
|
|
14845
|
+
const replyOpts = {
|
|
14686
14846
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
14687
|
-
...(options.html ? { parse_mode: 'HTML' as const, link_preview_options: { is_disabled: true } } : {}),
|
|
14688
14847
|
...(options.reply_markup ? { reply_markup: options.reply_markup } : {}),
|
|
14689
|
-
}
|
|
14848
|
+
}
|
|
14849
|
+
// #2669: `options.html` now means "render `text` as GFM markdown via the
|
|
14850
|
+
// rich-message path" (legacy field name kept). Plain otherwise.
|
|
14851
|
+
if (options.html) {
|
|
14852
|
+
await ctx.replyWithRichMessage(richMessage(text), replyOpts)
|
|
14853
|
+
} else {
|
|
14854
|
+
await ctx.reply(text, replyOpts)
|
|
14855
|
+
}
|
|
14690
14856
|
}
|
|
14691
14857
|
|
|
14692
14858
|
/**
|
|
@@ -14725,10 +14891,11 @@ async function deleteSensitiveMessage(
|
|
|
14725
14891
|
// the stderr log is the audit trail.
|
|
14726
14892
|
await swallowingApiCall(
|
|
14727
14893
|
() =>
|
|
14728
|
-
bot
|
|
14894
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
|
|
14895
|
+
bot.api.sendRichMessage(
|
|
14729
14896
|
chatId,
|
|
14730
|
-
`⚠️
|
|
14731
|
-
{
|
|
14897
|
+
richMessage(`⚠️ **Could not auto-delete message containing your ${escapeHtmlForTg(reason)}.**\n\nPlease delete message \`${msgId}\` manually so the value is not retained in chat history.\n\n_Reason: ${escapeHtmlForTg(msg)}_`),
|
|
14898
|
+
{},
|
|
14732
14899
|
),
|
|
14733
14900
|
{ chat_id: chatId, verb: 'deleteSensitiveMessage.warning' },
|
|
14734
14901
|
)
|
|
@@ -15233,7 +15400,7 @@ function notifyDetachedFailure(
|
|
|
15233
15400
|
clearRestartMarker()
|
|
15234
15401
|
const snippet = tail ? tail.slice(-800) : '(no output captured)'
|
|
15235
15402
|
const text =
|
|
15236
|
-
`❌
|
|
15403
|
+
`❌ **${escapeHtmlForTg(label)} failed** (exit ${code}):\n` +
|
|
15237
15404
|
preBlock(snippet)
|
|
15238
15405
|
// Fire-and-forget — we're off the command-handler context and don't
|
|
15239
15406
|
// have an await to block on anyway.
|
|
@@ -15242,9 +15409,7 @@ function notifyDetachedFailure(
|
|
|
15242
15409
|
// failure handler.
|
|
15243
15410
|
void swallowingApiCall(
|
|
15244
15411
|
() =>
|
|
15245
|
-
lockedBot.api.
|
|
15246
|
-
parse_mode: 'HTML',
|
|
15247
|
-
link_preview_options: { is_disabled: true },
|
|
15412
|
+
lockedBot.api.sendRichMessage(chatId, richMessage(text), {
|
|
15248
15413
|
// Detached restart/update child-failure notice — status, not
|
|
15249
15414
|
// the user's answer. Silence the open ping.
|
|
15250
15415
|
disable_notification: true,
|
|
@@ -15353,8 +15518,8 @@ function formatAuthOutputForTelegram(output: string): { text: string; url: strin
|
|
|
15353
15518
|
return true
|
|
15354
15519
|
})
|
|
15355
15520
|
const rendered = body.map(line => {
|
|
15356
|
-
if (line.startsWith('Started Claude auth') || line.startsWith('Auth session already running')) return
|
|
15357
|
-
if (line.startsWith('Open this URL')) return
|
|
15521
|
+
if (line.startsWith('Started Claude auth') || line.startsWith('Auth session already running')) return `**${escapeHtmlForTg(line)}**`
|
|
15522
|
+
if (line.startsWith('Open this URL')) return `_${escapeHtmlForTg(line)}_`
|
|
15358
15523
|
return escapeHtmlForTg(line)
|
|
15359
15524
|
})
|
|
15360
15525
|
// Mobile-native post-script. Two paths depending on which Anthropic
|
|
@@ -15378,11 +15543,11 @@ function formatAuthOutputForTelegram(output: string): { text: string; url: strin
|
|
|
15378
15543
|
// the same outcome with no API constraint. See PR #30.
|
|
15379
15544
|
rendered.push(
|
|
15380
15545
|
'',
|
|
15381
|
-
'👇 Tap
|
|
15546
|
+
'👇 Tap **🔐 Open Claude auth** below, then **reply with the browser code**.',
|
|
15382
15547
|
'',
|
|
15383
|
-
'
|
|
15548
|
+
'_Wrong Anthropic account getting authorized? Long-press the URL below and choose "Copy Link" or "Open in Browser" — lands in your main browser where the right account is signed in, bypassing Telegram\'s in-app browser cookies._',
|
|
15384
15549
|
'',
|
|
15385
|
-
|
|
15550
|
+
url,
|
|
15386
15551
|
)
|
|
15387
15552
|
return { text: rendered.join('\n'), url }
|
|
15388
15553
|
}
|
|
@@ -15474,7 +15639,7 @@ async function runSwitchroomAuthCommand(ctx: Context, args: string[], label: str
|
|
|
15474
15639
|
if (error.message?.includes('ENOENT')) { await switchroomReply(ctx, 'switchroom CLI not found.', { html: true }); return }
|
|
15475
15640
|
if (error.message?.includes('ETIMEDOUT') || error.message?.includes('timed out')) { await switchroomReply(ctx, `${label}: timed out`); return }
|
|
15476
15641
|
const detail = stripAnsi(error.stderr?.trim() || error.message || 'unknown error')
|
|
15477
|
-
await switchroomReply(ctx,
|
|
15642
|
+
await switchroomReply(ctx, `**${escapeHtmlForTg(label)} failed:**\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true })
|
|
15478
15643
|
}
|
|
15479
15644
|
}
|
|
15480
15645
|
|
|
@@ -15521,7 +15686,7 @@ function renderVaultOpFailure(
|
|
|
15521
15686
|
const verb = verbLabel === 'delete' ? 'remove' : verbLabel
|
|
15522
15687
|
const rendered = renderVaultCliError(parsed, { verb, key })
|
|
15523
15688
|
if (rendered.suppressRaw) return rendered.html
|
|
15524
|
-
return
|
|
15689
|
+
return `**vault ${verbLabel} failed:**\n${preBlock(cliOutput)}`
|
|
15525
15690
|
}
|
|
15526
15691
|
|
|
15527
15692
|
async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' | 'set' | 'delete', key: string | undefined, passphrase: string, setValue: string | undefined): Promise<void> {
|
|
@@ -15530,24 +15695,24 @@ async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' |
|
|
|
15530
15695
|
if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('list', r.output, undefined), { html: true }); return }
|
|
15531
15696
|
const keys = r.output.split('\n').filter(Boolean)
|
|
15532
15697
|
if (keys.length === 0) { await switchroomReply(ctx, 'Vault is empty.', { html: true }) }
|
|
15533
|
-
else { await switchroomReply(ctx,
|
|
15698
|
+
else { await switchroomReply(ctx, `**Vault keys (${keys.length}):**\n${keys.map(k => `• \`${k}\``).join('\n')}`, { html: true }) }
|
|
15534
15699
|
} else if (op === 'get') {
|
|
15535
15700
|
const r = runVaultCli(['get', key!], passphrase)
|
|
15536
15701
|
if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('get', r.output, key), { html: true }); return }
|
|
15537
|
-
await switchroomReply(ctx,
|
|
15702
|
+
await switchroomReply(ctx, `\`${key!}\` =\n\`${r.output}\``, { html: true })
|
|
15538
15703
|
} else if (op === 'set') {
|
|
15539
15704
|
if (setValue === undefined) {
|
|
15540
15705
|
pendingVaultOps.set(chatId, { kind: 'value', op: 'set', key: key!, passphrase, startedAt: Date.now() })
|
|
15541
|
-
await switchroomReply(ctx, `Send the value for
|
|
15706
|
+
await switchroomReply(ctx, `Send the value for \`${key!}\`.`, { html: true })
|
|
15542
15707
|
return
|
|
15543
15708
|
}
|
|
15544
15709
|
const r = runVaultCli(['set', key!], passphrase, setValue)
|
|
15545
15710
|
if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('set', r.output, key), { html: true }) }
|
|
15546
|
-
else { await switchroomReply(ctx, `✅
|
|
15711
|
+
else { await switchroomReply(ctx, `✅ \`${key!}\` saved to vault.`, { html: true }) }
|
|
15547
15712
|
} else if (op === 'delete') {
|
|
15548
15713
|
const r = runVaultCli(['remove', key!], passphrase)
|
|
15549
15714
|
if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('delete', r.output, key), { html: true }) }
|
|
15550
|
-
else { await switchroomReply(ctx, `✅
|
|
15715
|
+
else { await switchroomReply(ctx, `✅ \`${key!}\` removed from vault.`, { html: true }) }
|
|
15551
15716
|
}
|
|
15552
15717
|
}
|
|
15553
15718
|
|
|
@@ -15598,8 +15763,8 @@ async function dispatchShortVerbViaHostd(
|
|
|
15598
15763
|
if (hostdResp.result === 'started') {
|
|
15599
15764
|
await switchroomReply(
|
|
15600
15765
|
ctx,
|
|
15601
|
-
`🔄
|
|
15602
|
-
`(request_id
|
|
15766
|
+
`🔄 **${escapeHtmlForTg(label)}** dispatched via hostd ` +
|
|
15767
|
+
`(request_id=\`${hostdResp.request_id}\`). ` +
|
|
15603
15768
|
`Check audit log for completion.`,
|
|
15604
15769
|
{ html: true },
|
|
15605
15770
|
)
|
|
@@ -15613,7 +15778,7 @@ async function dispatchShortVerbViaHostd(
|
|
|
15613
15778
|
'(no error tail returned)'
|
|
15614
15779
|
await switchroomReply(
|
|
15615
15780
|
ctx,
|
|
15616
|
-
`❌
|
|
15781
|
+
`❌ **${escapeHtmlForTg(label)} failed via hostd** ` +
|
|
15617
15782
|
`(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
|
|
15618
15783
|
preBlock(stripAnsi(errBody)),
|
|
15619
15784
|
{ html: true },
|
|
@@ -15640,7 +15805,7 @@ async function runSwitchroomCommand(
|
|
|
15640
15805
|
if (error.message?.includes('ENOENT')) { await switchroomReply(ctx, 'switchroom CLI not found.', { html: true, classification }); return }
|
|
15641
15806
|
if (error.message?.includes('ETIMEDOUT') || error.message?.includes('timed out')) { await switchroomReply(ctx, `${label}: timed out`, { classification }); return }
|
|
15642
15807
|
const detail = stripAnsi(error.stderr?.trim() || error.message || 'unknown error')
|
|
15643
|
-
await switchroomReply(ctx,
|
|
15808
|
+
await switchroomReply(ctx, `**${escapeHtmlForTg(label)} failed:**\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true, classification })
|
|
15644
15809
|
}
|
|
15645
15810
|
}
|
|
15646
15811
|
|
|
@@ -15666,16 +15831,16 @@ function statusIcon(status: string): string {
|
|
|
15666
15831
|
function renderAuthCodeOutcome(outcome: AuthCodeOutcome | null | undefined): string | null {
|
|
15667
15832
|
if (!outcome || outcome.kind === 'success') return null
|
|
15668
15833
|
const tail = outcome.paneTailText
|
|
15669
|
-
? `\
|
|
15834
|
+
? `\n_${escapeHtmlForTg(outcome.paneTailText)}_`
|
|
15670
15835
|
: ''
|
|
15671
15836
|
switch (outcome.kind) {
|
|
15672
15837
|
case 'invalid-code':
|
|
15673
15838
|
case 'expired-code':
|
|
15674
|
-
return `Code rejected by Claude — tap
|
|
15839
|
+
return `Code rejected by Claude — tap **Restart flow** for a fresh URL.${tail}`
|
|
15675
15840
|
case 'pane-not-ready':
|
|
15676
|
-
return `Auth pane not ready — tap
|
|
15841
|
+
return `Auth pane not ready — tap **Retry**.`
|
|
15677
15842
|
case 'timeout':
|
|
15678
|
-
return `Still waiting after 2 min — tap
|
|
15843
|
+
return `Still waiting after 2 min — tap **Retry** or check \`switchroom auth list\`.${tail}`
|
|
15679
15844
|
}
|
|
15680
15845
|
}
|
|
15681
15846
|
|
|
@@ -15727,7 +15892,7 @@ async function runSwitchroomCommandFormatted(ctx: Context, args: string[], label
|
|
|
15727
15892
|
} catch (err: unknown) {
|
|
15728
15893
|
const error = err as { stderr?: string; message?: string }
|
|
15729
15894
|
const detail = stripAnsi(error.stderr?.trim() || error.message || 'unknown error')
|
|
15730
|
-
await switchroomReply(ctx,
|
|
15895
|
+
await switchroomReply(ctx, `**${escapeHtmlForTg(label)} failed:**\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true })
|
|
15731
15896
|
}
|
|
15732
15897
|
}
|
|
15733
15898
|
|
|
@@ -15762,8 +15927,8 @@ bot.use(async (ctx, next) => {
|
|
|
15762
15927
|
const nameHtml = escapeHtmlForTg(myName)
|
|
15763
15928
|
const notFlagged =
|
|
15764
15929
|
decision.reason === 'other-agent'
|
|
15765
|
-
? `⚠️
|
|
15766
|
-
: `⚠️
|
|
15930
|
+
? `⚠️ \`${cmdHtml}\` targeting another agent is an admin operation — this agent (\`${nameHtml}\`) isn't admin-flagged. Run it from an admin agent, or set \`admin: true\` for this agent in switchroom.yaml. (Self-restart is allowed: send \`/restart\` with no arg.)`
|
|
15931
|
+
: `⚠️ \`${cmdHtml}\` is an admin command — this agent (\`${nameHtml}\`) isn't admin-flagged. Run it from an admin agent, or set \`admin: true\` for this agent in switchroom.yaml.`
|
|
15767
15932
|
if (!AGENT_ADMIN) {
|
|
15768
15933
|
// Unchanged behaviour: a non-admin agent never executes admin
|
|
15769
15934
|
// verbs locally and must not forward them to Claude.
|
|
@@ -15793,7 +15958,7 @@ bot.use(async (ctx, next) => {
|
|
|
15793
15958
|
)
|
|
15794
15959
|
await switchroomReply(
|
|
15795
15960
|
ctx,
|
|
15796
|
-
`⚠️
|
|
15961
|
+
`⚠️ \`${cmdHtml}\` is a fleet-admin command — it is **operator-private**. Send it as a direct message to me from your operator account (a private chat where your Telegram ID is on the access allowlist), not in a group or forum.`,
|
|
15797
15962
|
{ html: true },
|
|
15798
15963
|
)
|
|
15799
15964
|
return
|
|
@@ -16018,12 +16183,12 @@ bot.command('start', async ctx => {
|
|
|
16018
16183
|
const gated = dmCommandGate(ctx)
|
|
16019
16184
|
if (!gated) return
|
|
16020
16185
|
const disabled = gated.access.dmPolicy === 'disabled'
|
|
16021
|
-
await ctx.
|
|
16186
|
+
await ctx.replyWithRichMessage(richMessage(buildStartText(getMyAgentName(), disabled)))
|
|
16022
16187
|
})
|
|
16023
16188
|
|
|
16024
16189
|
bot.command('help', async ctx => {
|
|
16025
16190
|
if (!dmCommandGate(ctx)) return
|
|
16026
|
-
await ctx.
|
|
16191
|
+
await ctx.replyWithRichMessage(richMessage(buildHelpText(getMyAgentName())))
|
|
16027
16192
|
})
|
|
16028
16193
|
|
|
16029
16194
|
bot.command('status', async ctx => {
|
|
@@ -16038,12 +16203,12 @@ bot.command('status', async ctx => {
|
|
|
16038
16203
|
const demo = hasDemoFlag(getCommandArgs(ctx))
|
|
16039
16204
|
const userTag = from.username ? `@${from.username}` : senderId
|
|
16040
16205
|
const meta = await buildAgentMetadata(getMyAgentName())
|
|
16041
|
-
await ctx.
|
|
16206
|
+
await ctx.replyWithRichMessage(richMessage(buildStatusPairedText({ user: userTag, meta, demo })))
|
|
16042
16207
|
return
|
|
16043
16208
|
}
|
|
16044
16209
|
for (const [code, p] of Object.entries(access.pending)) {
|
|
16045
16210
|
if (p.senderId === senderId) {
|
|
16046
|
-
await ctx.
|
|
16211
|
+
await ctx.replyWithRichMessage(richMessage(buildStatusPendingText(code)))
|
|
16047
16212
|
return
|
|
16048
16213
|
}
|
|
16049
16214
|
}
|
|
@@ -16056,11 +16221,11 @@ bot.command('agents', async ctx => {
|
|
|
16056
16221
|
type AgentListResp = { agents: Array<{ name: string; status: string; uptime: string; template: string; topic_name: string; topic_emoji?: string }> }
|
|
16057
16222
|
const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
16058
16223
|
if (!data) return null
|
|
16059
|
-
if (data.agents.length === 0) return '
|
|
16060
|
-
const lines = ['
|
|
16224
|
+
if (data.agents.length === 0) return '_No agents defined_'
|
|
16225
|
+
const lines = ['**Agents**']
|
|
16061
16226
|
for (const a of data.agents) {
|
|
16062
|
-
lines.push(`${statusIcon(a.status)}
|
|
16063
|
-
lines.push(`
|
|
16227
|
+
lines.push(`${statusIcon(a.status)} **${escapeHtmlForTg(a.name)}** · ${escapeHtmlForTg(a.status)} · ${escapeHtmlForTg(a.uptime)}`)
|
|
16228
|
+
lines.push(` _${escapeHtmlForTg(a.template)} → ${escapeHtmlForTg(a.topic_name)}${a.topic_emoji ? ' ' + a.topic_emoji : ''}_`)
|
|
16064
16229
|
}
|
|
16065
16230
|
return lines.join('\n')
|
|
16066
16231
|
})
|
|
@@ -16335,8 +16500,7 @@ bot.command('restart', async ctx => {
|
|
|
16335
16500
|
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
16336
16501
|
robustApiCall,
|
|
16337
16502
|
(tid) =>
|
|
16338
|
-
lockedBot.api.
|
|
16339
|
-
parse_mode: 'HTML', link_preview_options: { is_disabled: true },
|
|
16503
|
+
lockedBot.api.sendRichMessage(chatId, richMessage(ackText), {
|
|
16340
16504
|
// Restart acknowledgement is a status notice — silence the
|
|
16341
16505
|
// open ping (the "restarted — ready" follow-up is what matters).
|
|
16342
16506
|
disable_notification: true,
|
|
@@ -16378,7 +16542,7 @@ bot.command('restart', async ctx => {
|
|
|
16378
16542
|
clearRestartMarker()
|
|
16379
16543
|
await switchroomReply(
|
|
16380
16544
|
ctx,
|
|
16381
|
-
`❌
|
|
16545
|
+
`❌ **restart ${escapeHtmlForTg(name)} failed via hostd** ` +
|
|
16382
16546
|
`(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
|
|
16383
16547
|
preBlock(hostdResp.error ?? '(no error message)'),
|
|
16384
16548
|
{ html: true },
|
|
@@ -16435,7 +16599,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
|
|
|
16435
16599
|
await switchroomReply(
|
|
16436
16600
|
ctx,
|
|
16437
16601
|
`/${kind} only supports a single agent — “all” would leave other agents with stale handoff briefings. ` +
|
|
16438
|
-
`Run /${kind} from each agent’s own topic, or use
|
|
16602
|
+
`Run /${kind} from each agent’s own topic, or use \`switchroom agent restart all\` if you just want a plain restart without flushing sessions.`,
|
|
16439
16603
|
{ html: true },
|
|
16440
16604
|
)
|
|
16441
16605
|
return
|
|
@@ -16446,9 +16610,9 @@ async function handleNewCommand(ctx: Context): Promise<void> {
|
|
|
16446
16610
|
if (!isSelfTargetingCommand(name)) {
|
|
16447
16611
|
await switchroomReply(
|
|
16448
16612
|
ctx,
|
|
16449
|
-
`/${kind} only supports the current agent (
|
|
16613
|
+
`/${kind} only supports the current agent (**${escapeHtmlForTg(getMyAgentName())}**). ` +
|
|
16450
16614
|
`To restart another agent with a fresh session, run /${kind} from its own topic, ` +
|
|
16451
|
-
`or use
|
|
16615
|
+
`or use \`switchroom agent restart ${escapeHtmlForTg(name)}\`.`,
|
|
16452
16616
|
{ html: true },
|
|
16453
16617
|
)
|
|
16454
16618
|
return
|
|
@@ -16460,7 +16624,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
|
|
|
16460
16624
|
if (existing && Date.now() - existing.ts < 15_000) {
|
|
16461
16625
|
await switchroomReply(
|
|
16462
16626
|
ctx,
|
|
16463
|
-
`⏳ Restart of
|
|
16627
|
+
`⏳ Restart of **${escapeHtmlForTg(name)}** already in progress (${Math.round((Date.now() - existing.ts) / 1000)}s ago) — ignoring duplicate /${kind}.`,
|
|
16464
16628
|
{ html: true },
|
|
16465
16629
|
)
|
|
16466
16630
|
return
|
|
@@ -16479,8 +16643,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
|
|
|
16479
16643
|
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
16480
16644
|
robustApiCall,
|
|
16481
16645
|
(tid) =>
|
|
16482
|
-
lockedBot.api.
|
|
16483
|
-
parse_mode: 'HTML', link_preview_options: { is_disabled: true },
|
|
16646
|
+
lockedBot.api.sendRichMessage(chatId, richMessage(ackText), {
|
|
16484
16647
|
// /new /reset acknowledgement is a status notice — silence the
|
|
16485
16648
|
// open ping (the post-restart greeting card is what matters).
|
|
16486
16649
|
disable_notification: true,
|
|
@@ -16534,7 +16697,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
|
|
|
16534
16697
|
clearRestartMarker()
|
|
16535
16698
|
await switchroomReply(
|
|
16536
16699
|
ctx,
|
|
16537
|
-
`❌
|
|
16700
|
+
`❌ **${escapeHtmlForTg(kind)} ${escapeHtmlForTg(name)} failed via hostd** ` +
|
|
16538
16701
|
`(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
|
|
16539
16702
|
preBlock(hostdResp.error ?? '(no error message)'),
|
|
16540
16703
|
{ html: true },
|
|
@@ -16555,7 +16718,7 @@ bot.command('update', async ctx => {
|
|
|
16555
16718
|
await runSwitchroomCommand(ctx, ['update', '--check'], 'update --check')
|
|
16556
16719
|
await switchroomReply(
|
|
16557
16720
|
ctx,
|
|
16558
|
-
'Reply with
|
|
16721
|
+
'Reply with \`/update apply\` to execute, or \`/update apply --skip-images\` to skip the image pull.',
|
|
16559
16722
|
{ html: true },
|
|
16560
16723
|
)
|
|
16561
16724
|
return
|
|
@@ -16567,7 +16730,7 @@ bot.command('update', async ctx => {
|
|
|
16567
16730
|
if (tokens[0] !== 'apply' && tokens[0] !== '--apply') {
|
|
16568
16731
|
await switchroomReply(
|
|
16569
16732
|
ctx,
|
|
16570
|
-
'Usage:
|
|
16733
|
+
'Usage: \`/update\` (dry-run) or \`/update apply [--skip-images] [--rebuild]\`',
|
|
16571
16734
|
{ html: true },
|
|
16572
16735
|
)
|
|
16573
16736
|
return
|
|
@@ -16581,8 +16744,8 @@ bot.command('update', async ctx => {
|
|
|
16581
16744
|
if (!ALLOWED_FLAGS.has(tok)) {
|
|
16582
16745
|
await switchroomReply(
|
|
16583
16746
|
ctx,
|
|
16584
|
-
`Refusing to pass unknown flag:
|
|
16585
|
-
`Allowed:
|
|
16747
|
+
`Refusing to pass unknown flag: \`${tok}\`. ` +
|
|
16748
|
+
`Allowed: \`--skip-images\`, \`--rebuild\`.`,
|
|
16586
16749
|
{ html: true },
|
|
16587
16750
|
)
|
|
16588
16751
|
return
|
|
@@ -16615,16 +16778,16 @@ bot.command('update', async ctx => {
|
|
|
16615
16778
|
// hostd hasn't been installed yet, or its daemon is down.
|
|
16616
16779
|
await switchroomReply(
|
|
16617
16780
|
ctx,
|
|
16618
|
-
`❌
|
|
16619
|
-
`for agent
|
|
16781
|
+
`❌ **/update apply** needs \`hostd\`, but its socket ` +
|
|
16782
|
+
`for agent \`${myAgentName}\` isn't ` +
|
|
16620
16783
|
`bound and in-container docker access isn't available either.\n\n` +
|
|
16621
|
-
|
|
16784
|
+
`\`host_control.enabled\` is on in \`switchroom.yaml\`, ` +
|
|
16622
16785
|
`so hostd is the expected dispatch path — but no socket at ` +
|
|
16623
|
-
|
|
16786
|
+
`\`/run/switchroom/hostd/${escapeHtmlForTg(myAgentName)}/sock\`. ` +
|
|
16624
16787
|
`On a fresh docker install this usually means hostd hasn't been ` +
|
|
16625
16788
|
`installed yet.\n\n` +
|
|
16626
|
-
`Run
|
|
16627
|
-
`start the daemon, then retry. Send
|
|
16789
|
+
`Run \`switchroom hostd install\` on the host to install + ` +
|
|
16790
|
+
`start the daemon, then retry. Send \`/upgradestatus\` to ` +
|
|
16628
16791
|
`re-check the daemon state from here.`,
|
|
16629
16792
|
{ html: true },
|
|
16630
16793
|
)
|
|
@@ -16634,14 +16797,14 @@ bot.command('update', async ctx => {
|
|
|
16634
16797
|
// pick one of: host CLI, or enable hostd.
|
|
16635
16798
|
await switchroomReply(
|
|
16636
16799
|
ctx,
|
|
16637
|
-
`❌
|
|
16638
|
-
`container, but it's not available (no
|
|
16639
|
-
`PATH, no
|
|
16640
|
-
|
|
16641
|
-
`Either run
|
|
16642
|
-
`set
|
|
16643
|
-
|
|
16644
|
-
|
|
16800
|
+
`❌ **/update apply** needs docker access from inside the agent ` +
|
|
16801
|
+
`container, but it's not available (no \`docker\` binary on ` +
|
|
16802
|
+
`PATH, no \`/var/run/docker.sock\` mount) and ` +
|
|
16803
|
+
`\`host_control.enabled\` is off.\n\n` +
|
|
16804
|
+
`Either run \`switchroom update\` from the host shell, or ` +
|
|
16805
|
+
`set \`host_control.enabled: true\` in ` +
|
|
16806
|
+
`\`switchroom.yaml\` and run ` +
|
|
16807
|
+
`\`switchroom hostd install\` on the host so this verb ` +
|
|
16645
16808
|
`can dispatch through the host-side daemon.`,
|
|
16646
16809
|
{ html: true },
|
|
16647
16810
|
)
|
|
@@ -16671,9 +16834,9 @@ bot.command('update', async ctx => {
|
|
|
16671
16834
|
// /restart handler (gateway.ts ~6273) so the boot-card lookup
|
|
16672
16835
|
// (gateway.ts ~10393) finds chat_id + ack_message_id in the marker.
|
|
16673
16836
|
const ackText =
|
|
16674
|
-
`🚀
|
|
16675
|
-
'
|
|
16676
|
-
...passthrough.map((t) =>
|
|
16837
|
+
`🚀 **update started** — running ${[
|
|
16838
|
+
'\`switchroom update\`',
|
|
16839
|
+
...passthrough.map((t) => `\`${t}\``),
|
|
16677
16840
|
].join(' ')}\n` +
|
|
16678
16841
|
`\nThe gateway will restart as part of the recreate step; watch ` +
|
|
16679
16842
|
`for the post-restart greeting card to confirm completion.`
|
|
@@ -16683,9 +16846,7 @@ bot.command('update', async ctx => {
|
|
|
16683
16846
|
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
16684
16847
|
robustApiCall,
|
|
16685
16848
|
(tid) =>
|
|
16686
|
-
lockedBot.api.
|
|
16687
|
-
parse_mode: 'HTML',
|
|
16688
|
-
link_preview_options: { is_disabled: true },
|
|
16849
|
+
lockedBot.api.sendRichMessage(chatId, richMessage(ackText), {
|
|
16689
16850
|
// "update started" acknowledgement is a status notice — silence
|
|
16690
16851
|
// the open ping (the post-restart greeting card is what matters).
|
|
16691
16852
|
disable_notification: true,
|
|
@@ -16778,15 +16939,14 @@ bot.command('update', async ctx => {
|
|
|
16778
16939
|
terminal.stdout_tail ??
|
|
16779
16940
|
'(no error tail returned)'
|
|
16780
16941
|
const editedText =
|
|
16781
|
-
`🚀
|
|
16942
|
+
`🚀 **update started** — **FAILED** via hostd ` +
|
|
16782
16943
|
`(result=${escapeHtmlForTg(terminal.result)}):\n` +
|
|
16783
16944
|
preBlock(errBody)
|
|
16784
16945
|
if (ackId != null) {
|
|
16785
16946
|
try {
|
|
16786
16947
|
await robustApiCall(
|
|
16787
16948
|
() =>
|
|
16788
|
-
lockedBot.api.editMessageText(chatId, ackId!, editedText, {
|
|
16789
|
-
parse_mode: 'HTML',
|
|
16949
|
+
lockedBot.api.editMessageText(chatId, ackId!, richMessage(editedText), {
|
|
16790
16950
|
link_preview_options: { is_disabled: true },
|
|
16791
16951
|
}),
|
|
16792
16952
|
{ verb: 'update.poll.editAck' },
|
|
@@ -16814,7 +16974,7 @@ bot.command('update', async ctx => {
|
|
|
16814
16974
|
clearRestartMarker()
|
|
16815
16975
|
await switchroomReply(
|
|
16816
16976
|
ctx,
|
|
16817
|
-
`❌
|
|
16977
|
+
`❌ **/update apply failed via hostd** ` +
|
|
16818
16978
|
`(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
|
|
16819
16979
|
preBlock(hostdResp.error ?? '(no error message)'),
|
|
16820
16980
|
{ html: true },
|
|
@@ -16841,8 +17001,8 @@ bot.command('upgrade', async ctx => {
|
|
|
16841
17001
|
if (!isAuthorizedSender(ctx)) return
|
|
16842
17002
|
await switchroomReply(
|
|
16843
17003
|
ctx,
|
|
16844
|
-
'Did you mean
|
|
16845
|
-
'Or
|
|
17004
|
+
'Did you mean \`/upgradestatus\` (no hyphen — Telegram slash-command grammar)? ' +
|
|
17005
|
+
'Or \`/update\` to plan, \`/update apply\` to execute.',
|
|
16846
17006
|
{ html: true },
|
|
16847
17007
|
)
|
|
16848
17008
|
})
|
|
@@ -16858,7 +17018,7 @@ bot.command('audit', async ctx => {
|
|
|
16858
17018
|
if (arg === '' || arg === 'help' || arg === '--help') {
|
|
16859
17019
|
await switchroomReply(
|
|
16860
17020
|
ctx,
|
|
16861
|
-
'Usage:
|
|
17021
|
+
'Usage: \`/audit hostd [--tail N] [--agent <name>] [--op <verb>] [--error] [--verbose]\`',
|
|
16862
17022
|
{ html: true },
|
|
16863
17023
|
)
|
|
16864
17024
|
return
|
|
@@ -16868,8 +17028,8 @@ bot.command('audit', async ctx => {
|
|
|
16868
17028
|
if (sub !== 'hostd') {
|
|
16869
17029
|
await switchroomReply(
|
|
16870
17030
|
ctx,
|
|
16871
|
-
`Unknown audit target
|
|
16872
|
-
`Supported:
|
|
17031
|
+
`Unknown audit target \`${sub ?? ''}\`. ` +
|
|
17032
|
+
`Supported: \`hostd\`.`,
|
|
16873
17033
|
{ html: true },
|
|
16874
17034
|
)
|
|
16875
17035
|
return
|
|
@@ -16889,22 +17049,22 @@ bot.command('audit', async ctx => {
|
|
|
16889
17049
|
if (t === '--tail' || t === '--agent' || t === '--op') {
|
|
16890
17050
|
const v = tokens[++i]
|
|
16891
17051
|
if (v == null) {
|
|
16892
|
-
await switchroomReply(ctx, `Flag
|
|
17052
|
+
await switchroomReply(ctx, `Flag \`${t}\` requires a value.`, { html: true })
|
|
16893
17053
|
return
|
|
16894
17054
|
}
|
|
16895
17055
|
if (t === '--tail' && !/^[0-9]{1,4}$/.test(v)) {
|
|
16896
|
-
await switchroomReply(ctx,
|
|
17056
|
+
await switchroomReply(ctx, `\`--tail\` must be an integer (1-9999).`, { html: true })
|
|
16897
17057
|
return
|
|
16898
17058
|
}
|
|
16899
17059
|
if (t === '--agent' && !/^[a-z][a-z0-9-]{0,62}$/i.test(v)) {
|
|
16900
|
-
await switchroomReply(ctx,
|
|
17060
|
+
await switchroomReply(ctx, `\`--agent\` name has an invalid shape.`, { html: true })
|
|
16901
17061
|
return
|
|
16902
17062
|
}
|
|
16903
17063
|
if (t === '--op' && !ALLOWED_OPS.has(v)) {
|
|
16904
17064
|
await switchroomReply(
|
|
16905
17065
|
ctx,
|
|
16906
|
-
`Unknown hostd verb
|
|
16907
|
-
`Known: ${[...ALLOWED_OPS].sort().map(o =>
|
|
17066
|
+
`Unknown hostd verb \`${v}\`. ` +
|
|
17067
|
+
`Known: ${[...ALLOWED_OPS].sort().map(o => `\`${o}\``).join(', ')}.`,
|
|
16908
17068
|
{ html: true },
|
|
16909
17069
|
)
|
|
16910
17070
|
return
|
|
@@ -16914,8 +17074,8 @@ bot.command('audit', async ctx => {
|
|
|
16914
17074
|
}
|
|
16915
17075
|
await switchroomReply(
|
|
16916
17076
|
ctx,
|
|
16917
|
-
`Unknown flag
|
|
16918
|
-
`Allowed:
|
|
17077
|
+
`Unknown flag \`${t}\`. ` +
|
|
17078
|
+
`Allowed: \`--tail\`, \`--agent\`, \`--op\`, \`--error\`, \`--verbose\`.`,
|
|
16919
17079
|
{ html: true },
|
|
16920
17080
|
)
|
|
16921
17081
|
return
|
|
@@ -16964,7 +17124,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
|
|
|
16964
17124
|
if (!details) {
|
|
16965
17125
|
await switchroomReply(
|
|
16966
17126
|
ctx,
|
|
16967
|
-
`No pending permission for id
|
|
17127
|
+
`No pending permission for id \`${request_id}\`. It may have already been answered or timed out.`,
|
|
16968
17128
|
{ html: true },
|
|
16969
17129
|
)
|
|
16970
17130
|
return
|
|
@@ -16983,10 +17143,10 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
|
|
|
16983
17143
|
`[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
|
|
16984
17144
|
)
|
|
16985
17145
|
const lbl = behavior === 'allow' ? '✅ Allowed' : '❌ Denied'
|
|
16986
|
-
const suffix = details.tool_name ? ` (
|
|
17146
|
+
const suffix = details.tool_name ? ` (\`${details.tool_name}\`)` : ''
|
|
16987
17147
|
await switchroomReply(
|
|
16988
17148
|
ctx,
|
|
16989
|
-
`${lbl}${suffix} via /${behavior}
|
|
17149
|
+
`${lbl}${suffix} via /${behavior} \`${request_id}\``,
|
|
16990
17150
|
{ html: true },
|
|
16991
17151
|
)
|
|
16992
17152
|
}
|
|
@@ -17064,9 +17224,9 @@ bot.command('pending', async ctx => {
|
|
|
17064
17224
|
await switchroomReply(ctx, 'No pending permission prompts.')
|
|
17065
17225
|
return
|
|
17066
17226
|
}
|
|
17067
|
-
const lines: string[] = ['
|
|
17227
|
+
const lines: string[] = ['**Pending permission prompts**']
|
|
17068
17228
|
for (const [id, details] of pendingPermissions.entries()) {
|
|
17069
|
-
lines.push(`•
|
|
17229
|
+
lines.push(`• \`${id}\` — ${escapeHtmlForTg(details.tool_name)}`)
|
|
17070
17230
|
}
|
|
17071
17231
|
await switchroomReply(ctx, lines.join('\n'), { html: true })
|
|
17072
17232
|
})
|
|
@@ -17266,7 +17426,8 @@ function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): vo
|
|
|
17266
17426
|
const html = renderFallbackFailureNotice(triggerAgent, reason)
|
|
17267
17427
|
for (const chat_id of access.allowFrom) {
|
|
17268
17428
|
void swallowingApiCall(
|
|
17269
|
-
|
|
17429
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
|
|
17430
|
+
() => bot.api.sendRichMessage(chat_id, richMessage(html), {}),
|
|
17270
17431
|
{ chat_id, verb: 'fleet-fallback:failure-notify' },
|
|
17271
17432
|
)
|
|
17272
17433
|
}
|
|
@@ -17356,10 +17517,11 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
|
|
|
17356
17517
|
if (access.allowFrom.length === 0) return outcome.kind === 'switched'
|
|
17357
17518
|
// Account-switch / all-blocked announcement is a system status notice,
|
|
17358
17519
|
// not the user's answer — silence the open ping.
|
|
17359
|
-
const opts = {
|
|
17520
|
+
const opts = { disable_notification: true }
|
|
17360
17521
|
for (const chat_id of access.allowFrom) {
|
|
17361
17522
|
void swallowingApiCall(
|
|
17362
|
-
|
|
17523
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
|
|
17524
|
+
() => bot.api.sendRichMessage(chat_id, richMessage(outcome.announcement), opts),
|
|
17363
17525
|
{ chat_id, verb: 'fleet-fallback:notify' },
|
|
17364
17526
|
)
|
|
17365
17527
|
}
|
|
@@ -17447,9 +17609,7 @@ async function runCreditWatch(): Promise<void> {
|
|
|
17447
17609
|
// contract enforced by scripts/check-bot-api-wrapping.sh (#1075).
|
|
17448
17610
|
await swallowingApiCall(
|
|
17449
17611
|
() =>
|
|
17450
|
-
bot.api.
|
|
17451
|
-
parse_mode: 'HTML',
|
|
17452
|
-
link_preview_options: { is_disabled: true },
|
|
17612
|
+
bot.api.sendRichMessage(chat_id, richMessage(decision.message), {
|
|
17453
17613
|
// Credit/quota warning is a system status notice — silence the
|
|
17454
17614
|
// open ping (the user isn't waiting to tap anything).
|
|
17455
17615
|
disable_notification: true,
|
|
@@ -17589,9 +17749,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
17589
17749
|
}
|
|
17590
17750
|
await swallowingApiCall(
|
|
17591
17751
|
() =>
|
|
17592
|
-
bot.api.
|
|
17593
|
-
parse_mode: 'HTML',
|
|
17594
|
-
link_preview_options: { is_disabled: true },
|
|
17752
|
+
bot.api.sendRichMessage(chat_id, richMessage(fleetDecision.message), {
|
|
17595
17753
|
// Quota status notice — silence the open ping.
|
|
17596
17754
|
disable_notification: true,
|
|
17597
17755
|
}),
|
|
@@ -17812,9 +17970,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
17812
17970
|
// contract enforced by scripts/check-bot-api-wrapping.sh (#1075).
|
|
17813
17971
|
await swallowingApiCall(
|
|
17814
17972
|
() =>
|
|
17815
|
-
bot.api.
|
|
17816
|
-
parse_mode: 'HTML',
|
|
17817
|
-
link_preview_options: { is_disabled: true },
|
|
17973
|
+
bot.api.sendRichMessage(chat_id, richMessage(message), {
|
|
17818
17974
|
// Quota throttling status notice — silence the open ping.
|
|
17819
17975
|
disable_notification: true,
|
|
17820
17976
|
}),
|
|
@@ -17845,8 +18001,7 @@ async function editMicrosoftConnectCard(
|
|
|
17845
18001
|
): Promise<void> {
|
|
17846
18002
|
await robustApiCall(
|
|
17847
18003
|
// allow-raw-bot-api: background connect-card edit by message id (no thread_id; not the reply chunk loop)
|
|
17848
|
-
() => bot.api.editMessageText(chatId, messageId, html, {
|
|
17849
|
-
parse_mode: 'HTML',
|
|
18004
|
+
() => bot.api.editMessageText(chatId, messageId, richMessage(html), {
|
|
17850
18005
|
link_preview_options: { is_disabled: true },
|
|
17851
18006
|
}),
|
|
17852
18007
|
{ chat_id: String(chatId), verb: 'microsoftConnectCard' },
|
|
@@ -17874,21 +18029,21 @@ async function finalizeMicrosoftConnect(key: string): Promise<void> {
|
|
|
17874
18029
|
let html: string
|
|
17875
18030
|
if (result.kind === 'connected') {
|
|
17876
18031
|
html =
|
|
17877
|
-
`✅
|
|
18032
|
+
`✅ **Connected** \`${result.account}\` ` +
|
|
17878
18033
|
`(${result.accountType}).\n\n` +
|
|
17879
|
-
`To let
|
|
17880
|
-
|
|
18034
|
+
`To let **${escapeHtmlForTg(agentName)}** use it, run on the host:\n` +
|
|
18035
|
+
`\`switchroom auth microsoft enable ${escapeHtmlForTg(result.account)} ${escapeHtmlForTg(agentName)}\`\n` +
|
|
17881
18036
|
`then restart ${escapeHtmlForTg(agentName)}.`
|
|
17882
18037
|
} else if (result.kind === 'no-refresh-token') {
|
|
17883
18038
|
html =
|
|
17884
18039
|
`⚠ Microsoft returned no refresh token (the account would expire in ~1h), ` +
|
|
17885
|
-
`so it was not registered. Try
|
|
18040
|
+
`so it was not registered. Try \`/connect microsoft\` again, or ` +
|
|
17886
18041
|
`connect from the host CLI.`
|
|
17887
18042
|
} else {
|
|
17888
18043
|
html =
|
|
17889
|
-
`❌
|
|
17890
|
-
|
|
17891
|
-
`from the host:
|
|
18044
|
+
`❌ **Connect failed:** ${escapeHtmlForTg(result.message)}\n\n` +
|
|
18045
|
+
`_Work/school accounts can't use the phone flow at /common — connect those ` +
|
|
18046
|
+
`from the host: \`switchroom auth microsoft account add <email>\`._`
|
|
17892
18047
|
}
|
|
17893
18048
|
await editMicrosoftConnectCard(flow.cardChatId, flow.cardMessageId, html)
|
|
17894
18049
|
}
|
|
@@ -17918,7 +18073,7 @@ bot.command('connect', async ctx => {
|
|
|
17918
18073
|
)
|
|
17919
18074
|
await switchroomReply(
|
|
17920
18075
|
ctx,
|
|
17921
|
-
`⚠️
|
|
18076
|
+
`⚠️ \`/connect\` links account credentials — it is **operator-private**. ` +
|
|
17922
18077
|
`Send it as a direct message to me from your operator account (a private chat where ` +
|
|
17923
18078
|
`your Telegram ID is on the access allowlist), not in a group or forum.`,
|
|
17924
18079
|
{ html: true },
|
|
@@ -17945,8 +18100,8 @@ bot.command('connect', async ctx => {
|
|
|
17945
18100
|
if (!isAuthAdmin({ isAdmin })) {
|
|
17946
18101
|
await switchroomReply(
|
|
17947
18102
|
ctx,
|
|
17948
|
-
|
|
17949
|
-
|
|
18103
|
+
`**Not authorized.** \`/connect\` requires this agent to have ` +
|
|
18104
|
+
`\`admin: true\` in switchroom.yaml.`,
|
|
17950
18105
|
{ html: true },
|
|
17951
18106
|
)
|
|
17952
18107
|
return
|
|
@@ -17955,7 +18110,7 @@ bot.command('connect', async ctx => {
|
|
|
17955
18110
|
if (arg === 'cancel') {
|
|
17956
18111
|
const existing = pendingMicrosoftConnectFlows.get(key)
|
|
17957
18112
|
if (!existing) {
|
|
17958
|
-
await switchroomReply(ctx, '
|
|
18113
|
+
await switchroomReply(ctx, '_No pending connect flow in this chat._', { html: true })
|
|
17959
18114
|
return
|
|
17960
18115
|
}
|
|
17961
18116
|
existing.cancelled = true
|
|
@@ -17967,9 +18122,9 @@ bot.command('connect', async ctx => {
|
|
|
17967
18122
|
if (arg !== '' && arg !== 'microsoft' && arg !== 'ms') {
|
|
17968
18123
|
await switchroomReply(
|
|
17969
18124
|
ctx,
|
|
17970
|
-
|
|
17971
|
-
`(Outlook / Office 365), or
|
|
17972
|
-
|
|
18125
|
+
`**Usage:** \`/connect microsoft\` to link a Microsoft account ` +
|
|
18126
|
+
`(Outlook / Office 365), or \`/connect cancel\`.\n` +
|
|
18127
|
+
`_Google accounts are connected from the host CLI._`,
|
|
17973
18128
|
{ html: true },
|
|
17974
18129
|
)
|
|
17975
18130
|
return
|
|
@@ -17978,8 +18133,8 @@ bot.command('connect', async ctx => {
|
|
|
17978
18133
|
if (pendingMicrosoftConnectFlows.has(key)) {
|
|
17979
18134
|
await switchroomReply(
|
|
17980
18135
|
ctx,
|
|
17981
|
-
'
|
|
17982
|
-
'or send
|
|
18136
|
+
'_A Microsoft connect flow is already in progress here. Finish it on your phone, ' +
|
|
18137
|
+
'or send \`/connect cancel\`._',
|
|
17983
18138
|
{ html: true },
|
|
17984
18139
|
)
|
|
17985
18140
|
return
|
|
@@ -17999,9 +18154,9 @@ bot.command('connect', async ctx => {
|
|
|
17999
18154
|
if (started.kind === 'byo-vault') {
|
|
18000
18155
|
await switchroomReply(
|
|
18001
18156
|
ctx,
|
|
18002
|
-
|
|
18003
|
-
`app (
|
|
18004
|
-
`Run
|
|
18157
|
+
`**Can't connect from chat:** this install uses a vaulted custom Microsoft ` +
|
|
18158
|
+
`app (\`${started.ref}\`) only the host CLI can read. ` +
|
|
18159
|
+
`Run \`switchroom auth microsoft account add <email>\` on the host.`,
|
|
18005
18160
|
{ html: true },
|
|
18006
18161
|
)
|
|
18007
18162
|
return
|
|
@@ -18009,7 +18164,7 @@ bot.command('connect', async ctx => {
|
|
|
18009
18164
|
if (started.kind === 'error') {
|
|
18010
18165
|
await switchroomReply(
|
|
18011
18166
|
ctx,
|
|
18012
|
-
|
|
18167
|
+
`**/connect failed:** ${escapeHtmlForTg(started.message)}`,
|
|
18013
18168
|
{ html: true },
|
|
18014
18169
|
)
|
|
18015
18170
|
return
|
|
@@ -18017,21 +18172,21 @@ bot.command('connect', async ctx => {
|
|
|
18017
18172
|
|
|
18018
18173
|
const appNote =
|
|
18019
18174
|
started.source === 'default'
|
|
18020
|
-
? "
|
|
18021
|
-
: '
|
|
18175
|
+
? "_Using switchroom's shipped Microsoft app._"
|
|
18176
|
+
: '_Using your configured Microsoft app._'
|
|
18022
18177
|
const keyboard = new InlineKeyboard()
|
|
18023
18178
|
.url('🔐 Sign in to Microsoft', started.device.verification_uri)
|
|
18024
18179
|
.row()
|
|
18025
18180
|
.text('✖ Cancel', `cn:cancel:${key}`)
|
|
18026
|
-
const sent = await ctx.
|
|
18027
|
-
|
|
18028
|
-
|
|
18029
|
-
`
|
|
18181
|
+
const sent = await ctx.replyWithRichMessage(
|
|
18182
|
+
richMessage(
|
|
18183
|
+
`🔗 **Connect a Microsoft account**\n\n` +
|
|
18184
|
+
`1. Tap **Sign in to Microsoft** below.\n` +
|
|
18185
|
+
`2. Enter this code: \`${started.device.user_code}\`\n` +
|
|
18030
18186
|
`3. Approve the requested permissions (Mail, Calendar, Files).\n\n` +
|
|
18031
18187
|
`I'll confirm here once it's connected (within ~15 min).\n${appNote}`,
|
|
18188
|
+
),
|
|
18032
18189
|
{
|
|
18033
|
-
parse_mode: 'HTML',
|
|
18034
|
-
link_preview_options: { is_disabled: true },
|
|
18035
18190
|
reply_markup: keyboard,
|
|
18036
18191
|
...(ctx.message?.message_thread_id != null
|
|
18037
18192
|
? { message_thread_id: ctx.message.message_thread_id }
|
|
@@ -18082,7 +18237,7 @@ bot.command("auth", async ctx => {
|
|
|
18082
18237
|
)
|
|
18083
18238
|
await switchroomReply(
|
|
18084
18239
|
ctx,
|
|
18085
|
-
`⚠️
|
|
18240
|
+
`⚠️ \`/auth\` manages account credentials — it is **operator-private**. Send it as a direct message to me from your operator account (a private chat where your Telegram ID is on the access allowlist), not in a group or forum.`,
|
|
18086
18241
|
{ html: true },
|
|
18087
18242
|
).catch(() => {})
|
|
18088
18243
|
}
|
|
@@ -18121,10 +18276,10 @@ bot.command("auth", async ctx => {
|
|
|
18121
18276
|
if (!isAuthAdmin({ isAdmin })) {
|
|
18122
18277
|
await switchroomReply(
|
|
18123
18278
|
ctx,
|
|
18124
|
-
|
|
18125
|
-
`Set
|
|
18126
|
-
`(the same flag that gates
|
|
18127
|
-
|
|
18279
|
+
`**Not authorized.** \`/auth ${parsed.kind}\` is admin-only.\n` +
|
|
18280
|
+
`Set \`admin: true\` on this agent in switchroom.yaml to unlock ` +
|
|
18281
|
+
`(the same flag that gates \`/agents\`, \`/restart\`, ` +
|
|
18282
|
+
`\`/update\` etc.).`,
|
|
18128
18283
|
{ html: true },
|
|
18129
18284
|
)
|
|
18130
18285
|
return
|
|
@@ -18137,7 +18292,7 @@ bot.command("auth", async ctx => {
|
|
|
18137
18292
|
if (parsed.kind === 'cancel') {
|
|
18138
18293
|
const existing = pendingAuthAddFlows.get(authAddKey)
|
|
18139
18294
|
if (!existing) {
|
|
18140
|
-
await switchroomReply(ctx, "
|
|
18295
|
+
await switchroomReply(ctx, "_No pending \`/auth add\` flow in this chat._", { html: true })
|
|
18141
18296
|
return
|
|
18142
18297
|
}
|
|
18143
18298
|
cancelAccountAuthSession(existing)
|
|
@@ -18149,8 +18304,8 @@ bot.command("auth", async ctx => {
|
|
|
18149
18304
|
if (pendingAuthAddFlows.has(authAddKey)) {
|
|
18150
18305
|
await switchroomReply(
|
|
18151
18306
|
ctx,
|
|
18152
|
-
"
|
|
18153
|
-
"Finish the paste, or send
|
|
18307
|
+
"_An \`/auth add\` flow is already in progress for this chat. " +
|
|
18308
|
+
"Finish the paste, or send \`/auth cancel\` to abort._",
|
|
18154
18309
|
{ html: true },
|
|
18155
18310
|
)
|
|
18156
18311
|
return
|
|
@@ -18166,17 +18321,17 @@ bot.command("auth", async ctx => {
|
|
|
18166
18321
|
})
|
|
18167
18322
|
await switchroomReply(
|
|
18168
18323
|
ctx,
|
|
18169
|
-
|
|
18324
|
+
`**Adding account** \`${parsed.label}\`\n\n` +
|
|
18170
18325
|
`1. Open this URL on your phone:\n${loginUrl}\n\n` +
|
|
18171
18326
|
`2. Log into Anthropic, copy the code Claude shows.\n` +
|
|
18172
18327
|
`3. Paste it back here.\n\n` +
|
|
18173
|
-
`Send
|
|
18328
|
+
`Send \`/auth cancel\` to abort.`,
|
|
18174
18329
|
{ html: true },
|
|
18175
18330
|
)
|
|
18176
18331
|
} catch (err) {
|
|
18177
18332
|
await switchroomReply(
|
|
18178
18333
|
ctx,
|
|
18179
|
-
|
|
18334
|
+
`**/auth add failed:** ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
|
|
18180
18335
|
{ html: true },
|
|
18181
18336
|
)
|
|
18182
18337
|
}
|
|
@@ -18185,7 +18340,7 @@ bot.command("auth", async ctx => {
|
|
|
18185
18340
|
|
|
18186
18341
|
const client = await getAuthBrokerClient(currentAgent)
|
|
18187
18342
|
if (!client) {
|
|
18188
|
-
await switchroomReply(ctx, "
|
|
18343
|
+
await switchroomReply(ctx, "**/auth unavailable:** auth-broker client is not loaded (post-RFC-H rewire in progress?).", { html: true })
|
|
18189
18344
|
return
|
|
18190
18345
|
}
|
|
18191
18346
|
const reply = await handleAuthCommand(parsed, {
|
|
@@ -18437,7 +18592,7 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
|
|
|
18437
18592
|
return
|
|
18438
18593
|
}
|
|
18439
18594
|
if (result.kind === 'error') {
|
|
18440
|
-
await switchroomReply(ctx,
|
|
18595
|
+
await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
|
|
18441
18596
|
return
|
|
18442
18597
|
}
|
|
18443
18598
|
// Write the token to the agent's .vault-token file — same flow as the
|
|
@@ -18451,10 +18606,10 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
|
|
|
18451
18606
|
} catch (err) {
|
|
18452
18607
|
await switchroomReply(
|
|
18453
18608
|
ctx,
|
|
18454
|
-
|
|
18609
|
+
`**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
|
|
18455
18610
|
`${escapeHtmlForTg(String(err))}\n` +
|
|
18456
|
-
|
|
18457
|
-
`--keys ${escapeHtmlForTg(keyName)} --duration 30d
|
|
18611
|
+
`_Recover with: \`switchroom vault grant ${escapeHtmlForTg(agentName)} ` +
|
|
18612
|
+
`--keys ${escapeHtmlForTg(keyName)} --duration 30d\` on the host._`,
|
|
18458
18613
|
{ html: true },
|
|
18459
18614
|
)
|
|
18460
18615
|
return
|
|
@@ -18479,14 +18634,13 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
|
|
|
18479
18634
|
? escapeHtmlForTg(sourceMsg.text)
|
|
18480
18635
|
: ''
|
|
18481
18636
|
const statusLine =
|
|
18482
|
-
`\n\n✅
|
|
18483
|
-
|
|
18484
|
-
`(grant
|
|
18637
|
+
`\n\n✅ **${escapeHtmlForTg(agentName)}** granted read access to ` +
|
|
18638
|
+
`\`${keyName}\` for 30 days ` +
|
|
18639
|
+
`(grant \`${id}\`). ` +
|
|
18485
18640
|
`Re-run /vault audit to act on remaining denials.`
|
|
18486
18641
|
await finalizeCallback(ctx, {
|
|
18487
18642
|
ackText: '✅ Grant minted',
|
|
18488
18643
|
newText: baseText ? `${baseText}${statusLine}` : statusLine,
|
|
18489
|
-
parseMode: 'HTML',
|
|
18490
18644
|
// No synthInbound — operator-only flow. The granted agent picks
|
|
18491
18645
|
// up the token via .vault-token file on next CLI invocation; no
|
|
18492
18646
|
// turn-wake needed.
|
|
@@ -18553,11 +18707,11 @@ async function performVaultAccessApproval(
|
|
|
18553
18707
|
.editMessageText(
|
|
18554
18708
|
pending.chat_id,
|
|
18555
18709
|
pending.card_message_id,
|
|
18556
|
-
`ℹ️
|
|
18557
|
-
|
|
18558
|
-
|
|
18559
|
-
`The agent can read it directly
|
|
18560
|
-
{
|
|
18710
|
+
`ℹ️ **${escapeHtmlForTg(pending.agent)}** already has standing-ACL access to ` +
|
|
18711
|
+
`\`${pending.key}\` (schedule.secrets[]). ` +
|
|
18712
|
+
`**No grant minted** — a token would shadow the standing ACL. ` +
|
|
18713
|
+
richMessage(`The agent can read it directly.`),
|
|
18714
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
18561
18715
|
)
|
|
18562
18716
|
.catch(() => {})
|
|
18563
18717
|
}
|
|
@@ -18651,8 +18805,8 @@ async function performVaultAccessApproval(
|
|
|
18651
18805
|
.editMessageText(
|
|
18652
18806
|
pending.chat_id,
|
|
18653
18807
|
pending.card_message_id,
|
|
18654
|
-
|
|
18655
|
-
{
|
|
18808
|
+
richMessage(`**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`),
|
|
18809
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
18656
18810
|
)
|
|
18657
18811
|
.catch(() => {})
|
|
18658
18812
|
}
|
|
@@ -18667,10 +18821,10 @@ async function performVaultAccessApproval(
|
|
|
18667
18821
|
} catch (err) {
|
|
18668
18822
|
await switchroomReply(
|
|
18669
18823
|
ctx,
|
|
18670
|
-
|
|
18824
|
+
`**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
|
|
18671
18825
|
`${escapeHtmlForTg(String(err))}\n` +
|
|
18672
|
-
|
|
18673
|
-
`--keys ${escapeHtmlForTg(pending.key)} --duration ${Math.round(pending.ttl_seconds / 86400)}d
|
|
18826
|
+
`_Recover with: \`switchroom vault grant ${escapeHtmlForTg(pending.agent)} ` +
|
|
18827
|
+
`--keys ${escapeHtmlForTg(pending.key)} --duration ${Math.round(pending.ttl_seconds / 86400)}d\` on the host._`,
|
|
18674
18828
|
{ html: true },
|
|
18675
18829
|
)
|
|
18676
18830
|
return
|
|
@@ -18681,16 +18835,16 @@ async function performVaultAccessApproval(
|
|
|
18681
18835
|
const days = Math.round(pending.ttl_seconds / 86400)
|
|
18682
18836
|
const footer =
|
|
18683
18837
|
VAULT_APPROVAL_AUTH_MODE === 'telegram-id'
|
|
18684
|
-
? `\
|
|
18838
|
+
? `\n_Approver verified by Telegram identity — broker auto-unlocked at startup._`
|
|
18685
18839
|
: ''
|
|
18686
18840
|
await ctx.api
|
|
18687
18841
|
.editMessageText(
|
|
18688
18842
|
pending.chat_id,
|
|
18689
18843
|
pending.card_message_id,
|
|
18690
|
-
`✅ Granted
|
|
18691
|
-
|
|
18692
|
-
`(grant
|
|
18693
|
-
{
|
|
18844
|
+
`✅ Granted **${escapeHtmlForTg(pending.agent)}** ${pending.scope} access to ` +
|
|
18845
|
+
`\`${pending.key}\` for ${days}d. ` +
|
|
18846
|
+
richMessage(`(grant \`${id}\`)` + footer),
|
|
18847
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
18694
18848
|
)
|
|
18695
18849
|
.catch(() => {})
|
|
18696
18850
|
}
|
|
@@ -18734,6 +18888,97 @@ async function performVaultAccessApproval(
|
|
|
18734
18888
|
)
|
|
18735
18889
|
}
|
|
18736
18890
|
|
|
18891
|
+
/**
|
|
18892
|
+
* #2670 — handle an Approve / Dismiss tap on a one-tap skill-improvement
|
|
18893
|
+
* proposal card.
|
|
18894
|
+
*
|
|
18895
|
+
* Approve → mark the proposal approved + inject a synthetic
|
|
18896
|
+
* `skill_proposal_apply` turn instructing the live agent to
|
|
18897
|
+
* write the stored draft through `skill_*_personal` (so the
|
|
18898
|
+
* secret-scan pipeline runs; agent never self-applies).
|
|
18899
|
+
* Dismiss → mark rejected + write a rejection fingerprint so the weekly
|
|
18900
|
+
* synthesis cron doesn't re-propose it.
|
|
18901
|
+
*/
|
|
18902
|
+
async function handleSkillProposalCallback(ctx: Context, data: string): Promise<void> {
|
|
18903
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
18904
|
+
const access = loadAccess()
|
|
18905
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
18906
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
18907
|
+
return
|
|
18908
|
+
}
|
|
18909
|
+
const parsed = parseSkillProposalCallback(data)
|
|
18910
|
+
if (parsed == null) {
|
|
18911
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
18912
|
+
return
|
|
18913
|
+
}
|
|
18914
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
18915
|
+
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
18916
|
+
if (stateDir == null || stateDir.length === 0) {
|
|
18917
|
+
await ctx.answerCallbackQuery({ text: 'State dir unset — cannot apply.' }).catch(() => {})
|
|
18918
|
+
return
|
|
18919
|
+
}
|
|
18920
|
+
const proposal = getSkillProposal(stateDir, parsed.id)
|
|
18921
|
+
if (proposal == null) {
|
|
18922
|
+
await ctx.answerCallbackQuery({ text: 'Proposal expired or already actioned.' }).catch(() => {})
|
|
18923
|
+
if (ctx.callbackQuery?.message) {
|
|
18924
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
18925
|
+
}
|
|
18926
|
+
return
|
|
18927
|
+
}
|
|
18928
|
+
if (proposal.status !== 'pending') {
|
|
18929
|
+
await ctx.answerCallbackQuery({ text: `Already ${proposal.status}.` }).catch(() => {})
|
|
18930
|
+
if (ctx.callbackQuery?.message) {
|
|
18931
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
18932
|
+
}
|
|
18933
|
+
return
|
|
18934
|
+
}
|
|
18935
|
+
|
|
18936
|
+
const cbChatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
|
|
18937
|
+
const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
|
|
18938
|
+
|
|
18939
|
+
if (parsed.action === 'deny') {
|
|
18940
|
+
setSkillProposalStatus(stateDir, parsed.id, 'rejected')
|
|
18941
|
+
await ctx.answerCallbackQuery({ text: '🚫 Dismissed — won’t be proposed again.' }).catch(() => {})
|
|
18942
|
+
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
18943
|
+
await ctx
|
|
18944
|
+
.editMessageText(
|
|
18945
|
+
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
|
|
18946
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
18947
|
+
)
|
|
18948
|
+
.catch(() => {})
|
|
18949
|
+
}
|
|
18950
|
+
return
|
|
18951
|
+
}
|
|
18952
|
+
|
|
18953
|
+
// Approve.
|
|
18954
|
+
setSkillProposalStatus(stateDir, parsed.id, 'approved')
|
|
18955
|
+
const synthetic = buildSkillProposalApplyInbound({
|
|
18956
|
+
ctx: {
|
|
18957
|
+
agent,
|
|
18958
|
+
chat_id: cbChatId,
|
|
18959
|
+
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
18960
|
+
},
|
|
18961
|
+
proposalId: proposal.id,
|
|
18962
|
+
skillSlug: proposal.skill_slug,
|
|
18963
|
+
isNew: proposal.is_new,
|
|
18964
|
+
operatorId: senderId,
|
|
18965
|
+
})
|
|
18966
|
+
const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
|
|
18967
|
+
await ctx.answerCallbackQuery({ text: '✅ Applying the skill…' }).catch(() => {})
|
|
18968
|
+
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
18969
|
+
await ctx
|
|
18970
|
+
.editMessageText(
|
|
18971
|
+
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n✅ <i>Approved — applying.</i>`,
|
|
18972
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
18973
|
+
)
|
|
18974
|
+
.catch(() => {})
|
|
18975
|
+
}
|
|
18976
|
+
process.stderr.write(
|
|
18977
|
+
`telegram gateway: skill_proposal_apply injection agent=${agent} ` +
|
|
18978
|
+
`proposal=${proposal.id} slug=${proposal.skill_slug} delivered=${delivered}\n`,
|
|
18979
|
+
)
|
|
18980
|
+
}
|
|
18981
|
+
|
|
18737
18982
|
async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
|
|
18738
18983
|
const senderId = String(ctx.from?.id ?? '')
|
|
18739
18984
|
const access = loadAccess()
|
|
@@ -18756,8 +19001,8 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
18756
19001
|
.editMessageText(
|
|
18757
19002
|
ctx.callbackQuery.message.chat.id,
|
|
18758
19003
|
ctx.callbackQuery.message.message_id,
|
|
18759
|
-
'⌛
|
|
18760
|
-
{
|
|
19004
|
+
richMessage('⌛ _This access-request card expired before you tapped. Ask the agent to re-issue if the need still stands._'),
|
|
19005
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
18761
19006
|
)
|
|
18762
19007
|
.catch(() => {})
|
|
18763
19008
|
}
|
|
@@ -18772,8 +19017,8 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
18772
19017
|
.editMessageText(
|
|
18773
19018
|
pending.chat_id,
|
|
18774
19019
|
pending.card_message_id,
|
|
18775
|
-
`🚫
|
|
18776
|
-
{
|
|
19020
|
+
richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}** will not get access to \`${pending.key}\`._`),
|
|
19021
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
18777
19022
|
)
|
|
18778
19023
|
.catch(() => {})
|
|
18779
19024
|
}
|
|
@@ -18837,8 +19082,8 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
18837
19082
|
.editMessageText(
|
|
18838
19083
|
pending.chat_id,
|
|
18839
19084
|
pending.card_message_id,
|
|
18840
|
-
`✅ Approved by @${escapeHtmlForTg(username)} — minting
|
|
18841
|
-
{
|
|
19085
|
+
richMessage(`✅ Approved by @${escapeHtmlForTg(username)} — minting…`),
|
|
19086
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
18842
19087
|
)
|
|
18843
19088
|
.catch(() => {})
|
|
18844
19089
|
}
|
|
@@ -18894,13 +19139,13 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
18894
19139
|
pending.chat_id,
|
|
18895
19140
|
pending.card_message_id,
|
|
18896
19141
|
joiningBatch
|
|
18897
|
-
? `🔐
|
|
19142
|
+
? `🔐 **Queued behind an earlier card.** Type your passphrase as your next message — it covers **${items.length}** pending approvals in this chat (one entry mints all grants, no re-type per card).`
|
|
18898
19143
|
: isAdminOnly
|
|
18899
|
-
? `🔒
|
|
18900
|
-
|
|
18901
|
-
: `🔐
|
|
18902
|
-
|
|
18903
|
-
{
|
|
19144
|
+
? `🔒 **Admin-only credential.** \`${pending.key}\` requires your vault passphrase to grant — reply with it as your next message and we'll mint the grant for **${escapeHtmlForTg(pending.agent)}**, then delete the passphrase message.\n\n` +
|
|
19145
|
+
`_The passphrase is what proves it's you: an agent can never mint this key on its own._`
|
|
19146
|
+
: `🔐 **Vault is locked.** Reply with your passphrase as your next message — we'll unlock, mint the grant for **${escapeHtmlForTg(pending.agent)}**, and delete the passphrase message in one step.\n\n` +
|
|
19147
|
+
richMessage(`_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches._`),
|
|
19148
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
18904
19149
|
)
|
|
18905
19150
|
.catch(() => {})
|
|
18906
19151
|
return
|
|
@@ -18937,8 +19182,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
18937
19182
|
.editMessageText(
|
|
18938
19183
|
ctx.callbackQuery.message.chat.id,
|
|
18939
19184
|
ctx.callbackQuery.message.message_id,
|
|
18940
|
-
'⌛
|
|
18941
|
-
{
|
|
19185
|
+
richMessage('⌛ _This vault-save card expired before you tapped. Ask the agent to re-issue if you still want to save._'),
|
|
19186
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
18942
19187
|
)
|
|
18943
19188
|
.catch(() => {})
|
|
18944
19189
|
}
|
|
@@ -18953,8 +19198,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
18953
19198
|
.editMessageText(
|
|
18954
19199
|
pending.chat_id,
|
|
18955
19200
|
pending.card_message_id,
|
|
18956
|
-
`🚫
|
|
18957
|
-
{
|
|
19201
|
+
richMessage(`🚫 _Discarded. The secret was not written to the vault._`),
|
|
19202
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
18958
19203
|
)
|
|
18959
19204
|
.catch(() => {})
|
|
18960
19205
|
}
|
|
@@ -19003,12 +19248,11 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
19003
19248
|
? escapeHtmlForTg(sourceMsg.text)
|
|
19004
19249
|
: ''
|
|
19005
19250
|
const statusLine =
|
|
19006
|
-
`\n\n✏️
|
|
19007
|
-
`The current proposed key is
|
|
19251
|
+
`\n\n✏️ **Rename mode** — send the new key name as your next message. ` +
|
|
19252
|
+
`The current proposed key is \`${pending.key}\`.`
|
|
19008
19253
|
await finalizeCallback(ctx, {
|
|
19009
19254
|
ackText: 'Send the new key name as your next message.',
|
|
19010
19255
|
newText: baseText ? `${baseText}${statusLine}` : statusLine,
|
|
19011
|
-
parseMode: 'HTML',
|
|
19012
19256
|
})
|
|
19013
19257
|
return
|
|
19014
19258
|
}
|
|
@@ -19047,8 +19291,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
19047
19291
|
.editMessageText(
|
|
19048
19292
|
pending.chat_id,
|
|
19049
19293
|
pending.card_message_id,
|
|
19050
|
-
`🔒
|
|
19051
|
-
{
|
|
19294
|
+
richMessage(`🔒 **Passphrase not cached for this chat.** Run \`/vault unlock\` (or any /vault command) to cache it, then tap Save again on the next card.`),
|
|
19295
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
19052
19296
|
)
|
|
19053
19297
|
.catch(() => {})
|
|
19054
19298
|
}
|
|
@@ -19069,14 +19313,14 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
19069
19313
|
const rendered = renderVaultCliError(parsed, { verb: 'save', key: pending.key })
|
|
19070
19314
|
const body = rendered.suppressRaw
|
|
19071
19315
|
? rendered.html
|
|
19072
|
-
: `⚠️ vault write failed:\n
|
|
19316
|
+
: `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
|
|
19073
19317
|
if (pending.card_message_id != null) {
|
|
19074
19318
|
await ctx.api
|
|
19075
19319
|
.editMessageText(
|
|
19076
19320
|
pending.chat_id,
|
|
19077
19321
|
pending.card_message_id,
|
|
19078
|
-
`${body}\n\
|
|
19079
|
-
{
|
|
19322
|
+
richMessage(`${body}\n\n_Tap a fresh card after fixing the underlying issue._`),
|
|
19323
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
19080
19324
|
)
|
|
19081
19325
|
.catch(() => {})
|
|
19082
19326
|
}
|
|
@@ -19114,8 +19358,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
19114
19358
|
.editMessageText(
|
|
19115
19359
|
pending.chat_id,
|
|
19116
19360
|
pending.card_message_id,
|
|
19117
|
-
`✅ saved as
|
|
19118
|
-
{
|
|
19361
|
+
richMessage(`✅ saved as \`vault:${escapeHtmlForTg(pending.key)}\` (masked: \`${escapeHtmlForTg(maskToken(pending.value))}\`)\n_The agent can now reference this as \`vault:${escapeHtmlForTg(pending.key)}\`._`),
|
|
19362
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
19119
19363
|
)
|
|
19120
19364
|
.catch(() => {})
|
|
19121
19365
|
}
|
|
@@ -19238,8 +19482,8 @@ async function handleVaultDeferCallback(ctx: Context, data: string): Promise<voi
|
|
|
19238
19482
|
await ctx.answerCallbackQuery({ text: 'Send your passphrase…' }).catch(() => {})
|
|
19239
19483
|
await ctx
|
|
19240
19484
|
.editMessageText(
|
|
19241
|
-
'🔐 Send your vault passphrase as your next message — we\'ll save the held secret automatically and delete the passphrase message.',
|
|
19242
|
-
{
|
|
19485
|
+
richMessage('🔐 Send your vault passphrase as your next message — we\'ll save the held secret automatically and delete the passphrase message.'),
|
|
19486
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
19243
19487
|
)
|
|
19244
19488
|
.catch(() => {})
|
|
19245
19489
|
return
|
|
@@ -19324,7 +19568,7 @@ async function startGrantWizardStep1(ctx: Context, chatId: string): Promise<void
|
|
|
19324
19568
|
return
|
|
19325
19569
|
}
|
|
19326
19570
|
const kb = buildGrantAgentKeyboard(agents)
|
|
19327
|
-
const sent = await switchroomReply(ctx, '
|
|
19571
|
+
const sent = await switchroomReply(ctx, '**Grant capability token — Step 1/3**\n\nWhich agent?', { html: true, reply_markup: kb })
|
|
19328
19572
|
const wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
19329
19573
|
pendingVaultOps.set(chatId, {
|
|
19330
19574
|
kind: 'grant-wizard',
|
|
@@ -19343,16 +19587,16 @@ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wiz
|
|
|
19343
19587
|
return
|
|
19344
19588
|
}
|
|
19345
19589
|
if (keys.length === 0) {
|
|
19346
|
-
await switchroomReply(ctx, '⚠️ No vault keys found. Add secrets first with
|
|
19590
|
+
await switchroomReply(ctx, '⚠️ No vault keys found. Add secrets first with \`/vault set\`.', { html: true })
|
|
19347
19591
|
pendingVaultOps.delete(chatId)
|
|
19348
19592
|
return
|
|
19349
19593
|
}
|
|
19350
19594
|
const selected = new Set<string>()
|
|
19351
19595
|
const kb = buildGrantKeysKeyboard(keys, selected)
|
|
19352
|
-
const text =
|
|
19596
|
+
const text = `**Grant capability token — Step 2/3**\n\nWhich keys for \`${agent}\`?\n_Tap to toggle; tap Continue when done._`
|
|
19353
19597
|
if (wizardMsgId != null) {
|
|
19354
19598
|
// allow-raw-bot-api: vault grant wizard step 2/3; already .catch-swallows, tap-driven UI re-renders on retry
|
|
19355
|
-
await ctx.api.editMessageText(chatId, wizardMsgId, text, {
|
|
19599
|
+
await ctx.api.editMessageText(chatId, wizardMsgId, richMessage(text), { reply_markup: kb }).catch(() => {})
|
|
19356
19600
|
} else {
|
|
19357
19601
|
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
19358
19602
|
wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
@@ -19371,12 +19615,12 @@ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wiz
|
|
|
19371
19615
|
/** Advance grant wizard to step 3 (pick duration). */
|
|
19372
19616
|
async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
|
|
19373
19617
|
const kb = buildGrantDurationKeyboard()
|
|
19374
|
-
const keyList = state.selectedKeys!.map(k => `•
|
|
19375
|
-
const text =
|
|
19618
|
+
const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
|
|
19619
|
+
const text = `**Grant capability token — Step 3/3**\n\nKeys for \`${state.agent!}\`:\n${keyList}\n\nHow long should this grant be valid?`
|
|
19376
19620
|
const msgId = state.wizardMsgId
|
|
19377
19621
|
if (msgId != null) {
|
|
19378
19622
|
// allow-raw-bot-api: vault grant wizard step 3/3 (TTL select); already .catch-swallows, tap-driven UI re-renders on retry
|
|
19379
|
-
await ctx.api.editMessageText(chatId, msgId, text, {
|
|
19623
|
+
await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
|
|
19380
19624
|
} else {
|
|
19381
19625
|
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
19382
19626
|
state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
@@ -19388,20 +19632,20 @@ async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<Pen
|
|
|
19388
19632
|
async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
|
|
19389
19633
|
const kb = buildGrantConfirmKeyboard()
|
|
19390
19634
|
const expiresLabel = formatGrantExpiry(state.ttlSeconds!)
|
|
19391
|
-
const keyList = state.selectedKeys!.map(k => `•
|
|
19635
|
+
const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
|
|
19392
19636
|
const text = [
|
|
19393
|
-
'
|
|
19637
|
+
'**Confirm grant**',
|
|
19394
19638
|
'',
|
|
19395
|
-
`Agent:
|
|
19639
|
+
`Agent: \`${state.agent!}\``,
|
|
19396
19640
|
`Keys:\n${keyList}`,
|
|
19397
|
-
`Expires:
|
|
19641
|
+
`Expires: **${escapeHtmlForTg(expiresLabel)}**`,
|
|
19398
19642
|
'',
|
|
19399
|
-
'Tap
|
|
19643
|
+
'Tap **Generate** to mint the token.',
|
|
19400
19644
|
].join('\n')
|
|
19401
19645
|
const msgId = state.wizardMsgId
|
|
19402
19646
|
if (msgId != null) {
|
|
19403
19647
|
// allow-raw-bot-api: vault grant wizard confirm step; already .catch-swallows, tap-driven UI re-renders on retry
|
|
19404
|
-
await ctx.api.editMessageText(chatId, msgId, text, {
|
|
19648
|
+
await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
|
|
19405
19649
|
} else {
|
|
19406
19650
|
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
19407
19651
|
state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
@@ -19460,7 +19704,7 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
|
|
|
19460
19704
|
return
|
|
19461
19705
|
}
|
|
19462
19706
|
if (result.kind === 'error') {
|
|
19463
|
-
await switchroomReply(ctx,
|
|
19707
|
+
await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
|
|
19464
19708
|
return
|
|
19465
19709
|
}
|
|
19466
19710
|
// Write token to the agent's .vault-token file
|
|
@@ -19470,7 +19714,7 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
|
|
|
19470
19714
|
mkdirSync(join(homedir(), '.switchroom', 'agents', state.agent!), { recursive: true })
|
|
19471
19715
|
writeFileSync(tokenPath, token, { mode: 0o600 })
|
|
19472
19716
|
} catch (err) {
|
|
19473
|
-
await switchroomReply(ctx,
|
|
19717
|
+
await switchroomReply(ctx, `**Grant created but token write failed:** ${escapeHtmlForTg(String(err))}`, { html: true })
|
|
19474
19718
|
return
|
|
19475
19719
|
}
|
|
19476
19720
|
// Collapse wizard message to just the outcome.
|
|
@@ -19481,12 +19725,11 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
|
|
|
19481
19725
|
// Strip the keyboard atomically with the success text via the
|
|
19482
19726
|
// finalizeCallback helper.
|
|
19483
19727
|
const msgId = state.wizardMsgId
|
|
19484
|
-
const successText = `✅ Grant
|
|
19728
|
+
const successText = `✅ Grant \`${id}\` created. Written to \`~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token\``
|
|
19485
19729
|
if (msgId != null) {
|
|
19486
19730
|
await finalizeCallback(ctx, {
|
|
19487
19731
|
ackText: '✅ Grant created',
|
|
19488
19732
|
newText: successText,
|
|
19489
|
-
parseMode: 'HTML',
|
|
19490
19733
|
// No synthInbound — operator-only flow.
|
|
19491
19734
|
})
|
|
19492
19735
|
} else {
|
|
@@ -19537,15 +19780,14 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
|
|
|
19537
19780
|
return
|
|
19538
19781
|
}
|
|
19539
19782
|
const cardText =
|
|
19540
|
-
`🗑 Revoke
|
|
19541
|
-
`Agent:
|
|
19542
|
-
`Keys:
|
|
19783
|
+
`🗑 Revoke \`${grantId}\`?\n` +
|
|
19784
|
+
`Agent: **${escapeHtmlForTg(grant.agent_slug)}**\n` +
|
|
19785
|
+
`Keys: \`${escapeHtmlForTg(grant.key_allow.join(', '))}\``
|
|
19543
19786
|
const confirmKeyboard = new InlineKeyboard()
|
|
19544
19787
|
.text('✅ Confirm Revoke', `vg:confirm:${grantId}`)
|
|
19545
19788
|
.text('❌ Cancel', `vg:cancel:${grantId}`)
|
|
19546
19789
|
await ctx.answerCallbackQuery().catch(() => {})
|
|
19547
|
-
await ctx.editMessageText(cardText, {
|
|
19548
|
-
parse_mode: 'HTML',
|
|
19790
|
+
await ctx.editMessageText(richMessage(cardText), {
|
|
19549
19791
|
reply_markup: confirmKeyboard,
|
|
19550
19792
|
}).catch(async () => {
|
|
19551
19793
|
const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
|
|
@@ -19554,8 +19796,7 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
|
|
|
19554
19796
|
// #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND.
|
|
19555
19797
|
await swallowingApiCall(
|
|
19556
19798
|
() =>
|
|
19557
|
-
bot.api.
|
|
19558
|
-
parse_mode: 'HTML',
|
|
19799
|
+
bot.api.sendRichMessage(chatId, richMessage(cardText), {
|
|
19559
19800
|
reply_markup: confirmKeyboard,
|
|
19560
19801
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
19561
19802
|
}),
|
|
@@ -19584,8 +19825,8 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
|
|
|
19584
19825
|
}
|
|
19585
19826
|
await ctx.answerCallbackQuery({ text: '✅ Revoked' }).catch(() => {})
|
|
19586
19827
|
await ctx.editMessageText(
|
|
19587
|
-
`✅ Grant
|
|
19588
|
-
{
|
|
19828
|
+
richMessage(`✅ Grant \`${grantId}\` revoked. Token file removed.`),
|
|
19829
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
19589
19830
|
).catch(() => {})
|
|
19590
19831
|
return
|
|
19591
19832
|
}
|
|
@@ -19700,8 +19941,8 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
|
|
|
19700
19941
|
// `.catch(() => {})` would swallow the failure silently — same
|
|
19701
19942
|
// hazard PR #1158 caught on the operator-event card.
|
|
19702
19943
|
await ctx.editMessageText(
|
|
19703
|
-
escapeHtmlForTg(msg.text) + '\n\
|
|
19704
|
-
{
|
|
19944
|
+
richMessage(escapeHtmlForTg(msg.text) + '\n\n_Send a duration like \`30d\` or \`12h\`:_'),
|
|
19945
|
+
{ reply_markup: buildGrantDurationKeyboard() },
|
|
19705
19946
|
).catch(() => {})
|
|
19706
19947
|
}
|
|
19707
19948
|
await ackSilently()
|
|
@@ -19803,15 +20044,14 @@ async function executeDeferredSecretSave(
|
|
|
19803
20044
|
const rendered = renderVaultCliError(parsed, { verb: "save", key: slug })
|
|
19804
20045
|
const body = rendered.suppressRaw
|
|
19805
20046
|
? rendered.html
|
|
19806
|
-
: `⚠️ vault write failed:\n
|
|
20047
|
+
: `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
|
|
19807
20048
|
if (cardMessageId != null) {
|
|
19808
20049
|
await ctx.api
|
|
19809
20050
|
.editMessageText(
|
|
19810
20051
|
deferred.chat_id,
|
|
19811
20052
|
cardMessageId,
|
|
19812
|
-
`${body}\n\nRe-tap to retry
|
|
20053
|
+
richMessage(`${body}\n\nRe-tap to retry.`),
|
|
19813
20054
|
{
|
|
19814
|
-
parse_mode: 'HTML',
|
|
19815
20055
|
reply_markup: buildDeferredSecretKeyboard(deferKey).inline_keyboard.length > 0
|
|
19816
20056
|
? buildDeferredSecretKeyboard(deferKey)
|
|
19817
20057
|
: undefined,
|
|
@@ -19829,8 +20069,8 @@ async function executeDeferredSecretSave(
|
|
|
19829
20069
|
.editMessageText(
|
|
19830
20070
|
deferred.chat_id,
|
|
19831
20071
|
cardMessageId,
|
|
19832
|
-
`✅ stored as
|
|
19833
|
-
{
|
|
20072
|
+
richMessage(`✅ stored as \`vault:${slug}\` (masked: \`${masked}\`)\n\nReply \`rename NEW_NAME\` to relabel.`),
|
|
20073
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
19834
20074
|
)
|
|
19835
20075
|
.catch(() => {})
|
|
19836
20076
|
}
|
|
@@ -19907,11 +20147,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
19907
20147
|
case 'dismiss': {
|
|
19908
20148
|
// #1150 audit P1: was strip-only. Now appends a status line so
|
|
19909
20149
|
// scrollback shows the dismissal.
|
|
19910
|
-
const status = `\n\n✗
|
|
20150
|
+
const status = `\n\n✗ _Dismissed by operator._`
|
|
19911
20151
|
await finalizeCallback(ctx, {
|
|
19912
20152
|
ackText: 'Dismissed',
|
|
19913
20153
|
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
19914
|
-
parseMode: 'HTML',
|
|
19915
20154
|
// No synthInbound — dismiss is operator-only, no model in loop.
|
|
19916
20155
|
})
|
|
19917
20156
|
return
|
|
@@ -19922,11 +20161,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
19922
20161
|
// #1150 audit P1: was reply + editMessageReplyMarkup (two
|
|
19923
20162
|
// separate edits). Atomic via finalizeCallback now — the
|
|
19924
20163
|
// status line is the announcement, no separate reply needed.
|
|
19925
|
-
const status = `\n\n🔄
|
|
20164
|
+
const status = `\n\n🔄 _**${escapeHtmlForTg(agent)}** restart requested by operator._`
|
|
19926
20165
|
await finalizeCallback(ctx, {
|
|
19927
20166
|
ackText: `Restarting ${agent}…`,
|
|
19928
20167
|
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
19929
|
-
parseMode: 'HTML',
|
|
19930
20168
|
})
|
|
19931
20169
|
} else {
|
|
19932
20170
|
// Failure-path: leave the keyboard tappable so the operator
|
|
@@ -19936,11 +20174,9 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
19936
20174
|
const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
19937
20175
|
const detail = isDocker
|
|
19938
20176
|
? `cross-agent restart is not supported under docker. ` +
|
|
19939
|
-
`Restart from the host:
|
|
20177
|
+
`Restart from the host: \`docker compose -p switchroom restart agent-${agent}\`.`
|
|
19940
20178
|
: 'restart trigger failed'
|
|
19941
|
-
await ctx.
|
|
19942
|
-
parse_mode: 'HTML',
|
|
19943
|
-
})
|
|
20179
|
+
await ctx.replyWithRichMessage(richMessage(`**Restart failed for ${agent}:** ${detail}`))
|
|
19944
20180
|
}
|
|
19945
20181
|
return
|
|
19946
20182
|
}
|
|
@@ -19951,11 +20187,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
19951
20187
|
// for the login URL state. Strip the keyboard and append a status
|
|
19952
20188
|
// line; the new reauth-flow's own messages appear below the
|
|
19953
20189
|
// collapsed card.
|
|
19954
|
-
const status = `\n\n🔐
|
|
20190
|
+
const status = `\n\n🔐 _Reauth started for **${escapeHtmlForTg(agent)}** — follow the login URL below._`
|
|
19955
20191
|
await finalizeCallback(ctx, {
|
|
19956
20192
|
ackText: `Starting reauth for ${agent}…`,
|
|
19957
20193
|
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
19958
|
-
parseMode: 'HTML',
|
|
19959
20194
|
synthInbound: async () => {
|
|
19960
20195
|
await runSwitchroomAuthCommand(ctx, ['auth', 'reauth', agent], `auth reauth ${agent}`)
|
|
19961
20196
|
// PR3 supergroup-mode: key by (chat, thread) so an OAuth code
|
|
@@ -19980,11 +20215,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
19980
20215
|
// path systemd-only.
|
|
19981
20216
|
const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
19982
20217
|
if (isDocker) {
|
|
19983
|
-
await ctx.
|
|
19984
|
-
|
|
19985
|
-
`Run from the host:
|
|
19986
|
-
|
|
19987
|
-
)
|
|
20218
|
+
await ctx.replyWithRichMessage(richMessage(
|
|
20219
|
+
`_Inline log fetch is not available under docker mode (no docker.sock in agent containers). ` +
|
|
20220
|
+
`Run from the host: \`docker logs --since 30m --tail 30 switchroom-${agent}\`_`,
|
|
20221
|
+
))
|
|
19988
20222
|
return
|
|
19989
20223
|
}
|
|
19990
20224
|
try {
|
|
@@ -19994,17 +20228,15 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
19994
20228
|
{ encoding: 'utf-8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
19995
20229
|
) as string
|
|
19996
20230
|
const trimmed = out.trim().slice(-3500)
|
|
19997
|
-
await ctx.
|
|
20231
|
+
await ctx.replyWithRichMessage(richMessage(
|
|
19998
20232
|
trimmed
|
|
19999
|
-
?
|
|
20000
|
-
:
|
|
20001
|
-
|
|
20002
|
-
)
|
|
20233
|
+
? `\`\`\`\n${trimmed.replace(/```/g, '```')}\n\`\`\``
|
|
20234
|
+
: `_No logs for ${agent}._`,
|
|
20235
|
+
))
|
|
20003
20236
|
} catch (err) {
|
|
20004
|
-
await ctx.
|
|
20005
|
-
|
|
20006
|
-
|
|
20007
|
-
)
|
|
20237
|
+
await ctx.replyWithRichMessage(richMessage(
|
|
20238
|
+
`**logs failed:** ${escapeHtmlForTg((err as Error).message)}`,
|
|
20239
|
+
))
|
|
20008
20240
|
}
|
|
20009
20241
|
return
|
|
20010
20242
|
}
|
|
@@ -20058,10 +20290,12 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
|
20058
20290
|
bot.api.editMessageText(
|
|
20059
20291
|
msg.chat.id,
|
|
20060
20292
|
msg.message_id,
|
|
20061
|
-
|
|
20062
|
-
|
|
20063
|
-
|
|
20064
|
-
|
|
20293
|
+
richMessage(
|
|
20294
|
+
`**Active account →** \`${result.active}\`\n` +
|
|
20295
|
+
`_Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}._\n\n` +
|
|
20296
|
+
`_Tap /auth to see updated quota for the new active account._`,
|
|
20297
|
+
),
|
|
20298
|
+
{},
|
|
20065
20299
|
),
|
|
20066
20300
|
{ chat_id: String(msg.chat.id), verb: 'auth:use:edit' },
|
|
20067
20301
|
)
|
|
@@ -20140,8 +20374,7 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
|
20140
20374
|
if (msg) {
|
|
20141
20375
|
await swallowingApiCall(
|
|
20142
20376
|
() =>
|
|
20143
|
-
bot.api.editMessageText(msg.chat.id, msg.message_id, text, {
|
|
20144
|
-
parse_mode: 'HTML',
|
|
20377
|
+
bot.api.editMessageText(msg.chat.id, msg.message_id, richMessage(text), {
|
|
20145
20378
|
reply_markup: { inline_keyboard },
|
|
20146
20379
|
}),
|
|
20147
20380
|
{ chat_id: String(msg.chat.id), verb: 'auth:refresh:edit' },
|
|
@@ -20189,17 +20422,17 @@ bot.command('vault', async ctx => {
|
|
|
20189
20422
|
const key = args[1]
|
|
20190
20423
|
if (!sub || sub === 'help') {
|
|
20191
20424
|
await switchroomReply(ctx, [
|
|
20192
|
-
'
|
|
20425
|
+
'**Vault commands**',
|
|
20193
20426
|
'/vault list — list all secret keys',
|
|
20194
|
-
'/vault get
|
|
20195
|
-
'/vault set
|
|
20196
|
-
'/vault delete
|
|
20427
|
+
'/vault get <key> — read a secret value',
|
|
20428
|
+
'/vault set <key> — set a secret',
|
|
20429
|
+
'/vault delete <key> — remove a secret',
|
|
20197
20430
|
'/vault status — show broker state',
|
|
20198
20431
|
'/vault unlock — unlock the broker (prompts for passphrase)',
|
|
20199
20432
|
'/vault lock — lock the broker',
|
|
20200
20433
|
'/vault grant — mint a capability token (inline wizard)',
|
|
20201
20434
|
'/vault grants [agent] — list active capability grants (tap to revoke)',
|
|
20202
|
-
'/vault audit
|
|
20435
|
+
'/vault audit <agent> — unified view of an agent\'s vault access',
|
|
20203
20436
|
'',
|
|
20204
20437
|
'Your passphrase is cached in memory for 30 min after first use.',
|
|
20205
20438
|
].join('\n'), { html: true })
|
|
@@ -20212,7 +20445,7 @@ bot.command('vault', async ctx => {
|
|
|
20212
20445
|
if (sub === 'audit') {
|
|
20213
20446
|
const targetAgent = args[1]
|
|
20214
20447
|
if (!targetAgent) {
|
|
20215
|
-
await switchroomReply(ctx, 'Usage: /vault audit
|
|
20448
|
+
await switchroomReply(ctx, 'Usage: /vault audit <agent>', { html: true })
|
|
20216
20449
|
return
|
|
20217
20450
|
}
|
|
20218
20451
|
// Sanity-check the agent name shape — same charset constraint as
|
|
@@ -20270,48 +20503,48 @@ bot.command('vault', async ctx => {
|
|
|
20270
20503
|
}
|
|
20271
20504
|
|
|
20272
20505
|
// 3. Compose the rendered audit view.
|
|
20273
|
-
const lines: string[] = [
|
|
20506
|
+
const lines: string[] = [`**🔐 ${escapeHtmlForTg(targetAgent)} — vault access**`, '']
|
|
20274
20507
|
|
|
20275
20508
|
if (grantsError) {
|
|
20276
|
-
lines.push(
|
|
20509
|
+
lines.push(`_Grants: ⚠️ ${escapeHtmlForTg(grantsError)}_`)
|
|
20277
20510
|
lines.push('')
|
|
20278
20511
|
} else {
|
|
20279
|
-
lines.push('
|
|
20512
|
+
lines.push('**Read grants:**')
|
|
20280
20513
|
if (readGrants.length === 0) {
|
|
20281
|
-
lines.push('
|
|
20514
|
+
lines.push(' _none_')
|
|
20282
20515
|
} else {
|
|
20283
20516
|
for (const g of readGrants) {
|
|
20284
20517
|
const expiry = g.expiresAt
|
|
20285
20518
|
? new Date(g.expiresAt * 1000).toISOString().slice(0, 10)
|
|
20286
20519
|
: 'no expiry'
|
|
20287
20520
|
const keysJoined = g.keys.join(', ')
|
|
20288
|
-
lines.push(`
|
|
20521
|
+
lines.push(` \`${g.id}\` · ${escapeHtmlForTg(keysJoined)} · expires ${expiry}`)
|
|
20289
20522
|
}
|
|
20290
20523
|
}
|
|
20291
20524
|
lines.push('')
|
|
20292
|
-
lines.push('
|
|
20525
|
+
lines.push('**Write grants:**')
|
|
20293
20526
|
if (writeGrants.length === 0) {
|
|
20294
|
-
lines.push('
|
|
20527
|
+
lines.push(' _none_')
|
|
20295
20528
|
} else {
|
|
20296
20529
|
for (const g of writeGrants) {
|
|
20297
20530
|
const expiry = g.expiresAt
|
|
20298
20531
|
? new Date(g.expiresAt * 1000).toISOString().slice(0, 10)
|
|
20299
20532
|
: 'no expiry'
|
|
20300
20533
|
const keysJoined = g.keys.join(', ')
|
|
20301
|
-
lines.push(`
|
|
20534
|
+
lines.push(` \`${g.id}\` · ${escapeHtmlForTg(keysJoined)} · expires ${expiry}`)
|
|
20302
20535
|
}
|
|
20303
20536
|
}
|
|
20304
20537
|
lines.push('')
|
|
20305
20538
|
}
|
|
20306
20539
|
|
|
20307
|
-
lines.push('
|
|
20540
|
+
lines.push('**Cron-declared secrets:**')
|
|
20308
20541
|
if (configError) {
|
|
20309
|
-
lines.push(`
|
|
20542
|
+
lines.push(` _⚠️ ${escapeHtmlForTg(configError)}_`)
|
|
20310
20543
|
} else if (cronEntries.length === 0) {
|
|
20311
|
-
lines.push('
|
|
20544
|
+
lines.push(' _none_')
|
|
20312
20545
|
} else {
|
|
20313
20546
|
for (const entry of cronEntries) {
|
|
20314
|
-
const cronLabel = entry.cron ? ` (
|
|
20547
|
+
const cronLabel = entry.cron ? ` (\`${entry.cron}\`)` : ''
|
|
20315
20548
|
const keysJoined = entry.secrets.join(', ')
|
|
20316
20549
|
lines.push(` schedule[${entry.index}]${cronLabel}: ${escapeHtmlForTg(keysJoined)}`)
|
|
20317
20550
|
}
|
|
@@ -20326,11 +20559,11 @@ bot.command('vault', async ctx => {
|
|
|
20326
20559
|
const denials = readRecentDenialsForAgent(targetAgent, 7 * 24 * 60 * 60 * 1000, 5)
|
|
20327
20560
|
const denialKeyboard = new InlineKeyboard()
|
|
20328
20561
|
if (denials.length > 0) {
|
|
20329
|
-
lines.push('
|
|
20562
|
+
lines.push('**Recent denials (last 7d):**')
|
|
20330
20563
|
for (const d of denials) {
|
|
20331
20564
|
const when = new Date(d.lastSeenMs).toISOString().slice(0, 16).replace('T', ' ')
|
|
20332
20565
|
lines.push(
|
|
20333
|
-
`
|
|
20566
|
+
` \`${d.key}\` · ${d.count}× · last ${when}`,
|
|
20334
20567
|
)
|
|
20335
20568
|
// callback_data must stay ≤ 64 bytes. Format:
|
|
20336
20569
|
// vrd:<agent>:<key>
|
|
@@ -20348,17 +20581,15 @@ bot.command('vault', async ctx => {
|
|
|
20348
20581
|
}
|
|
20349
20582
|
|
|
20350
20583
|
lines.push(
|
|
20351
|
-
|
|
20584
|
+
`_Summary: ${readGrants.length} read grant${readGrants.length === 1 ? '' : 's'}, ` +
|
|
20352
20585
|
`${writeGrants.length} write grant${writeGrants.length === 1 ? '' : 's'}, ` +
|
|
20353
20586
|
`${cronEntries.length} cron entr${cronEntries.length === 1 ? 'y' : 'ies'}, ` +
|
|
20354
|
-
`${denials.length} recent denial${denials.length === 1 ? '' : 's'}
|
|
20587
|
+
`${denials.length} recent denial${denials.length === 1 ? '' : 's'}._`,
|
|
20355
20588
|
)
|
|
20356
20589
|
|
|
20357
20590
|
const chatIdAudit = String(ctx.chat!.id)
|
|
20358
20591
|
const threadIdAudit = resolveThreadId(chatIdAudit, ctx.message?.message_thread_id)
|
|
20359
|
-
await ctx.
|
|
20360
|
-
parse_mode: 'HTML',
|
|
20361
|
-
link_preview_options: { is_disabled: true },
|
|
20592
|
+
await ctx.replyWithRichMessage(richMessage(lines.join('\n')), {
|
|
20362
20593
|
...(denials.length > 0 ? { reply_markup: denialKeyboard } : {}),
|
|
20363
20594
|
...(threadIdAudit != null ? { message_thread_id: threadIdAudit } : {}),
|
|
20364
20595
|
})
|
|
@@ -20379,7 +20610,7 @@ bot.command('vault', async ctx => {
|
|
|
20379
20610
|
}
|
|
20380
20611
|
const { grants } = result
|
|
20381
20612
|
if (grants.length === 0) {
|
|
20382
|
-
const filterNote = agentFilter ? ` for
|
|
20613
|
+
const filterNote = agentFilter ? ` for \`${agentFilter}\`` : ''
|
|
20383
20614
|
await switchroomReply(ctx, `📜 No active grants${filterNote}.`, { html: true })
|
|
20384
20615
|
return
|
|
20385
20616
|
}
|
|
@@ -20391,16 +20622,16 @@ bot.command('vault', async ctx => {
|
|
|
20391
20622
|
byAgent.set(g.agent_slug, list)
|
|
20392
20623
|
}
|
|
20393
20624
|
// Build message text (grouped) + inline keyboard (one [Revoke] per grant per row)
|
|
20394
|
-
const lines: string[] = ['
|
|
20625
|
+
const lines: string[] = ['**📜 Active grants**', '']
|
|
20395
20626
|
const keyboard = new InlineKeyboard()
|
|
20396
20627
|
for (const [agentName, agentGrants] of byAgent) {
|
|
20397
|
-
lines.push(
|
|
20628
|
+
lines.push(`**${escapeHtmlForTg(agentName)}:**`)
|
|
20398
20629
|
for (const g of agentGrants) {
|
|
20399
20630
|
const keys = g.key_allow.join(', ')
|
|
20400
20631
|
const expiry = g.expires_at
|
|
20401
20632
|
? new Date(g.expires_at * 1000).toISOString().slice(0, 10)
|
|
20402
20633
|
: 'no expiry'
|
|
20403
|
-
lines.push(`•
|
|
20634
|
+
lines.push(`• \`${g.id}\` — ${escapeHtmlForTg(keys)}, expires ${expiry}`)
|
|
20404
20635
|
// callback_data: vg:revoke:<id> — max 64 bytes; grant IDs are "vg_" + 6 chars = 9 chars total → well within limit
|
|
20405
20636
|
keyboard.text(`🗑 Revoke ${g.id}`, `vg:revoke:${g.id}`).row()
|
|
20406
20637
|
}
|
|
@@ -20408,9 +20639,7 @@ bot.command('vault', async ctx => {
|
|
|
20408
20639
|
}
|
|
20409
20640
|
const chatId2 = String(ctx.chat!.id)
|
|
20410
20641
|
const threadId2 = resolveThreadId(chatId2, ctx.message?.message_thread_id)
|
|
20411
|
-
await ctx.
|
|
20412
|
-
parse_mode: 'HTML',
|
|
20413
|
-
link_preview_options: { is_disabled: true },
|
|
20642
|
+
await ctx.replyWithRichMessage(richMessage(lines.join('\n')), {
|
|
20414
20643
|
reply_markup: keyboard,
|
|
20415
20644
|
...(threadId2 != null ? { message_thread_id: threadId2 } : {}),
|
|
20416
20645
|
})
|
|
@@ -20460,11 +20689,11 @@ bot.command('vault', async ctx => {
|
|
|
20460
20689
|
}
|
|
20461
20690
|
|
|
20462
20691
|
if (!['list', 'get', 'set', 'delete', 'remove'].includes(sub)) {
|
|
20463
|
-
await switchroomReply(ctx, `Unknown vault subcommand:
|
|
20692
|
+
await switchroomReply(ctx, `Unknown vault subcommand: \`${sub}\`. Try /vault help`, { html: true })
|
|
20464
20693
|
return
|
|
20465
20694
|
}
|
|
20466
|
-
if ((sub === 'get' || sub === 'delete' || sub === 'remove') && !key) { await switchroomReply(ctx, `Usage: /vault ${sub}
|
|
20467
|
-
if (sub === 'set' && !key) { await switchroomReply(ctx, 'Usage: /vault set
|
|
20695
|
+
if ((sub === 'get' || sub === 'delete' || sub === 'remove') && !key) { await switchroomReply(ctx, `Usage: /vault ${sub} <key>`, { html: true }); return }
|
|
20696
|
+
if (sub === 'set' && !key) { await switchroomReply(ctx, 'Usage: /vault set <key>', { html: true }); return }
|
|
20468
20697
|
|
|
20469
20698
|
const cached = vaultPassphraseCache.get(chatId)
|
|
20470
20699
|
const passphrase = cached && cached.expiresAt > Date.now() ? cached.passphrase : undefined
|
|
@@ -20551,9 +20780,9 @@ bot.command('issues', async ctx => {
|
|
|
20551
20780
|
await switchroomReply(
|
|
20552
20781
|
ctx,
|
|
20553
20782
|
[
|
|
20554
|
-
`This will resolve
|
|
20783
|
+
`This will resolve **${n} ${noun}**. To confirm, run:`,
|
|
20555
20784
|
'',
|
|
20556
|
-
'
|
|
20785
|
+
'\`/issues clear --confirm\`',
|
|
20557
20786
|
].join('\n'),
|
|
20558
20787
|
{ html: true },
|
|
20559
20788
|
)
|
|
@@ -20573,7 +20802,7 @@ bot.command('issues', async ctx => {
|
|
|
20573
20802
|
}
|
|
20574
20803
|
await switchroomReply(
|
|
20575
20804
|
ctx,
|
|
20576
|
-
'Usage:
|
|
20805
|
+
'Usage: \`/issues\` | \`/issues resolve <fingerprint>\` | \`/issues clear [--confirm]\`',
|
|
20577
20806
|
{ html: true },
|
|
20578
20807
|
)
|
|
20579
20808
|
})
|
|
@@ -20633,12 +20862,12 @@ bot.command('usage', async ctx => {
|
|
|
20633
20862
|
// socket bind, etc.). Same shape /usage shipped with originally.
|
|
20634
20863
|
const agentDir = resolveAgentDirFromEnv()
|
|
20635
20864
|
if (!agentDir) {
|
|
20636
|
-
await switchroomReply(ctx, '
|
|
20865
|
+
await switchroomReply(ctx, '**/usage:** cannot resolve agent dir.', { html: true })
|
|
20637
20866
|
return
|
|
20638
20867
|
}
|
|
20639
20868
|
const result = await fetchQuota({ claudeConfigDir: join(agentDir, '.claude') })
|
|
20640
20869
|
if (!result.ok) {
|
|
20641
|
-
await switchroomReply(ctx,
|
|
20870
|
+
await switchroomReply(ctx, `**/usage:** ${escapeHtmlForTg(result.reason)}`, { html: true })
|
|
20642
20871
|
return
|
|
20643
20872
|
}
|
|
20644
20873
|
await switchroomReply(ctx, formatQuotaBlock(result.data), { html: true })
|
|
@@ -20694,7 +20923,7 @@ async function renderFleetDoctor(ctx: Context): Promise<void> {
|
|
|
20694
20923
|
return
|
|
20695
20924
|
}
|
|
20696
20925
|
if (resp.result === 'denied') {
|
|
20697
|
-
await switchroomReply(ctx, `🩺
|
|
20926
|
+
await switchroomReply(ctx, `🩺 **Whole-fleet doctor denied by hostd:**\n${preBlock(formatSwitchroomOutput(resp.error ?? 'admin required'))}`, { html: true })
|
|
20698
20927
|
return
|
|
20699
20928
|
}
|
|
20700
20929
|
// `completed` (including `switchroom doctor` exit 1 = "found
|
|
@@ -20712,7 +20941,7 @@ bot.command('doctor', async ctx => {
|
|
|
20712
20941
|
// approval card — doctor is read-only). Everyone else keeps the
|
|
20713
20942
|
// original zero-extra-tap in-container behaviour.
|
|
20714
20943
|
if (AGENT_ADMIN && hostdWillBeUsed(getMyAgentName())) {
|
|
20715
|
-
await switchroomReply(ctx, '🩺
|
|
20944
|
+
await switchroomReply(ctx, '🩺 **Doctor** — which scope?', {
|
|
20716
20945
|
html: true,
|
|
20717
20946
|
reply_markup: buildDoctorScopeKeyboard(),
|
|
20718
20947
|
})
|
|
@@ -20720,7 +20949,7 @@ bot.command('doctor', async ctx => {
|
|
|
20720
20949
|
}
|
|
20721
20950
|
await renderSelfDoctor(ctx)
|
|
20722
20951
|
} catch (err: unknown) {
|
|
20723
|
-
await switchroomReply(ctx,
|
|
20952
|
+
await switchroomReply(ctx, `**doctor failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
20724
20953
|
}
|
|
20725
20954
|
})
|
|
20726
20955
|
|
|
@@ -20770,7 +20999,7 @@ bot.command('version', async ctx => {
|
|
|
20770
20999
|
if (!trimmed) { await switchroomReply(ctx, 'version: no output'); return }
|
|
20771
21000
|
await switchroomReply(ctx, preBlock(formatSwitchroomOutput(trimmed)), { html: true })
|
|
20772
21001
|
} catch (err: unknown) {
|
|
20773
|
-
await switchroomReply(ctx,
|
|
21002
|
+
await switchroomReply(ctx, `**version failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
20774
21003
|
}
|
|
20775
21004
|
})
|
|
20776
21005
|
|
|
@@ -20793,7 +21022,7 @@ bot.command('whoami', async ctx => {
|
|
|
20793
21022
|
catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
|
|
20794
21023
|
await switchroomReply(ctx, card, { html: true })
|
|
20795
21024
|
} catch (err: unknown) {
|
|
20796
|
-
await switchroomReply(ctx,
|
|
21025
|
+
await switchroomReply(ctx, `**whoami failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
20797
21026
|
}
|
|
20798
21027
|
})
|
|
20799
21028
|
|
|
@@ -20811,7 +21040,7 @@ function formatWhoamiCard(v: {
|
|
|
20811
21040
|
const esc = escapeHtmlForTg
|
|
20812
21041
|
const yn = (b?: boolean) => (b ? '✓' : '✗')
|
|
20813
21042
|
const lines: string[] = []
|
|
20814
|
-
lines.push(`👤
|
|
21043
|
+
lines.push(`👤 **${esc(v.name ?? '?')}** · ${esc(v.tier ?? 'standard')}`)
|
|
20815
21044
|
if (v.persona) lines.push(esc(v.persona))
|
|
20816
21045
|
if (v.model) lines.push(`Model: ${esc(v.model)}`)
|
|
20817
21046
|
const allow = v.tools?.allow ?? []
|
|
@@ -20882,8 +21111,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
20882
21111
|
try {
|
|
20883
21112
|
const outcome = await handleEffortMenuCallback(data, buildEffortDeps())
|
|
20884
21113
|
await ctx
|
|
20885
|
-
.editMessageText(outcome.reply.text, {
|
|
20886
|
-
parse_mode: 'HTML',
|
|
21114
|
+
.editMessageText(richMessage(outcome.reply.text), {
|
|
20887
21115
|
reply_markup: effortMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
|
|
20888
21116
|
})
|
|
20889
21117
|
.catch(() => {})
|
|
@@ -20908,8 +21136,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
20908
21136
|
if (process.env.SWITCHROOM_MODEL_MENU === '0') {
|
|
20909
21137
|
await ctx.answerCallbackQuery({ text: 'Model menu is disabled (SWITCHROOM_MODEL_MENU=0).' }).catch(() => {})
|
|
20910
21138
|
await ctx
|
|
20911
|
-
.editMessageText('Model menu is disabled on this agent. Use
|
|
20912
|
-
parse_mode: 'HTML',
|
|
21139
|
+
.editMessageText(richMessage('Model menu is disabled on this agent. Use \`/model <name>\`.'), {
|
|
20913
21140
|
reply_markup: { inline_keyboard: [] },
|
|
20914
21141
|
})
|
|
20915
21142
|
.catch(() => {})
|
|
@@ -20953,7 +21180,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
20953
21180
|
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
20954
21181
|
const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)))
|
|
20955
21182
|
await ctx
|
|
20956
|
-
.editMessageText(`⏳ Switching session to
|
|
21183
|
+
.editMessageText(richMessage(`⏳ Switching session to **${srLabel}**…`), { reply_markup: { inline_keyboard: [] } })
|
|
20957
21184
|
.catch(() => {})
|
|
20958
21185
|
didInterimSrEdit = true
|
|
20959
21186
|
}
|
|
@@ -20982,8 +21209,8 @@ bot.on('callback_query:data', async ctx => {
|
|
|
20982
21209
|
// Replace the menu with a restart notice (no buttons — session is ending).
|
|
20983
21210
|
await ctx
|
|
20984
21211
|
.editMessageText(
|
|
20985
|
-
`🔄 Switching from
|
|
20986
|
-
{
|
|
21212
|
+
richMessage(`🔄 Switching from **${escapeHtmlForTg(prevSessionModel!)}** back to Claude — restarting session cleanly. Claude will be ready in ~30s.`),
|
|
21213
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
20987
21214
|
)
|
|
20988
21215
|
.catch(() => {})
|
|
20989
21216
|
// Write the restart marker so the post-restart boot card edits into this chat.
|
|
@@ -21001,8 +21228,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21001
21228
|
}
|
|
21002
21229
|
|
|
21003
21230
|
await ctx
|
|
21004
|
-
.editMessageText(outcome.reply.text, {
|
|
21005
|
-
parse_mode: 'HTML',
|
|
21231
|
+
.editMessageText(richMessage(outcome.reply.text), {
|
|
21006
21232
|
reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
|
|
21007
21233
|
})
|
|
21008
21234
|
.catch(() => {})
|
|
@@ -21099,8 +21325,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21099
21325
|
editCard: async (args) => {
|
|
21100
21326
|
try {
|
|
21101
21327
|
await robustApiCall(() =>
|
|
21102
|
-
bot.api.editMessageText(args.chatId, args.messageId, args.text, {
|
|
21103
|
-
parse_mode: 'HTML',
|
|
21328
|
+
bot.api.editMessageText(args.chatId, args.messageId, richMessage(args.text), {
|
|
21104
21329
|
// Resolved on tap — strip the keyboard so it can't be re-tapped.
|
|
21105
21330
|
...(args.stripKeyboard ? { reply_markup: { inline_keyboard: [] } } : {}),
|
|
21106
21331
|
}),
|
|
@@ -21185,7 +21410,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21185
21410
|
if (scope === 'fleet') await renderFleetDoctor(ctx)
|
|
21186
21411
|
else await renderSelfDoctor(ctx)
|
|
21187
21412
|
} catch (err: unknown) {
|
|
21188
|
-
await switchroomReply(ctx,
|
|
21413
|
+
await switchroomReply(ctx, `**doctor failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
21189
21414
|
}
|
|
21190
21415
|
return
|
|
21191
21416
|
}
|
|
@@ -21224,6 +21449,16 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21224
21449
|
return
|
|
21225
21450
|
}
|
|
21226
21451
|
|
|
21452
|
+
// #2670: one-tap skill-improvement proposal card.
|
|
21453
|
+
// skprop:approve:<id> — apply the stored draft via the personal-skill
|
|
21454
|
+
// write pipeline (inject a turn; agent writes it)
|
|
21455
|
+
// skprop:deny:<id> — dismiss + record a rejection fingerprint so it
|
|
21456
|
+
// isn't re-proposed
|
|
21457
|
+
if (data.startsWith('skprop:')) {
|
|
21458
|
+
await handleSkillProposalCallback(ctx, data)
|
|
21459
|
+
return
|
|
21460
|
+
}
|
|
21461
|
+
|
|
21227
21462
|
// Issue #969 P2b: vault recent-denial one-tap approval.
|
|
21228
21463
|
// vrd:<agent>:<key> — mint a 30-day read-grant for the agent + key
|
|
21229
21464
|
// Posted by /vault audit <agent> in the "Recent denials" section.
|
|
@@ -21279,8 +21514,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21279
21514
|
if (sourceMsg && 'text' in sourceMsg && sourceMsg.text != null) {
|
|
21280
21515
|
try {
|
|
21281
21516
|
await ctx.editMessageText(
|
|
21282
|
-
`${escapeHtmlForTg(sourceMsg.text)}\n\n✅
|
|
21283
|
-
{ parse_mode: 'HTML' },
|
|
21517
|
+
richMessage(`${escapeHtmlForTg(sourceMsg.text)}\n\n✅ **${escapeHtmlForTg(choice)}**`),
|
|
21284
21518
|
)
|
|
21285
21519
|
} catch {
|
|
21286
21520
|
/* edit-failed is fine — the answer-callback below still acks */
|
|
@@ -21658,13 +21892,13 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21658
21892
|
: ''
|
|
21659
21893
|
const editLabel = ok
|
|
21660
21894
|
? (legacyNote
|
|
21661
|
-
? `✅
|
|
21895
|
+
? `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking (legacy path); restart agent for full effect`
|
|
21662
21896
|
: restartScheduled
|
|
21663
|
-
? `✅
|
|
21664
|
-
: `✅
|
|
21897
|
+
? `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking — applying now (restarting to take effect).`
|
|
21898
|
+
: `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking; restart agent for full effect`)
|
|
21665
21899
|
: (editLockHint
|
|
21666
|
-
? `⚠️
|
|
21667
|
-
: `⚠️
|
|
21900
|
+
? `⚠️ **Allowed for now — "always" did NOT save.** Config edits are locked; enable \`hostd.config_edit_enabled\`.`
|
|
21901
|
+
: `⚠️ **Allowed for now — "always" did NOT save.** It will ask again after restart. Check gateway log.`)
|
|
21668
21902
|
// #1150 audit: route through finalizeCallback so the keyboard
|
|
21669
21903
|
// strips alongside the status-line edit. The in-flight verdict was
|
|
21670
21904
|
// ALREADY dispatched above (independently of this host round-trip)
|
|
@@ -21673,7 +21907,6 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21673
21907
|
await finalizeCallback(ctx, {
|
|
21674
21908
|
ackText: ackText.slice(0, 200),
|
|
21675
21909
|
newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
|
|
21676
|
-
parseMode: 'HTML',
|
|
21677
21910
|
})
|
|
21678
21911
|
return
|
|
21679
21912
|
}
|
|
@@ -21686,6 +21919,20 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21686
21919
|
// scopes (resolveTimeBox → null) and the disabled tier (ttl<=0) stay truly
|
|
21687
21920
|
// once. The verdict is still dispatched WITHOUT a `rule` (below), so the
|
|
21688
21921
|
// bridge never caches it untimed — the window lives only in scopedGrants.
|
|
21922
|
+
// Stale-id tap (#2469 follow-up): the pendingStateReaper auto-DENIES a
|
|
21923
|
+
// permission whose TTL expired and DELETES its pending entry — but a late
|
|
21924
|
+
// operator tap on the (now keyboard-stripped, but possibly still-cached)
|
|
21925
|
+
// card would otherwise dispatch a verdict for an already-resolved/dead
|
|
21926
|
+
// request_id, which Claude Code silently ignores. Net: operator THINKS
|
|
21927
|
+
// they approved, but the work was auto-denied and never continues. Be
|
|
21928
|
+
// honest: tell the operator the request already resolved and do NOT
|
|
21929
|
+
// dispatch a verdict for the dead id (no buffering, no resume message).
|
|
21930
|
+
// LIVE ids fall through to the normal dispatch path below, preserving the
|
|
21931
|
+
// dispatchPermissionVerdict buffering/offline-redelivery contract.
|
|
21932
|
+
if (!pendingPermissions.has(request_id)) {
|
|
21933
|
+
await ctx.answerCallbackQuery({ text: STALE_TAP_NOTICE }).catch(() => {})
|
|
21934
|
+
return
|
|
21935
|
+
}
|
|
21689
21936
|
// Operator tapped a verdict ⇒ they are present; reset no-repeat suppression
|
|
21690
21937
|
// so a later identical ask is shown fresh rather than silently short-circuited.
|
|
21691
21938
|
clearPermissionTimeoutSuppression('operator answered a permission card')
|
|
@@ -21718,10 +21965,10 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21718
21965
|
? '❌ Denied'
|
|
21719
21966
|
: (windowGranted ? `✅ Allowed (${scopedMins} min)` : '✅ Allowed once')
|
|
21720
21967
|
const htmlLabel = behavior === 'deny'
|
|
21721
|
-
? '❌
|
|
21968
|
+
? '❌ **Denied**'
|
|
21722
21969
|
: (windowGranted
|
|
21723
|
-
? `✅
|
|
21724
|
-
: '✅
|
|
21970
|
+
? `✅ **Allowed — won't ask again about ${escapeHtmlForTg(timeBox!.breadth)} for ${scopedMins} min**`
|
|
21971
|
+
: '✅ **Allowed once**')
|
|
21725
21972
|
// HTML-escape the source text — same hazard as the scope-commit and
|
|
21726
21973
|
// recent-denial paths above. The permission card body
|
|
21727
21974
|
// (formatPermissionCardBody) appends claude-supplied `description`
|
|
@@ -21742,7 +21989,6 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21742
21989
|
await finalizeCallback(ctx, {
|
|
21743
21990
|
ackText: ackText.slice(0, 200),
|
|
21744
21991
|
newText: baseText ? `${baseText}\n\n${htmlLabel}` : htmlLabel,
|
|
21745
|
-
parseMode: 'HTML',
|
|
21746
21992
|
synthInbound: () => {
|
|
21747
21993
|
// No `rule` → the bridge does NOT cache this (truly once on the bridge);
|
|
21748
21994
|
// any 30-min stickiness lives only in scopedGrants (recorded above).
|
|
@@ -23633,14 +23879,16 @@ void (async () => {
|
|
|
23633
23879
|
const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS)
|
|
23634
23880
|
workerActivityFeed?.stop()
|
|
23635
23881
|
workerActivityFeed = createWorkerActivityFeed({
|
|
23882
|
+
// #2669: worker-feed body is raw GFM markdown — send via rich.
|
|
23636
23883
|
bot: {
|
|
23637
23884
|
sendMessage: async (cid, text, sendOpts) => {
|
|
23638
23885
|
const sent = await robustApiCall(
|
|
23639
23886
|
() =>
|
|
23640
|
-
|
|
23887
|
+
// allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
|
|
23888
|
+
lockedBot.api.sendRichMessage(
|
|
23641
23889
|
cid,
|
|
23642
|
-
text,
|
|
23643
|
-
sendOpts as Parameters<typeof lockedBot.api.
|
|
23890
|
+
richMessage(text),
|
|
23891
|
+
sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
|
|
23644
23892
|
),
|
|
23645
23893
|
{ chat_id: cid, verb: 'worker-feed' },
|
|
23646
23894
|
)
|
|
@@ -23652,7 +23900,7 @@ void (async () => {
|
|
|
23652
23900
|
lockedBot.api.editMessageText(
|
|
23653
23901
|
cid,
|
|
23654
23902
|
mid,
|
|
23655
|
-
text,
|
|
23903
|
+
richMessage(text),
|
|
23656
23904
|
editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
|
|
23657
23905
|
),
|
|
23658
23906
|
{ chat_id: cid, verb: 'worker-feed' },
|