switchroom 0.16.46 β†’ 0.16.47

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.
@@ -20160,20 +20160,61 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
20160
20160
  // cards open at once.
20161
20161
  const joiningBatch = items.length > 1
20162
20162
  await ctx.answerCallbackQuery({ text: joiningBatch ? `πŸ” Queued β€” one passphrase covers ${items.length} cards` : 'πŸ” Send your passphrase…' }).catch(() => {})
20163
+
20164
+ // Strip the buttons on the ORIGINAL card and mark it "waiting" so it
20165
+ // can't be re-tapped, but do NOT overload it as the passphrase prompt.
20166
+ // An in-place edit fires no notification and stays pinned to the card's
20167
+ // old position in the chat, so a busy topic buries it and the operator
20168
+ // never sees the passphrase ask β€” the exact admin-key miss this fixes
20169
+ // (v0.16.45: the prompt scrolled off, the passphrase never arrived, the
20170
+ // grant was never minted). The prompt goes out as a fresh message below.
20163
20171
  await ctx.api
20164
20172
  .editMessageText(
20165
20173
  pending.chat_id,
20166
20174
  pending.card_message_id,
20167
- joiningBatch
20168
- ? `πŸ” **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).`
20169
- : isAdminOnly
20170
- ? `πŸ”’ **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` +
20171
- `_The passphrase is what proves it's you: an agent can never mint this key on its own._`
20172
- : `πŸ” **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` +
20173
- richMessage(`_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches._`),
20175
+ richMessage(`πŸ” _Approved β€” waiting for your vault passphrase. See the prompt below._`),
20174
20176
  { reply_markup: { inline_keyboard: [] } },
20175
20177
  )
20176
20178
  .catch(() => {})
20179
+
20180
+ // The passphrase prompt as a NEW rich message. Three fixes vs. the old
20181
+ // in-place edit, all of which the reported bug needed:
20182
+ // 1. Real bold/italic β€” rendered through the sanctioned `richMessage`
20183
+ // GFM path (`sendRichMessage`), never a raw string. The old admin
20184
+ // and joining-batch branches passed raw markdown to editMessageText
20185
+ // (parse_mode=none), so `**`/`_` rendered as literal characters;
20186
+ // the "locked" branch even concatenated a string with a
20187
+ // `richMessage()` object (β†’ `[object Object]`). All three are gone.
20188
+ // 2. It lands at the BOTTOM of the chat, not stapled to an old card
20189
+ // that later messages bury.
20190
+ // 3. It fires a notification β€” `disable_notification` is deliberately
20191
+ // NOT set β€” so the operator is actually pinged to act.
20192
+ // Attention-grabbing header, short lines, key in code formatting.
20193
+ const promptText = joiningBatch
20194
+ ? `**βš οΈπŸ” ACTION NEEDED: passphrase required**\n\n` +
20195
+ `Type your vault passphrase as your **next message**.\n` +
20196
+ `One entry covers **${items.length}** pending approvals in this chat, no re-type per card.\n\n` +
20197
+ `_We delete the passphrase message the moment we read it._`
20198
+ : isAdminOnly
20199
+ ? `**βš οΈπŸ” ACTION NEEDED: passphrase required**\n\n` +
20200
+ `\`${pending.key}\` is an **admin-only credential**.\n` +
20201
+ `Type your vault passphrase as your **next message** to mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
20202
+ `_The passphrase is what proves it's you. An agent can never mint this key on its own. We delete the passphrase message the moment we read it._`
20203
+ : `**βš οΈπŸ” ACTION NEEDED: passphrase required**\n\n` +
20204
+ `Your vault is locked.\n` +
20205
+ `Reply with your passphrase as your **next message** to unlock and mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
20206
+ `_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches. We delete the passphrase message the moment we read it._`
20207
+
20208
+ // #1075: deleted-topic safe β€” fall back to the main chat. Wrapped
20209
+ // through robustApiCall for flood-wait retries, mirroring the card send.
20210
+ await retryWithThreadFallback<{ message_id: number }>(
20211
+ robustApiCall,
20212
+ (tid) =>
20213
+ lockedBot.api.sendRichMessage(pending.chat_id, richMessage(promptText), {
20214
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
20215
+ }),
20216
+ { threadId: pending.threadId, chat_id: pending.chat_id, verb: 'vault_request_access.passphrase_prompt' },
20217
+ ).catch(() => {})
20177
20218
  return
20178
20219
  }
20179
20220
 
@@ -54,6 +54,52 @@ describe('vault_request_access β€” tap-to-unlock-and-approve UX', () => {
54
54
  expect(approveBlock).not.toMatch(/ask the agent to re-issue the request card/)
55
55
  })
56
56
 
57
+ it('passphrase prompt goes out as a NEW rich message, not an in-place edit', () => {
58
+ // fails when: the cache-miss branch reverts to overloading the
59
+ // ORIGINAL card as the prompt via editMessageText. An in-place
60
+ // edit fires no notification and stays stapled to the card's old
61
+ // position, so a busy topic buries it and the operator never sees
62
+ // the passphrase ask (the reported v0.16.45 admin-key miss). The
63
+ // prompt must be a fresh `sendRichMessage` below.
64
+ const approveBlock =
65
+ gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
66
+ // A distinct passphrase-prompt send exists (verb-tagged).
67
+ expect(approveBlock).toMatch(/sendRichMessage\(pending\.chat_id, richMessage\(promptText\)/)
68
+ expect(approveBlock).toMatch(/vault_request_access\.passphrase_prompt/)
69
+ })
70
+
71
+ it('passphrase prompt renders via richMessage β€” no raw literal-markdown edit', () => {
72
+ // ROOT CAUSE of the reported bug: the old admin-only and
73
+ // joining-batch branches passed RAW markdown strings (with literal
74
+ // `**`/`_`) straight to editMessageText (parse_mode=none), so the
75
+ // bold/italic rendered as literal characters, and the "locked"
76
+ // branch concatenated a string with a richMessage() object
77
+ // (β†’ "[object Object]"). Every branch must now flow through the
78
+ // richMessage() GFM render path.
79
+ const approveBlock =
80
+ gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
81
+ // The prompt text is assembled once and wrapped in richMessage.
82
+ expect(approveBlock).toMatch(/const promptText =/)
83
+ // Regression guard: the old raw-string admin-only edit copy is gone.
84
+ expect(approveBlock).not.toMatch(/requires your vault passphrase to grant/)
85
+ // Regression guard: no string-concatenated richMessage() object
86
+ // (the "[object Object]" bug) remains.
87
+ expect(approveBlock).not.toMatch(/\+\s*\n\s*richMessage\(/)
88
+ })
89
+
90
+ it('passphrase prompt is attention-grabbing and does NOT suppress notifications', () => {
91
+ // fails when: the prompt loses its strong header or someone adds
92
+ // disable_notification to it. The whole point of the fix is that
93
+ // the operator gets PINGED β€” a silent prompt is the bug.
94
+ const approveBlock =
95
+ gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
96
+ expect(approveBlock).toMatch(/ACTION NEEDED: passphrase required/)
97
+ // The send options for the prompt must not carry disable_notification.
98
+ const promptSend =
99
+ approveBlock.split('const promptText =')[1]?.split('return')[0] ?? ''
100
+ expect(promptSend).not.toMatch(/disable_notification/)
101
+ })
102
+
57
103
  it('passphrase intercept deletes the chat message and resumes mint', () => {
58
104
  // fails when: the new pending-op handler stops calling
59
105
  // deleteSensitiveMessage on the passphrase message OR stops