From 0c119b5c1e68d90cd70663626410fd236f2035d2 Mon Sep 17 00:00:00 2001 From: Nick Pismenkov Date: Mon, 16 Mar 2026 22:31:54 -0700 Subject: [PATCH] fix: Telegram pairing approval required for existing bots / existing pairing skipped on reconfigure --- src/channels/web/static/app.js | 14 ++++-- src/channels/web/static/style.css | 7 +++ src/extensions/manager.rs | 20 ++++++++ src/pairing/store.rs | 78 +++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 9d931500..f67e21df 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -2640,7 +2640,7 @@ function renderExtensionCard(ext) { pairingSection.className = 'ext-pairing'; pairingSection.setAttribute('data-channel', ext.name); card.appendChild(pairingSection); - loadPairingRequests(ext.name, pairingSection); + loadPairingRequests(ext.name, pairingSection, ext.activation_status); } return card; @@ -3034,11 +3034,19 @@ function openOAuthUrl(url) { // --- Pairing --- -function loadPairingRequests(channel, container) { +function loadPairingRequests(channel, container, status) { apiFetch('/api/pairing/' + encodeURIComponent(channel)) .then(data => { container.innerHTML = ''; - if (!data.requests || data.requests.length === 0) return; + if (!data.requests || data.requests.length === 0) { + if (status === 'pairing') { + const hint = document.createElement('p'); + hint.className = 'pairing-hint'; + hint.textContent = 'Send any message to your bot to receive a pairing request here.'; + container.appendChild(hint); + } + return; + } const heading = document.createElement('div'); heading.className = 'pairing-heading'; diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 06d9665a..d094606b 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -2865,6 +2865,13 @@ body { flex: 1; } +.pairing-hint { + color: var(--text-secondary); + font-size: 13px; + margin: 4px 0 8px; + font-style: italic; +} + /* Configure modal */ .configure-overlay { position: fixed; diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 00d787a5..b5742c18 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -3739,6 +3739,26 @@ impl ExtensionManager { } }; + // Credentials changed (new bot token) — clear pairing state so existing users + // must re-approve with the new bot identity. + if cred_count > 0 { + let pairing_store = crate::pairing::PairingStore::new(); + if let Err(e) = pairing_store.clear_allow_from(name) { + tracing::warn!( + channel = %name, + error = %e, + "Failed to clear allow-from on credential refresh" + ); + } + if let Err(e) = pairing_store.clear_pending(name) { + tracing::warn!( + channel = %name, + error = %e, + "Failed to clear pending pairings on credential refresh" + ); + } + } + // Load capabilities file once to extract all secret names let cap_path = self .wasm_channels_dir diff --git a/src/pairing/store.rs b/src/pairing/store.rs index 6c0882fd..509efd63 100644 --- a/src/pairing/store.rs +++ b/src/pairing/store.rs @@ -440,6 +440,39 @@ impl PairingStore { Ok(file.allow_from) } + /// Clear the allow-from list for a channel. + /// + /// Called on credential refresh so that existing users must re-approve + /// after a bot token change. + pub fn clear_allow_from(&self, channel: &str) -> Result<(), PairingStoreError> { + let path = allow_from_path(&self.base_dir, channel)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&path)?; + file.lock_exclusive()?; + let store = AllowFromStoreFile { + version: 1, + allow_from: Vec::new(), + }; + let json = serde_json::to_string_pretty(&store)?; + fs::write(&path, json)?; + fs4::FileExt::unlock(&file)?; + Ok(()) + } + + /// Clear all pending pairing requests for a channel. + /// + /// Called on credential refresh so stale requests don't confuse users. + pub fn clear_pending(&self, channel: &str) -> Result<(), PairingStoreError> { + self.write_pairing_file(channel, &[]) + } + /// Check if a sender is allowed (by id or username). pub fn is_sender_allowed( &self, @@ -717,4 +750,49 @@ mod tests { store.list_pending("").unwrap_err(); store.upsert_request("", "u1", None).unwrap_err(); } + + #[test] + fn test_clear_allow_from_removes_all_entries() { + let (store, _) = test_store(); + let r1 = store.upsert_request("telegram", "user1", None).unwrap(); + store.approve("telegram", &r1.code).unwrap(); + + let list = store.read_allow_from("telegram").unwrap(); + assert_eq!(list.len(), 1); + + store.clear_allow_from("telegram").unwrap(); + let list = store.read_allow_from("telegram").unwrap(); + assert!(list.is_empty()); + } + + #[test] + fn test_clear_pending_removes_all_requests() { + let (store, _) = test_store(); + store + .upsert_request("telegram", "user1", Some(serde_json::json!({"chat_id": 1}))) + .unwrap(); + store + .upsert_request("telegram", "user2", Some(serde_json::json!({"chat_id": 2}))) + .unwrap(); + + let requests = store.list_pending("telegram").unwrap(); + assert_eq!(requests.len(), 2); + + store.clear_pending("telegram").unwrap(); + let requests = store.list_pending("telegram").unwrap(); + assert!(requests.is_empty()); + } + + #[test] + fn test_clear_allow_from_allows_new_approval() { + let (store, _) = test_store(); + let r1 = store.upsert_request("telegram", "user1", None).unwrap(); + store.approve("telegram", &r1.code).unwrap(); + + assert!(store.is_sender_allowed("telegram", "user1", None).unwrap()); + + store.clear_allow_from("telegram").unwrap(); + + assert!(!store.is_sender_allowed("telegram", "user1", None).unwrap()); + } }