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.
@@ -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 exclusively by the `/auth add`
60
- * chat flow — the narrow {@link AuthBrokerClient} surface in
61
- * `auth-command.ts` deliberately omits `addAccount` because the verb
62
- * is gateway-routed (not handler-routed). Constructs and closes a
63
- * one-shot {@link BrokerClient} so the gateway doesn't need a
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(label, credentials, opts.replace)
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
- const obligationLedger = new ObligationLedger(OBLIGATION_REPRESENT_MAX)
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 — close FIRST so the loop ends even if the send fails.
4852
- obligationLedger.close(o.originTurnId)
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 escalated (exhausted ${OBLIGATION_REPRESENT_MAX} re-presents) origin=${o.originTurnId}\n`,
4958
+ `telegram gateway: obligation escalating (exhausted ${OBLIGATION_REPRESENT_MAX} re-presents) origin=${escId} attempt=${attempt}/${OBLIGATION_ESCALATE_MAX}\n`,
4855
4959
  )
4856
- void robustApiCall(
4857
- () =>
4858
- bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
4859
- ...(o.threadId != null ? { message_thread_id: o.threadId } : {}),
4860
- }),
4861
- { chat_id: o.chatId, ...(o.threadId != null ? { threadId: o.threadId } : {}), verb: 'obligation.escalate' },
4862
- ).catch((err) => {
4863
- process.stderr.write(`telegram gateway: obligation escalation send failed: ${err}\n`)
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 &lt;email&gt;</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 &lt;email&gt;</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