From 94bffe6760519661328535d004ebf9153cafbe9e Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Thu, 6 Aug 2026 23:36:34 +0100 Subject: [PATCH] fix: don't burn a Telegram pairing code on the way out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_pairing_code consumes the pending entry and save_config writes that consumption, so from that line on the code is spent — but the handler then returned `?` on the per-user status blob. A failure there sent the user back to the form holding a code that now reads "invalid or expired": the one message guaranteed to make a pairing that actually succeeded look like one that never happened. The blob is what the page renders as "linked"; the binding is real without it, so it warns instead. The same write also refreshes shared.bindings directly. The dispatcher learns the new binding through the ConfigKeyUpdated broadcast, which is lossy, and a dropped event would leave the bot treating the chat as unbound — asking the user to pair again, immediately after pairing. The event is now a confirmation, not the delivery, on both sides of the flow. --- crates/plugin-telegram-bot/src/lib.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/plugin-telegram-bot/src/lib.rs b/crates/plugin-telegram-bot/src/lib.rs index 791c80c..64310db 100644 --- a/crates/plugin-telegram-bot/src/lib.rs +++ b/crates/plugin-telegram-bot/src/lib.rs @@ -251,9 +251,29 @@ impl Plugin for TelegramPlugin { let mut cfg = auth::load_config(&*shared.config).await?; let chat_id = auth::apply_pairing_code(&mut cfg, code, user_id)?; auth::save_config(&*shared.config, &cfg).await?; - ctx.user_config + + // The code is spent the moment that write lands, so everything after it + // must be best-effort: an error from here on sends the user back to a + // form where their code now reads as "invalid or expired", which is the + // one message guaranteed to make them think the pairing never happened. + // + // Refreshing the cache is the same lossy-bus hole as on the issuing side + // (`auth::handle_pairing`): the binding reaches the dispatcher through a + // `ConfigKeyUpdated` broadcast, and a dropped event would leave the bot + // treating this chat as unbound — asking to pair again, right after a + // pairing that in fact succeeded. Writing it here makes the event a + // confirmation rather than the delivery. + *shared.bindings.write().await = cfg; + + // The status blob is what the page renders as "linked"; the binding is + // already real without it. + if let Err(e) = ctx.user_config .set(self.id(), user_id, json!({ "linked": true, "chat_id": chat_id })) - .await?; + .await + { + warn!(user_id, chat_id, error = %e, + "telegram: paired, but the per-user status blob could not be stored"); + } info!(user_id, chat_id, "telegram: user self-paired via the web UI"); Ok(()) }