switchroom 0.14.60 → 0.14.62
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/cli/switchroom.js +73 -62
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +2586 -2061
- package/telegram-plugin/gateway/auth-broker-client.ts +18 -8
- package/telegram-plugin/gateway/gateway.ts +401 -14
- package/telegram-plugin/gateway/microsoft-connect-flow.ts +226 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +65 -2
- package/telegram-plugin/gateway/obligation-store.ts +107 -0
- package/telegram-plugin/gateway/with-deadline.ts +43 -0
- package/telegram-plugin/tests/microsoft-connect-flow.test.ts +185 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +241 -0
- package/telegram-plugin/tests/obligation-ledger.test.ts +69 -0
- package/telegram-plugin/tests/obligation-store.test.ts +117 -0
- package/telegram-plugin/tests/with-deadline.test.ts +61 -0
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { AuthBrokerClient as BrokerClient, type AddAccountCredentials } from '../../src/auth/broker/client.js'
|
|
14
|
+
import type { ProviderName } from '../../src/auth/broker/protocol.js'
|
|
14
15
|
import type { AuthBrokerClient } from './auth-command.js'
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -56,21 +57,30 @@ export async function getAuthBrokerClient(
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* Add an account via the broker. Used
|
|
60
|
-
*
|
|
61
|
-
* `auth-command.ts` deliberately
|
|
62
|
-
* is gateway-routed (not
|
|
63
|
-
* one-shot {@link BrokerClient}
|
|
64
|
-
* long-lived handle just for this verb.
|
|
60
|
+
* Add an account via the broker. Used by the `/auth add` chat flow
|
|
61
|
+
* (Anthropic) and the `/connect microsoft` device-code flow — the narrow
|
|
62
|
+
* {@link AuthBrokerClient} surface in `auth-command.ts` deliberately
|
|
63
|
+
* omits `addAccount` because the verb is gateway-routed (not
|
|
64
|
+
* handler-routed). Constructs and closes a one-shot {@link BrokerClient}
|
|
65
|
+
* so the gateway doesn't need a long-lived handle just for this verb.
|
|
66
|
+
*
|
|
67
|
+
* `provider` defaults to Anthropic (back-compat with the `/auth add`
|
|
68
|
+
* caller, which omits it). The device-code flow passes `"microsoft"`
|
|
69
|
+
* with a `MicrosoftAddAccountCredentials` payload.
|
|
65
70
|
*/
|
|
66
71
|
export async function addAccountViaBroker(
|
|
67
72
|
label: string,
|
|
68
73
|
credentials: AddAccountCredentials,
|
|
69
|
-
opts: { replace?: boolean } = {},
|
|
74
|
+
opts: { replace?: boolean; provider?: ProviderName } = {},
|
|
70
75
|
): Promise<{ label: string; expiresAt?: number }> {
|
|
71
76
|
const broker = new BrokerClient()
|
|
72
77
|
try {
|
|
73
|
-
return await broker.addAccount(
|
|
78
|
+
return await broker.addAccount(
|
|
79
|
+
label,
|
|
80
|
+
credentials,
|
|
81
|
+
opts.replace,
|
|
82
|
+
opts.provider,
|
|
83
|
+
)
|
|
74
84
|
} finally {
|
|
75
85
|
await broker.close()
|
|
76
86
|
}
|
|
@@ -116,6 +116,11 @@ import {
|
|
|
116
116
|
import type { AuthBrokerClient } from './auth-command.js'
|
|
117
117
|
import type { ListStateData } from './auth-line.js'
|
|
118
118
|
import { getAuthBrokerClient, addAccountViaBroker } from './auth-broker-client.js'
|
|
119
|
+
import {
|
|
120
|
+
pendingMicrosoftConnectFlows,
|
|
121
|
+
startMicrosoftConnect,
|
|
122
|
+
runMicrosoftConnectPoll,
|
|
123
|
+
} from './microsoft-connect-flow.js'
|
|
119
124
|
import { resolveAuthBrokerSocketPath } from '../../src/auth/broker/client.js'
|
|
120
125
|
import { createFleetFallbackGate } from '../fleet-fallback-gate.js'
|
|
121
126
|
import {
|
|
@@ -283,6 +288,8 @@ import {
|
|
|
283
288
|
buildObligationRepresentInbound,
|
|
284
289
|
obligationEscalationText,
|
|
285
290
|
} from './obligation-ledger.js'
|
|
291
|
+
import { loadObligations, persistObligations } from './obligation-store.js'
|
|
292
|
+
import { withDeadline } from './with-deadline.js'
|
|
286
293
|
import { createInboundSpool } from './inbound-spool.js'
|
|
287
294
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
288
295
|
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
@@ -1397,7 +1404,60 @@ const deliveryQueue = createDeliveryQueue<InboundMessage>()
|
|
|
1397
1404
|
const OBLIGATION_LEDGER_ENABLED = process.env.SWITCHROOM_OBLIGATION_LEDGER === '1'
|
|
1398
1405
|
const OBLIGATION_REPRESENT_MAX = 2
|
|
1399
1406
|
const OBLIGATION_SWEEP_MS = 5_000
|
|
1400
|
-
|
|
1407
|
+
// Bound on escalation SEND attempts. The escalation now closes only AFTER a
|
|
1408
|
+
// successful send (a transient failure stays OPEN and retries next sweep), so a
|
|
1409
|
+
// PERMANENTLY-undeliverable nudge (dead/renumbered topic → Telegram 400 even
|
|
1410
|
+
// after thread-fallback, blocked bot) would loop forever — and, with the
|
|
1411
|
+
// durable snapshot, re-enter that loop on every boot. After this many failed
|
|
1412
|
+
// attempts the gateway closes best-effort (loud log): the user is genuinely
|
|
1413
|
+
// unreachable, so a bounded give-up beats an infinite/poison loop.
|
|
1414
|
+
const OBLIGATION_ESCALATE_MAX = 3
|
|
1415
|
+
// Deadline for a single escalation send. grammy/fetch impose NO request timeout,
|
|
1416
|
+
// and the in-flight guard (obligationEscalateInFlight) is cleared only in the
|
|
1417
|
+
// send's `.finally` — which never runs if the send hangs. Without a bound, one
|
|
1418
|
+
// stalled send leaks the in-flight flag forever and the obligation is stuck OPEN
|
|
1419
|
+
// (never re-escalated, never closed) — the sole liveness hole a total proof
|
|
1420
|
+
// found. Racing the send against this deadline makes the wait bounded BY
|
|
1421
|
+
// CONSTRUCTION (see with-deadline.ts): the chain always settles, `.finally`
|
|
1422
|
+
// always clears the flag, and a hang becomes a bounded reject that feeds the
|
|
1423
|
+
// bounded escalate ladder to a terminal. 45s comfortably exceeds robustApiCall's
|
|
1424
|
+
// 3-attempt network backoff so a legitimate slow send isn't cut short.
|
|
1425
|
+
const OBLIGATION_ESCALATE_SEND_DEADLINE_MS = 45_000
|
|
1426
|
+
// Durable snapshot of the open obligation set on the persistent per-agent
|
|
1427
|
+
// volume (STATE_DIR = /state/agent/telegram in prod). Closes the restart hole:
|
|
1428
|
+
// the in-memory ledger alone empties on restart and the spool's boot-replay
|
|
1429
|
+
// bypasses handleInbound (where obligations OPEN), so a delivered-but-unanswered
|
|
1430
|
+
// message used to lose its obligation across a restart. onChange persists every
|
|
1431
|
+
// mutation; boot hydrate (below) restores OPEN/ESCALATING with representCount +
|
|
1432
|
+
// escalateAttempts intact. STATIC mode (no runtime) and flag-off both skip disk.
|
|
1433
|
+
const OBLIGATION_STORE_PATH = join(STATE_DIR, 'obligations.json')
|
|
1434
|
+
const obligationStoreFs = {
|
|
1435
|
+
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
1436
|
+
writeFileSync: (p: string, d: string) => writeFileSync(p, d),
|
|
1437
|
+
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
1438
|
+
existsSync: (p: string) => existsSync(p),
|
|
1439
|
+
}
|
|
1440
|
+
const obligationLedger = new ObligationLedger(OBLIGATION_REPRESENT_MAX, {
|
|
1441
|
+
onChange:
|
|
1442
|
+
STATIC || !OBLIGATION_LEDGER_ENABLED
|
|
1443
|
+
? undefined
|
|
1444
|
+
: (snapshot) => persistObligations(OBLIGATION_STORE_PATH, obligationStoreFs, snapshot),
|
|
1445
|
+
})
|
|
1446
|
+
if (!STATIC && OBLIGATION_LEDGER_ENABLED) {
|
|
1447
|
+
// Restart-durability: re-open obligations that were OPEN/ESCALATING when the
|
|
1448
|
+
// gateway last ran. The very next idle sweep re-presents or re-escalates them.
|
|
1449
|
+
const restored = loadObligations(OBLIGATION_STORE_PATH, obligationStoreFs)
|
|
1450
|
+
if (restored.length > 0) {
|
|
1451
|
+
obligationLedger.hydrate(restored)
|
|
1452
|
+
process.stderr.write(
|
|
1453
|
+
`telegram gateway: obligation-ledger hydrated ${restored.length} open obligation(s) from ${OBLIGATION_STORE_PATH}\n`,
|
|
1454
|
+
)
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
// Origin ids with an escalation send IN FLIGHT — prevents the 5s sweep from
|
|
1458
|
+
// firing a second concurrent send for the same obligation while the first is
|
|
1459
|
+
// still awaiting (an escalation that takes >5s, or repeated failures).
|
|
1460
|
+
const obligationEscalateInFlight = new Set<string>()
|
|
1401
1461
|
|
|
1402
1462
|
// ─── Serialize-until-replied (multitopic reply-routing) ───────────────────
|
|
1403
1463
|
// Component 1 (deliver-before-drain gate). A buffered cross-topic inbound
|
|
@@ -3702,6 +3762,17 @@ const pendingStateReaper = setInterval(() => {
|
|
|
3702
3762
|
for (const [k, v] of awaitingAuthCodeAt) {
|
|
3703
3763
|
if (now - v > AUTH_CODE_CONTEXT_TTL_MS) awaitingAuthCodeAt.delete(k)
|
|
3704
3764
|
}
|
|
3765
|
+
// Microsoft connect flows self-expire at the device code's own expiry
|
|
3766
|
+
// (~15 min) — sweep past that + grace so an abandoned card doesn't pin
|
|
3767
|
+
// its key. Setting cancelled makes any still-running poll bail. Placed
|
|
3768
|
+
// AFTER the OAuth-code cluster above, which secret-detect-oauth-code.
|
|
3769
|
+
// test.ts pins as contiguous within the first 800 chars of the reaper.
|
|
3770
|
+
for (const [k, v] of pendingMicrosoftConnectFlows) {
|
|
3771
|
+
if (now - v.startedAt > v.device.expires_in * 1000 + 30_000) {
|
|
3772
|
+
v.cancelled = true
|
|
3773
|
+
pendingMicrosoftConnectFlows.delete(k)
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3705
3776
|
// Auth-refresh throttle entries decay quickly (5s window); sweep
|
|
3706
3777
|
// anything older than 60s so abandoned snapshot messages don't pin
|
|
3707
3778
|
// their key forever.
|
|
@@ -4831,16 +4902,39 @@ const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
|
|
|
4831
4902
|
// OPEN via meta.source; reuses tested delivery). Bounded: after maxRepresents,
|
|
4832
4903
|
// escalate to ONE operator-visible "did I miss this?" and close — no loop.
|
|
4833
4904
|
// No-op unless the flag is on; gated on the same idle predicate as the drains.
|
|
4905
|
+
// DETERMINISM (closed-form, not sampled). The obligation ledger itself
|
|
4906
|
+
// (obligation-ledger.ts) is a finite FSM with a total transition function and a
|
|
4907
|
+
// strictly-decreasing measure μ = (REPRESENT_MAX - representCount) +
|
|
4908
|
+
// (ESCALATE_MAX - escalateAttempts): every markRepresented/markEscalateAttempt
|
|
4909
|
+
// decreases μ, both terms are bounded below, and at the floor the only move is
|
|
4910
|
+
// close → terminal. So on the FSM, every OPEN reaches answered | escalation-
|
|
4911
|
+
// delivered | bounded give-up (no cycle re-increments a counter). This sweep is
|
|
4912
|
+
// the FSM's driver; its termination rests on three liveness facts, all bounded:
|
|
4913
|
+
// (1) the 5s setInterval keeps firing;
|
|
4914
|
+
// (2) the gate (turnInFlightForGate) opens — claudeBusyKeys is cleared at
|
|
4915
|
+
// turn-end (purgeReactionTracking / releaseTurnBufferGate), on bridge
|
|
4916
|
+
// disconnect (disconnect-flush.ts), and by the 300s silence-poke watchdog;
|
|
4917
|
+
// (3) the escalation send settles — bounded BY CONSTRUCTION via withDeadline
|
|
4918
|
+
// below (grammy has no request timeout, so an unbounded send was the one
|
|
4919
|
+
// way an obligation could get stuck OPEN forever — now closed).
|
|
4920
|
+
// The only residual liveness assumption is the bridge eventually reconnecting /
|
|
4921
|
+
// the process restarting, which the entire gateway's inbound delivery already
|
|
4922
|
+
// depends on and which durable spool + boot-replay make self-healing.
|
|
4834
4923
|
function obligationSweep(): void {
|
|
4835
4924
|
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
4836
4925
|
if (!obligationLedger.hasOpen()) return
|
|
4837
4926
|
if (turnInFlightForGate()) return // a turn is running — let it finish/answer
|
|
4838
4927
|
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
4839
|
-
if (pendingInboundBuffer.depth(agent) > 0) return // existing drain runs first; avoids double-present
|
|
4840
4928
|
const decision = obligationLedger.decideAtIdle()
|
|
4841
4929
|
const o = decision.obligation
|
|
4842
4930
|
if (decision.action === 'none' || o == null) return
|
|
4843
4931
|
if (decision.action === 'represent') {
|
|
4932
|
+
// Re-present goes through the bridge → buffer. Only the represent path is
|
|
4933
|
+
// gated on an empty buffer (let the existing drain run first, avoid
|
|
4934
|
+
// double-presenting). Escalation below is NOT gated on the buffer — it is a
|
|
4935
|
+
// direct Telegram send, independent of the bridge, so a represent stranded
|
|
4936
|
+
// behind a dead bridge can never block the operator nudge.
|
|
4937
|
+
if (pendingInboundBuffer.depth(agent) > 0) return
|
|
4844
4938
|
pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()))
|
|
4845
4939
|
const attempt = obligationLedger.markRepresented(o.originTurnId)
|
|
4846
4940
|
process.stderr.write(
|
|
@@ -4848,20 +4942,62 @@ function obligationSweep(): void {
|
|
|
4848
4942
|
)
|
|
4849
4943
|
return
|
|
4850
4944
|
}
|
|
4851
|
-
// escalate —
|
|
4852
|
-
|
|
4945
|
+
// escalate — re-present ladder exhausted. Send ONE operator-visible nudge and
|
|
4946
|
+
// close the obligation ONLY AFTER it actually lands. This inverts the old
|
|
4947
|
+
// close-before-send (which silently dropped the terminal whenever the send
|
|
4948
|
+
// failed): the close is now itself an observable terminal. A transient send
|
|
4949
|
+
// failure leaves the obligation OPEN → retried next sweep; a PERMANENT one
|
|
4950
|
+
// (dead topic even after thread-fallback, blocked bot) is bounded by
|
|
4951
|
+
// OBLIGATION_ESCALATE_MAX → close best-effort (the user is unreachable, so a
|
|
4952
|
+
// bounded give-up beats an infinite loop / a boot-surviving poison record).
|
|
4953
|
+
if (obligationEscalateInFlight.has(o.originTurnId)) return // a send is already awaiting
|
|
4954
|
+
const escId = o.originTurnId
|
|
4955
|
+
const attempt = obligationLedger.markEscalateAttempt(escId)
|
|
4956
|
+
obligationEscalateInFlight.add(escId)
|
|
4853
4957
|
process.stderr.write(
|
|
4854
|
-
`telegram gateway: obligation
|
|
4958
|
+
`telegram gateway: obligation escalating (exhausted ${OBLIGATION_REPRESENT_MAX} re-presents) origin=${escId} attempt=${attempt}/${OBLIGATION_ESCALATE_MAX}\n`,
|
|
4855
4959
|
)
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4960
|
+
// retryWithThreadFallback: a stale/renumbered topic returns THREAD_NOT_FOUND;
|
|
4961
|
+
// retry WITHOUT the thread so the nudge still lands in the chat (the #2096
|
|
4962
|
+
// pattern) instead of being permanently undeliverable to a dead topic.
|
|
4963
|
+
// withDeadline: grammy/fetch impose no request timeout and `.finally` (which
|
|
4964
|
+
// clears the in-flight flag) only runs on settle — so a hung send would leak
|
|
4965
|
+
// the flag forever and wedge this obligation OPEN. Racing against a deadline
|
|
4966
|
+
// guarantees the chain settles, the flag always clears, and a hang becomes a
|
|
4967
|
+
// bounded reject handled exactly like any other failed attempt.
|
|
4968
|
+
void withDeadline(
|
|
4969
|
+
retryWithThreadFallback(
|
|
4970
|
+
robustApiCall,
|
|
4971
|
+
(tid) =>
|
|
4972
|
+
bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
|
|
4973
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
4974
|
+
}),
|
|
4975
|
+
{ threadId: o.threadId, chat_id: o.chatId, verb: 'obligation.escalate' },
|
|
4976
|
+
),
|
|
4977
|
+
OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
|
|
4978
|
+
'obligation escalation send timed out',
|
|
4979
|
+
)
|
|
4980
|
+
.then(() => {
|
|
4981
|
+
obligationLedger.close(escId)
|
|
4982
|
+
process.stderr.write(
|
|
4983
|
+
`telegram gateway: obligation escalation delivered + closed origin=${escId}\n`,
|
|
4984
|
+
)
|
|
4985
|
+
})
|
|
4986
|
+
.catch((err) => {
|
|
4987
|
+
if (attempt >= OBLIGATION_ESCALATE_MAX) {
|
|
4988
|
+
obligationLedger.close(escId)
|
|
4989
|
+
process.stderr.write(
|
|
4990
|
+
`telegram gateway: obligation escalation PERMANENTLY undeliverable after ${attempt} attempts — closing best-effort origin=${escId}: ${err}\n`,
|
|
4991
|
+
)
|
|
4992
|
+
} else {
|
|
4993
|
+
process.stderr.write(
|
|
4994
|
+
`telegram gateway: obligation escalation send failed (attempt ${attempt}/${OBLIGATION_ESCALATE_MAX}), retrying next sweep origin=${escId}: ${err}\n`,
|
|
4995
|
+
)
|
|
4996
|
+
}
|
|
4997
|
+
})
|
|
4998
|
+
.finally(() => {
|
|
4999
|
+
obligationEscalateInFlight.delete(escId)
|
|
5000
|
+
})
|
|
4865
5001
|
}
|
|
4866
5002
|
if (!STATIC && OBLIGATION_LEDGER_ENABLED) {
|
|
4867
5003
|
setInterval(obligationSweep, OBLIGATION_SWEEP_MS).unref()
|
|
@@ -14309,6 +14445,226 @@ async function runQuotaWatch(): Promise<void> {
|
|
|
14309
14445
|
}
|
|
14310
14446
|
}
|
|
14311
14447
|
|
|
14448
|
+
/**
|
|
14449
|
+
* Edit a Microsoft connect card from the BACKGROUND device-code poll
|
|
14450
|
+
* (no `ctx` — we hold the chat+message id). Wrapped in robustApiCall;
|
|
14451
|
+
* swallows failures (a deleted card / closed topic must not crash the
|
|
14452
|
+
* poll). RFC #1873 Phase 2.
|
|
14453
|
+
*/
|
|
14454
|
+
async function editMicrosoftConnectCard(
|
|
14455
|
+
chatId: number | string,
|
|
14456
|
+
messageId: number,
|
|
14457
|
+
html: string,
|
|
14458
|
+
): Promise<void> {
|
|
14459
|
+
await robustApiCall(
|
|
14460
|
+
// allow-raw-bot-api: background connect-card edit by message id (no thread_id; not the reply chunk loop)
|
|
14461
|
+
() => bot.api.editMessageText(chatId, messageId, html, {
|
|
14462
|
+
parse_mode: 'HTML',
|
|
14463
|
+
link_preview_options: { is_disabled: true },
|
|
14464
|
+
}),
|
|
14465
|
+
{ chat_id: String(chatId), verb: 'microsoftConnectCard' },
|
|
14466
|
+
).catch(() => {})
|
|
14467
|
+
}
|
|
14468
|
+
|
|
14469
|
+
/**
|
|
14470
|
+
* Background half of `/connect microsoft`: poll Microsoft for consent,
|
|
14471
|
+
* register the account with the broker, then edit the card to the
|
|
14472
|
+
* outcome. Fire-and-forget from the command handler.
|
|
14473
|
+
*/
|
|
14474
|
+
async function finalizeMicrosoftConnect(key: string): Promise<void> {
|
|
14475
|
+
const flow = pendingMicrosoftConnectFlows.get(key)
|
|
14476
|
+
if (!flow) return
|
|
14477
|
+
const agentName = getMyAgentName()
|
|
14478
|
+
const result = await runMicrosoftConnectPoll(flow)
|
|
14479
|
+
// A `/connect cancel` (command or button) between consent and write
|
|
14480
|
+
// already edited the card and dropped the entry — don't clobber it.
|
|
14481
|
+
if (result.kind === 'cancelled' || !pendingMicrosoftConnectFlows.has(key)) {
|
|
14482
|
+
pendingMicrosoftConnectFlows.delete(key)
|
|
14483
|
+
return
|
|
14484
|
+
}
|
|
14485
|
+
pendingMicrosoftConnectFlows.delete(key)
|
|
14486
|
+
|
|
14487
|
+
let html: string
|
|
14488
|
+
if (result.kind === 'connected') {
|
|
14489
|
+
html =
|
|
14490
|
+
`✅ <b>Connected</b> <code>${escapeHtmlForTg(result.account)}</code> ` +
|
|
14491
|
+
`(${result.accountType}).\n\n` +
|
|
14492
|
+
`To let <b>${escapeHtmlForTg(agentName)}</b> use it, run on the host:\n` +
|
|
14493
|
+
`<code>switchroom auth microsoft enable ${escapeHtmlForTg(result.account)} ${escapeHtmlForTg(agentName)}</code>\n` +
|
|
14494
|
+
`then restart ${escapeHtmlForTg(agentName)}.`
|
|
14495
|
+
} else if (result.kind === 'no-refresh-token') {
|
|
14496
|
+
html =
|
|
14497
|
+
`⚠ Microsoft returned no refresh token (the account would expire in ~1h), ` +
|
|
14498
|
+
`so it was not registered. Try <code>/connect microsoft</code> again, or ` +
|
|
14499
|
+
`connect from the host CLI.`
|
|
14500
|
+
} else {
|
|
14501
|
+
html =
|
|
14502
|
+
`❌ <b>Connect failed:</b> ${escapeHtmlForTg(result.message)}\n\n` +
|
|
14503
|
+
`<i>Work/school accounts can't use the phone flow at /common — connect those ` +
|
|
14504
|
+
`from the host: <code>switchroom auth microsoft account add <email></code>.</i>`
|
|
14505
|
+
}
|
|
14506
|
+
await editMicrosoftConnectCard(flow.cardChatId, flow.cardMessageId, html)
|
|
14507
|
+
}
|
|
14508
|
+
|
|
14509
|
+
/**
|
|
14510
|
+
* `/connect microsoft` — Telegram-native device-code connect for a
|
|
14511
|
+
* Microsoft account (RFC #1873 Phase 2). The user signs in on their
|
|
14512
|
+
* phone; we register the account with the auth-broker (shipped default
|
|
14513
|
+
* app unless the operator BYO'd one). Admin-gated like `/auth add`.
|
|
14514
|
+
* `/connect cancel` aborts a pending flow. Google stays host-CLI.
|
|
14515
|
+
*/
|
|
14516
|
+
bot.command('connect', async ctx => {
|
|
14517
|
+
// Credential-plane admin is OPERATOR-PRIVATE (WS7-F2 / #1408), exactly
|
|
14518
|
+
// like `/auth`: honor `/connect` ONLY in a private chat from a strict
|
|
14519
|
+
// `access.allowFrom` sender — never the group-permissive
|
|
14520
|
+
// `isAuthorizedSender` (an empty group `allowFrom` = allow-all, so a
|
|
14521
|
+
// forum member could otherwise bind THEIR OWN Microsoft account as the
|
|
14522
|
+
// agent's credential). The agent-`admin:true` flag check below is
|
|
14523
|
+
// orthogonal and the broker enforces it server-side on add-account.
|
|
14524
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
14525
|
+
const operatorPrivate =
|
|
14526
|
+
ctx.chat?.type === 'private' && loadAccess().allowFrom.includes(senderId)
|
|
14527
|
+
if (!operatorPrivate) {
|
|
14528
|
+
if (ctx.chat?.type !== 'private') {
|
|
14529
|
+
process.stderr.write(
|
|
14530
|
+
`telegram gateway: /connect refused (not operator-private) agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} chat=${ctx.chat?.type ?? '?'} sender=${senderId}\n`,
|
|
14531
|
+
)
|
|
14532
|
+
await switchroomReply(
|
|
14533
|
+
ctx,
|
|
14534
|
+
`⚠️ <code>/connect</code> links account credentials — it is <b>operator-private</b>. ` +
|
|
14535
|
+
`Send it as a direct message to me from your operator account (a private chat where ` +
|
|
14536
|
+
`your Telegram ID is on the access allowlist), not in a group or forum.`,
|
|
14537
|
+
{ html: true },
|
|
14538
|
+
).catch(() => {})
|
|
14539
|
+
}
|
|
14540
|
+
return
|
|
14541
|
+
}
|
|
14542
|
+
|
|
14543
|
+
const arg = String(ctx.match ?? '').trim().toLowerCase()
|
|
14544
|
+
const chatId = String(ctx.chat?.id ?? '')
|
|
14545
|
+
const key = chatKey(chatId, ctx.message?.message_thread_id ?? null) as string
|
|
14546
|
+
|
|
14547
|
+
// Agent-`admin:true` gate (orthogonal to operator-private above; same
|
|
14548
|
+
// source as /auth + the broker's server-side add-account enforcement).
|
|
14549
|
+
let isAdmin = false
|
|
14550
|
+
try {
|
|
14551
|
+
const cfg = loadSwitchroomConfig()
|
|
14552
|
+
const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean }> })
|
|
14553
|
+
?.agents?.[getMyAgentName()]
|
|
14554
|
+
isAdmin = me?.admin === true
|
|
14555
|
+
} catch { /* non-admin is the safe default */ }
|
|
14556
|
+
if (!isAuthAdmin({ isAdmin })) {
|
|
14557
|
+
await switchroomReply(
|
|
14558
|
+
ctx,
|
|
14559
|
+
`<b>Not authorized.</b> <code>/connect</code> requires this agent to have ` +
|
|
14560
|
+
`<code>admin: true</code> in switchroom.yaml.`,
|
|
14561
|
+
{ html: true },
|
|
14562
|
+
)
|
|
14563
|
+
return
|
|
14564
|
+
}
|
|
14565
|
+
|
|
14566
|
+
if (arg === 'cancel') {
|
|
14567
|
+
const existing = pendingMicrosoftConnectFlows.get(key)
|
|
14568
|
+
if (!existing) {
|
|
14569
|
+
await switchroomReply(ctx, '<i>No pending connect flow in this chat.</i>', { html: true })
|
|
14570
|
+
return
|
|
14571
|
+
}
|
|
14572
|
+
existing.cancelled = true
|
|
14573
|
+
pendingMicrosoftConnectFlows.delete(key)
|
|
14574
|
+
await switchroomReply(ctx, 'Cancelled.', { html: true })
|
|
14575
|
+
return
|
|
14576
|
+
}
|
|
14577
|
+
|
|
14578
|
+
if (arg !== '' && arg !== 'microsoft' && arg !== 'ms') {
|
|
14579
|
+
await switchroomReply(
|
|
14580
|
+
ctx,
|
|
14581
|
+
`<b>Usage:</b> <code>/connect microsoft</code> to link a Microsoft account ` +
|
|
14582
|
+
`(Outlook / Office 365), or <code>/connect cancel</code>.\n` +
|
|
14583
|
+
`<i>Google accounts are connected from the host CLI.</i>`,
|
|
14584
|
+
{ html: true },
|
|
14585
|
+
)
|
|
14586
|
+
return
|
|
14587
|
+
}
|
|
14588
|
+
|
|
14589
|
+
if (pendingMicrosoftConnectFlows.has(key)) {
|
|
14590
|
+
await switchroomReply(
|
|
14591
|
+
ctx,
|
|
14592
|
+
'<i>A Microsoft connect flow is already in progress here. Finish it on your phone, ' +
|
|
14593
|
+
'or send <code>/connect cancel</code>.</i>',
|
|
14594
|
+
{ html: true },
|
|
14595
|
+
)
|
|
14596
|
+
return
|
|
14597
|
+
}
|
|
14598
|
+
|
|
14599
|
+
let configClientId: string | undefined
|
|
14600
|
+
let orgMode = false
|
|
14601
|
+
try {
|
|
14602
|
+
const cfg = loadSwitchroomConfig() as unknown as {
|
|
14603
|
+
microsoft_workspace?: { microsoft_client_id?: string; org_mode?: boolean }
|
|
14604
|
+
}
|
|
14605
|
+
configClientId = cfg?.microsoft_workspace?.microsoft_client_id
|
|
14606
|
+
orgMode = cfg?.microsoft_workspace?.org_mode === true
|
|
14607
|
+
} catch { /* fall back to the shipped default */ }
|
|
14608
|
+
|
|
14609
|
+
const started = await startMicrosoftConnect({ configClientId, orgMode })
|
|
14610
|
+
if (started.kind === 'byo-vault') {
|
|
14611
|
+
await switchroomReply(
|
|
14612
|
+
ctx,
|
|
14613
|
+
`<b>Can't connect from chat:</b> this install uses a vaulted custom Microsoft ` +
|
|
14614
|
+
`app (<code>${escapeHtmlForTg(started.ref)}</code>) only the host CLI can read. ` +
|
|
14615
|
+
`Run <code>switchroom auth microsoft account add <email></code> on the host.`,
|
|
14616
|
+
{ html: true },
|
|
14617
|
+
)
|
|
14618
|
+
return
|
|
14619
|
+
}
|
|
14620
|
+
if (started.kind === 'error') {
|
|
14621
|
+
await switchroomReply(
|
|
14622
|
+
ctx,
|
|
14623
|
+
`<b>/connect failed:</b> ${escapeHtmlForTg(started.message)}`,
|
|
14624
|
+
{ html: true },
|
|
14625
|
+
)
|
|
14626
|
+
return
|
|
14627
|
+
}
|
|
14628
|
+
|
|
14629
|
+
const appNote =
|
|
14630
|
+
started.source === 'default'
|
|
14631
|
+
? "<i>Using switchroom's shipped Microsoft app.</i>"
|
|
14632
|
+
: '<i>Using your configured Microsoft app.</i>'
|
|
14633
|
+
const keyboard = new InlineKeyboard()
|
|
14634
|
+
.url('🔐 Sign in to Microsoft', started.device.verification_uri)
|
|
14635
|
+
.row()
|
|
14636
|
+
.text('✖ Cancel', `cn:cancel:${key}`)
|
|
14637
|
+
const sent = await ctx.reply(
|
|
14638
|
+
`🔗 <b>Connect a Microsoft account</b>\n\n` +
|
|
14639
|
+
`1. Tap <b>Sign in to Microsoft</b> below.\n` +
|
|
14640
|
+
`2. Enter this code: <code>${escapeHtmlForTg(started.device.user_code)}</code>\n` +
|
|
14641
|
+
`3. Approve the requested permissions (Mail, Calendar, Files).\n\n` +
|
|
14642
|
+
`I'll confirm here once it's connected (within ~15 min).\n${appNote}`,
|
|
14643
|
+
{
|
|
14644
|
+
parse_mode: 'HTML',
|
|
14645
|
+
link_preview_options: { is_disabled: true },
|
|
14646
|
+
reply_markup: keyboard,
|
|
14647
|
+
...(ctx.message?.message_thread_id != null
|
|
14648
|
+
? { message_thread_id: ctx.message.message_thread_id }
|
|
14649
|
+
: {}),
|
|
14650
|
+
},
|
|
14651
|
+
)
|
|
14652
|
+
|
|
14653
|
+
pendingMicrosoftConnectFlows.set(key, {
|
|
14654
|
+
initiatedBy: String(ctx.from?.id ?? ''),
|
|
14655
|
+
cardChatId: ctx.chat!.id,
|
|
14656
|
+
cardMessageId: sent.message_id,
|
|
14657
|
+
device: started.device,
|
|
14658
|
+
clientId: started.clientId,
|
|
14659
|
+
scopes: started.scopes,
|
|
14660
|
+
startedAt: Date.now(),
|
|
14661
|
+
cancelled: false,
|
|
14662
|
+
})
|
|
14663
|
+
|
|
14664
|
+
// Background: poll Microsoft, register the account, edit the card.
|
|
14665
|
+
void finalizeMicrosoftConnect(key)
|
|
14666
|
+
})
|
|
14667
|
+
|
|
14312
14668
|
bot.command("auth", async ctx => {
|
|
14313
14669
|
// sec WS7-F2b (#1394): `/auth` drives the auth-broker credential
|
|
14314
14670
|
// lifecycle (`/auth add` mints/attaches an Anthropic account token,
|
|
@@ -17054,6 +17410,37 @@ bot.on('callback_query:data', async ctx => {
|
|
|
17054
17410
|
return
|
|
17055
17411
|
}
|
|
17056
17412
|
|
|
17413
|
+
// `cn:cancel:<key>` — cancel a pending Microsoft connect flow (the
|
|
17414
|
+
// Cancel button on the /connect card). RFC #1873 Phase 2.
|
|
17415
|
+
if (data.startsWith('cn:')) {
|
|
17416
|
+
const access = loadAccess()
|
|
17417
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
17418
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
17419
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' })
|
|
17420
|
+
return
|
|
17421
|
+
}
|
|
17422
|
+
const rest = data.slice('cn:'.length)
|
|
17423
|
+
const sep = rest.indexOf(':')
|
|
17424
|
+
const action = sep >= 0 ? rest.slice(0, sep) : rest
|
|
17425
|
+
const flowKey = sep >= 0 ? rest.slice(sep + 1) : ''
|
|
17426
|
+
if (action === 'cancel') {
|
|
17427
|
+
const pending = pendingMicrosoftConnectFlows.get(flowKey)
|
|
17428
|
+
if (pending) {
|
|
17429
|
+
pending.cancelled = true
|
|
17430
|
+
pendingMicrosoftConnectFlows.delete(flowKey)
|
|
17431
|
+
}
|
|
17432
|
+
await ctx.answerCallbackQuery({ text: 'Connect cancelled.' })
|
|
17433
|
+
await ctx
|
|
17434
|
+
.editMessageText('Microsoft connect cancelled.', {
|
|
17435
|
+
reply_markup: { inline_keyboard: [] },
|
|
17436
|
+
})
|
|
17437
|
+
.catch(() => {})
|
|
17438
|
+
} else {
|
|
17439
|
+
await ctx.answerCallbackQuery()
|
|
17440
|
+
}
|
|
17441
|
+
return
|
|
17442
|
+
}
|
|
17443
|
+
|
|
17057
17444
|
// RFC B §6.1: apv:<request_id>:<choice>[:<param>] — approval kernel taps.
|
|
17058
17445
|
// Routed through the generic kernel handler so any surface that uses
|
|
17059
17446
|
// buildApprovalCard inherits consume → record → confirmation UX without
|