mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
Fix Telegram auto-verify flow and routing (#1273)
* Fix Telegram auto-verify flow and routing * Fix CI formatting and clippy follow-ups * Simplify Telegram waiting state update * Fix notification fallback scopes * Fix message metadata routing and zh-CN copy
This commit is contained in:
+165
-11
@@ -140,6 +140,13 @@ struct TelegramGetUpdatesResponse {
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct TelegramApiOkResponse {
|
||||
ok: bool,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct TelegramUpdate {
|
||||
update_id: i64,
|
||||
@@ -204,7 +211,7 @@ fn channel_auth_instructions(
|
||||
) -> String {
|
||||
if channel_name == TELEGRAM_CHANNEL_NAME && secret.name == "telegram_bot_token" {
|
||||
return format!(
|
||||
"{} After you submit it, IronClaw will show a one-time verification code. Send `/start CODE` to your bot in Telegram, then verify again to bind the owner.",
|
||||
"{} After you submit it, IronClaw will show a one-time verification code. Send `/start CODE` to your bot in Telegram and IronClaw will finish setup automatically.",
|
||||
secret.prompt
|
||||
);
|
||||
}
|
||||
@@ -237,10 +244,12 @@ fn telegram_verification_deep_link(bot_username: Option<&str>, code: &str) -> Op
|
||||
|
||||
fn telegram_verification_instructions(bot_username: Option<&str>, code: &str) -> String {
|
||||
if let Some(username) = bot_username.filter(|username| !username.trim().is_empty()) {
|
||||
return format!("Send `/start {code}` to @{username}, then click Verify owner.");
|
||||
return format!(
|
||||
"Send `/start {code}` to @{username} in Telegram. IronClaw will finish setup automatically."
|
||||
);
|
||||
}
|
||||
|
||||
format!("Send `/start {code}` to your Telegram bot, then click Verify owner.")
|
||||
format!("Send `/start {code}` to your Telegram bot. IronClaw will finish setup automatically.")
|
||||
}
|
||||
|
||||
fn telegram_message_matches_verification_code(text: &str, code: &str) -> bool {
|
||||
@@ -253,6 +262,42 @@ fn telegram_message_matches_verification_code(text: &str, code: &str) -> bool {
|
||||
.any(|token| token == code)
|
||||
}
|
||||
|
||||
async fn send_telegram_text_message(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
chat_id: i64,
|
||||
text: &str,
|
||||
) -> Result<(), ExtensionError> {
|
||||
let response = client
|
||||
.post(endpoint)
|
||||
.json(&serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| telegram_request_error("sendMessage", &e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(ExtensionError::Other(format!(
|
||||
"Telegram sendMessage failed (HTTP {})",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let payload: TelegramApiOkResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| telegram_response_parse_error("sendMessage", &e))?;
|
||||
if !payload.ok {
|
||||
return Err(ExtensionError::Other(payload.description.unwrap_or_else(
|
||||
|| "Telegram sendMessage returned ok=false".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Central manager for extension lifecycle operations.
|
||||
///
|
||||
/// # Initialization Order
|
||||
@@ -421,6 +466,29 @@ impl ExtensionManager {
|
||||
*self.test_telegram_binding_resolver.write().await = Some(resolver);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn set_test_telegram_pending_verification(
|
||||
&self,
|
||||
code: &str,
|
||||
bot_username: Option<&str>,
|
||||
) {
|
||||
let code = code.to_string();
|
||||
let bot_username = bot_username.map(str::to_string);
|
||||
self.set_test_telegram_binding_resolver(Arc::new(move |_token, existing_owner_id| {
|
||||
if existing_owner_id.is_some() {
|
||||
return Err(ExtensionError::Other(
|
||||
"unexpected existing owner binding".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(TelegramBindingResult::Pending(VerificationChallenge {
|
||||
code: code.clone(),
|
||||
instructions: telegram_verification_instructions(bot_username.as_deref(), &code),
|
||||
deep_link: telegram_verification_deep_link(bot_username.as_deref(), &code),
|
||||
}))
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Enable gateway mode so OAuth flows return auth URLs to the frontend
|
||||
/// instead of calling `open::that()` on the server.
|
||||
///
|
||||
@@ -597,6 +665,12 @@ impl ExtensionManager {
|
||||
self.current_channel_owner_id(name).await.is_some()
|
||||
}
|
||||
|
||||
pub(crate) async fn notification_target_for_channel(&self, name: &str) -> Option<String> {
|
||||
self.current_channel_owner_id(name)
|
||||
.await
|
||||
.map(|owner_id| owner_id.to_string())
|
||||
}
|
||||
|
||||
async fn get_pending_telegram_verification(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -1074,7 +1148,7 @@ impl ExtensionManager {
|
||||
active,
|
||||
tools: Vec::new(),
|
||||
needs_setup: auth_state == ToolAuthState::NeedsSetup,
|
||||
has_auth: false,
|
||||
has_auth: auth_state != ToolAuthState::NoAuth,
|
||||
installed: true,
|
||||
activation_error,
|
||||
version,
|
||||
@@ -4336,6 +4410,22 @@ impl ExtensionManager {
|
||||
}
|
||||
|
||||
if let Some(owner_id) = bound_owner_id {
|
||||
if let Err(err) = send_telegram_text_message(
|
||||
&client,
|
||||
&format!("https://api.telegram.org/bot{bot_token}/sendMessage"),
|
||||
owner_id,
|
||||
"Verification received. Finishing setup...",
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
channel = name,
|
||||
owner_id,
|
||||
error = %err,
|
||||
"Failed to send Telegram verification acknowledgment"
|
||||
);
|
||||
}
|
||||
|
||||
self.clear_pending_telegram_verification(name).await;
|
||||
if offset > 0 {
|
||||
let _ = client
|
||||
@@ -4355,10 +4445,10 @@ impl ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
Err(ExtensionError::ValidationFailed(format!(
|
||||
"Telegram owner verification timed out. Send `/start {}` to your bot, then click Verify owner again.",
|
||||
challenge.code
|
||||
)))
|
||||
self.clear_pending_telegram_verification(name).await;
|
||||
Err(ExtensionError::ValidationFailed(
|
||||
"Telegram owner verification timed out. Request a new code and try again.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn notify_telegram_owner_verified(
|
||||
@@ -5120,7 +5210,7 @@ mod tests {
|
||||
use crate::extensions::manager::{
|
||||
ChannelRuntimeState, FallbackDecision, TelegramBindingData, TelegramBindingResult,
|
||||
TelegramOwnerBindingState, build_wasm_channel_runtime_config_updates,
|
||||
combine_install_errors, fallback_decision, infer_kind_from_url,
|
||||
combine_install_errors, fallback_decision, infer_kind_from_url, send_telegram_text_message,
|
||||
telegram_message_matches_verification_code,
|
||||
};
|
||||
use crate::extensions::{
|
||||
@@ -5923,7 +6013,7 @@ mod tests {
|
||||
Ok(TelegramBindingResult::Pending(VerificationChallenge {
|
||||
code: "iclaw-7qk2m9".to_string(),
|
||||
instructions:
|
||||
"Send `/start iclaw-7qk2m9` to @test_hot_bot, then click Verify owner."
|
||||
"Send `/start iclaw-7qk2m9` to @test_hot_bot in Telegram. IronClaw will finish setup automatically."
|
||||
.to_string(),
|
||||
deep_link: Some("https://t.me/test_hot_bot?start=iclaw-7qk2m9".to_string()),
|
||||
}))
|
||||
@@ -6875,11 +6965,75 @@ mod tests {
|
||||
)?;
|
||||
require(
|
||||
instructions.contains("one-time verification code")
|
||||
&& instructions.contains("/start CODE"),
|
||||
&& instructions.contains("/start CODE")
|
||||
&& instructions.contains("finish setup automatically"),
|
||||
"telegram auth instructions should explain the owner verification step",
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_telegram_text_message_posts_expected_payload() -> Result<(), String> {
|
||||
use axum::{Json, Router, extract::State, routing::post};
|
||||
|
||||
let payloads = Arc::new(tokio::sync::Mutex::new(Vec::<serde_json::Value>::new()));
|
||||
|
||||
async fn handler(
|
||||
State(payloads): State<Arc<tokio::sync::Mutex<Vec<serde_json::Value>>>>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> Json<serde_json::Value> {
|
||||
payloads.lock().await.push(payload);
|
||||
Json(serde_json::json!({ "ok": true, "result": {} }))
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/sendMessage", post(handler))
|
||||
.with_state(Arc::clone(&payloads));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|err| format!("bind listener: {err}"))?;
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.map_err(|err| format!("listener addr: {err}"))?;
|
||||
let server = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
send_telegram_text_message(
|
||||
&client,
|
||||
&format!("http://{addr}/sendMessage"),
|
||||
424242,
|
||||
"Verification received. Finishing setup...",
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("send message: {err}"))?;
|
||||
|
||||
let captured = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
loop {
|
||||
let maybe_payload = { payloads.lock().await.first().cloned() };
|
||||
if let Some(payload) = maybe_payload {
|
||||
break payload;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "timed out waiting for sendMessage payload".to_string())?;
|
||||
|
||||
server.abort();
|
||||
|
||||
require_eq(
|
||||
captured["chat_id"].clone(),
|
||||
serde_json::json!(424242),
|
||||
"chat_id",
|
||||
)?;
|
||||
require_eq(
|
||||
captured["text"].clone(),
|
||||
serde_json::json!("Verification received. Finishing setup..."),
|
||||
"text",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_message_matches_verification_code_variants() -> Result<(), String> {
|
||||
require(
|
||||
|
||||
Reference in New Issue
Block a user