mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +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:
@@ -885,6 +885,36 @@ async def test_auth_and_configure_helpers_escape_selector_sensitive_extension_na
|
||||
assert result["configureStillPresent"] is False
|
||||
|
||||
|
||||
async def test_auth_required_does_not_reopen_existing_configure_modal(page):
|
||||
"""Regression: auth_required SSE should not clobber an already-open configure modal."""
|
||||
result = await page.evaluate(
|
||||
"""() => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'configure-overlay';
|
||||
overlay.setAttribute('data-extension-name', 'telegram');
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const originalShowConfigureModal = window.showConfigureModal;
|
||||
const originalSetAuthFlowPending = window.setAuthFlowPending;
|
||||
let showCalls = 0;
|
||||
let pendingCalls = 0;
|
||||
|
||||
window.showConfigureModal = () => { showCalls += 1; };
|
||||
window.setAuthFlowPending = () => { pendingCalls += 1; };
|
||||
|
||||
handleAuthRequired({ extension_name: 'telegram', instructions: 'pending', auth_url: null });
|
||||
|
||||
window.showConfigureModal = originalShowConfigureModal;
|
||||
window.setAuthFlowPending = originalSetAuthFlowPending;
|
||||
overlay.remove();
|
||||
return { showCalls, pendingCalls };
|
||||
}"""
|
||||
)
|
||||
|
||||
assert result["showCalls"] == 0
|
||||
assert result["pendingCalls"] == 0
|
||||
|
||||
|
||||
async def test_auth_completed_sse_dismisses_card(page):
|
||||
"""Simulating the auth_completed SSE event removes the auth card."""
|
||||
await _show_auth_card(page, extension_name="myext")
|
||||
|
||||
@@ -125,6 +125,8 @@ async def test_telegram_hot_activation_transitions_installed_to_active(page):
|
||||
phase = {"value": "installed"}
|
||||
captured_setup_payloads = []
|
||||
post_count = {"value": 0}
|
||||
second_request_started = asyncio.Event()
|
||||
allow_second_response = asyncio.Event()
|
||||
|
||||
async def handle_ext_list(route):
|
||||
extensions = {
|
||||
@@ -170,16 +172,18 @@ async def test_telegram_hot_activation_transitions_installed_to_active(page):
|
||||
{
|
||||
"success": True,
|
||||
"activated": False,
|
||||
"message": "Configuration saved for 'telegram'. Send `/start iclaw-7qk2m9` to @test_hot_bot, then click Verify owner.",
|
||||
"message": "Configuration saved for 'telegram'. Send `/start iclaw-7qk2m9` to @test_hot_bot in Telegram. IronClaw will finish setup automatically.",
|
||||
"verification": {
|
||||
"code": "iclaw-7qk2m9",
|
||||
"instructions": "Send `/start iclaw-7qk2m9` to @test_hot_bot, then click Verify owner.",
|
||||
"instructions": "Send `/start iclaw-7qk2m9` to @test_hot_bot in Telegram. IronClaw will finish setup automatically.",
|
||||
"deep_link": "https://t.me/test_hot_bot?start=iclaw-7qk2m9",
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
else:
|
||||
second_request_started.set()
|
||||
await allow_second_response.wait()
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
@@ -203,16 +207,19 @@ async def test_telegram_hot_activation_transitions_installed_to_active(page):
|
||||
await modal.wait_for(state="visible", timeout=5000)
|
||||
await modal.locator(_CONFIGURE_SECRET_INPUT).fill("123456789:ABCdefGhI")
|
||||
await modal.locator(_CONFIGURE_SAVE_BUTTON).click()
|
||||
await modal.locator(_CONFIGURE_SAVE_BUTTON, has_text="Verify owner").wait_for(
|
||||
await second_request_started.wait()
|
||||
await modal.locator(".configure-inline-status", has_text="Waiting for Telegram owner verification...").wait_for(
|
||||
state="visible", timeout=5000
|
||||
)
|
||||
assert "Verify owner" in (
|
||||
await modal.locator(_CONFIGURE_SAVE_BUTTON).text_content()
|
||||
)
|
||||
assert "iclaw-7qk2m9" in (await modal.text_content())
|
||||
assert "/start iclaw-7qk2m9" in (await modal.text_content())
|
||||
assert await modal.locator(".configure-verification-link").count() == 1
|
||||
await modal.locator(_CONFIGURE_SAVE_BUTTON).wait_for(state="hidden", timeout=5000)
|
||||
|
||||
await modal.locator(_CONFIGURE_SAVE_BUTTON).click()
|
||||
await page.locator(SEL["configure_overlay"]).click(position={"x": 1, "y": 1})
|
||||
assert await page.locator(SEL["configure_overlay"]).is_visible()
|
||||
|
||||
allow_second_response.set()
|
||||
await page.locator(SEL["configure_overlay"]).wait_for(state="hidden", timeout=5000)
|
||||
|
||||
phase["value"] = "active"
|
||||
|
||||
@@ -442,6 +442,9 @@ mod advanced {
|
||||
other => panic!("expected event trigger, got {other:?}"),
|
||||
}
|
||||
|
||||
rig.clear().await;
|
||||
let llm_calls_before = rig.llm_call_count();
|
||||
|
||||
rig.send_incoming(IncomingMessage::new(
|
||||
"telegram",
|
||||
"test-user",
|
||||
@@ -451,8 +454,18 @@ mod advanced {
|
||||
|
||||
let runs = wait_for_routine_run(rig.database(), routine.id, TIMEOUT).await;
|
||||
assert_eq!(runs[0].trigger_type, "event");
|
||||
assert_eq!(
|
||||
rig.llm_call_count(),
|
||||
llm_calls_before + 1,
|
||||
"matching event message should only trigger the routine LLM call"
|
||||
);
|
||||
|
||||
let responses = rig.wait_for_responses(3, TIMEOUT).await;
|
||||
let responses = rig.wait_for_responses(1, TIMEOUT).await;
|
||||
assert_eq!(
|
||||
responses.len(),
|
||||
1,
|
||||
"expected only the routine notification after the matching event"
|
||||
);
|
||||
assert!(
|
||||
responses.iter().any(|response| {
|
||||
response
|
||||
@@ -505,6 +518,9 @@ mod advanced {
|
||||
other => panic!("expected event trigger, got {other:?}"),
|
||||
}
|
||||
|
||||
rig.clear().await;
|
||||
let llm_calls_before = rig.llm_call_count();
|
||||
|
||||
rig.send_incoming(IncomingMessage::new(
|
||||
"telegram",
|
||||
"test-user",
|
||||
@@ -514,6 +530,22 @@ mod advanced {
|
||||
|
||||
let runs = wait_for_routine_run(rig.database(), routine.id, TIMEOUT).await;
|
||||
assert_eq!(runs[0].trigger_type, "event");
|
||||
assert_eq!(
|
||||
rig.llm_call_count(),
|
||||
llm_calls_before + 1,
|
||||
"matching event message should only trigger the routine LLM call"
|
||||
);
|
||||
|
||||
let responses = rig.wait_for_responses(1, TIMEOUT).await;
|
||||
assert_eq!(
|
||||
responses.len(),
|
||||
1,
|
||||
"expected only the routine notification after the matching event"
|
||||
);
|
||||
assert!(
|
||||
responses[0].content.contains("Bug report detected"),
|
||||
"expected routine notification, got: {responses:?}"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! E2E tests for Telegram message routing through the real agent + message tool.
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod support;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use ironclaw::agent::{Agent, AgentDeps};
|
||||
use ironclaw::app::{AppBuilder, AppBuilderFlags};
|
||||
use ironclaw::channels::web::log_layer::LogBroadcaster;
|
||||
use ironclaw::channels::{
|
||||
Channel, ChannelManager, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate,
|
||||
};
|
||||
use ironclaw::config::Config;
|
||||
use ironclaw::db::{Database, libsql::LibSqlBackend};
|
||||
use ironclaw::error::ChannelError;
|
||||
use ironclaw::llm::{LlmProvider, SessionConfig, SessionManager};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
use crate::support::test_channel::{TestChannel, TestChannelHandle};
|
||||
use crate::support::trace_llm::{LlmTrace, TraceLlm, TraceResponse, TraceStep, TraceToolCall};
|
||||
|
||||
type TelegramCaptures = Arc<Mutex<Vec<(String, OutgoingResponse)>>>;
|
||||
|
||||
struct RecordingTelegramChannel {
|
||||
captures: TelegramCaptures,
|
||||
}
|
||||
|
||||
impl RecordingTelegramChannel {
|
||||
fn new() -> (Self, TelegramCaptures) {
|
||||
let captures = Arc::new(Mutex::new(Vec::new()));
|
||||
(
|
||||
Self {
|
||||
captures: Arc::clone(&captures),
|
||||
},
|
||||
captures,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for RecordingTelegramChannel {
|
||||
fn name(&self) -> &str {
|
||||
"telegram"
|
||||
}
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
let (_tx, rx) = mpsc::channel::<IncomingMessage>(1);
|
||||
Ok(ReceiverStream::new(rx).boxed())
|
||||
}
|
||||
|
||||
async fn respond(
|
||||
&self,
|
||||
_msg: &IncomingMessage,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.captures
|
||||
.lock()
|
||||
.await
|
||||
.push(("respond".to_string(), response));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_status(
|
||||
&self,
|
||||
_status: StatusUpdate,
|
||||
_metadata: &serde_json::Value,
|
||||
) -> Result<(), ChannelError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast(
|
||||
&self,
|
||||
user_id: &str,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.captures
|
||||
.lock()
|
||||
.await
|
||||
.push((user_id.to_string(), response));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
gateway: Arc<TestChannel>,
|
||||
telegram_captures: Arc<Mutex<Vec<(String, OutgoingResponse)>>>,
|
||||
db: Arc<dyn Database>,
|
||||
owner_id: String,
|
||||
_temp_dir: tempfile::TempDir,
|
||||
agent_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
async fn store_telegram_owner_binding(&self, owner_id: i64) {
|
||||
for scope in [&self.owner_id, "test-user"] {
|
||||
self.db
|
||||
.set_setting(
|
||||
scope,
|
||||
"channels.wasm_channel_owner_ids.telegram",
|
||||
&serde_json::json!(owner_id),
|
||||
)
|
||||
.await
|
||||
.expect("failed to store telegram owner binding");
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_telegram_broadcasts(
|
||||
&self,
|
||||
expected: usize,
|
||||
timeout: Duration,
|
||||
) -> Vec<(String, OutgoingResponse)> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let snapshot = self.telegram_captures.lock().await.clone();
|
||||
if snapshot.len() >= expected || tokio::time::Instant::now() >= deadline {
|
||||
return snapshot;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Harness {
|
||||
fn drop(&mut self) {
|
||||
self.gateway.signal_shutdown();
|
||||
if let Some(handle) = self.agent_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_harness(trace: LlmTrace) -> Harness {
|
||||
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let db_path = temp_dir.path().join("telegram_message_routing.db");
|
||||
let backend = LibSqlBackend::new_local(&db_path)
|
||||
.await
|
||||
.expect("failed to create test LibSqlBackend");
|
||||
backend
|
||||
.run_migrations()
|
||||
.await
|
||||
.expect("failed to run migrations");
|
||||
let db: Arc<dyn Database> = Arc::new(backend);
|
||||
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
let installed_skills_dir = temp_dir.path().join("installed_skills");
|
||||
let _ = std::fs::create_dir_all(&skills_dir);
|
||||
let _ = std::fs::create_dir_all(&installed_skills_dir);
|
||||
let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir);
|
||||
config.agent.auto_approve_tools = true;
|
||||
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
let log_broadcaster = Arc::new(LogBroadcaster::new());
|
||||
let llm: Arc<dyn LlmProvider> = Arc::new(TraceLlm::from_trace(trace));
|
||||
|
||||
let mut builder = AppBuilder::new(
|
||||
config,
|
||||
AppBuilderFlags::default(),
|
||||
None,
|
||||
session,
|
||||
log_broadcaster,
|
||||
);
|
||||
builder.with_database(Arc::clone(&db));
|
||||
builder.with_llm(llm);
|
||||
|
||||
let mut components = builder
|
||||
.build_all()
|
||||
.await
|
||||
.expect("AppBuilder::build_all() failed");
|
||||
components.config.agent.auto_approve_tools = true;
|
||||
components.config.agent.allow_local_tools = true;
|
||||
|
||||
let deps = AgentDeps {
|
||||
owner_id: components.config.owner_id.clone(),
|
||||
store: components.db.clone(),
|
||||
llm: components.llm.clone(),
|
||||
cheap_llm: components.cheap_llm.clone(),
|
||||
safety: components.safety.clone(),
|
||||
tools: components.tools.clone(),
|
||||
workspace: components.workspace.clone(),
|
||||
extension_manager: components.extension_manager.clone(),
|
||||
skill_registry: components.skill_registry.clone(),
|
||||
skill_catalog: components.skill_catalog.clone(),
|
||||
skills_config: components.config.skills.clone(),
|
||||
hooks: components.hooks.clone(),
|
||||
cost_guard: components.cost_guard.clone(),
|
||||
sse_tx: None,
|
||||
http_interceptor: None,
|
||||
transcription: None,
|
||||
document_extraction: None,
|
||||
};
|
||||
|
||||
let gateway = Arc::new(TestChannel::new());
|
||||
let gateway_handle = TestChannelHandle::new(Arc::clone(&gateway));
|
||||
let (telegram_channel, telegram_captures) = RecordingTelegramChannel::new();
|
||||
|
||||
let channel_manager = ChannelManager::new();
|
||||
channel_manager.add(Box::new(gateway_handle)).await;
|
||||
channel_manager.add(Box::new(telegram_channel)).await;
|
||||
let channels = Arc::new(channel_manager);
|
||||
|
||||
deps.tools
|
||||
.register_message_tools(Arc::clone(&channels), deps.extension_manager.clone())
|
||||
.await;
|
||||
|
||||
let agent = Agent::new(
|
||||
components.config.agent.clone(),
|
||||
deps,
|
||||
channels,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(Arc::clone(&components.context_manager)),
|
||||
None,
|
||||
);
|
||||
|
||||
let agent_handle = tokio::spawn(async move {
|
||||
if let Err(err) = agent.run().await {
|
||||
eprintln!("[telegram routing e2e] Agent exited with error: {err}");
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(rx) = gateway.take_ready_rx().await {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), rx).await;
|
||||
}
|
||||
|
||||
Harness {
|
||||
gateway,
|
||||
telegram_captures,
|
||||
db,
|
||||
owner_id: components.config.owner_id.clone(),
|
||||
_temp_dir: temp_dir,
|
||||
agent_handle: Some(agent_handle),
|
||||
}
|
||||
}
|
||||
|
||||
fn single_message_trace(arguments: serde_json::Value, final_text: &str) -> LlmTrace {
|
||||
LlmTrace::single_turn(
|
||||
"telegram-message-routing",
|
||||
"send a reminder",
|
||||
vec![
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::ToolCalls {
|
||||
tool_calls: vec![TraceToolCall {
|
||||
id: "call_message_1".to_string(),
|
||||
name: "message".to_string(),
|
||||
arguments,
|
||||
}],
|
||||
input_tokens: 32,
|
||||
output_tokens: 12,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: final_text.to_string(),
|
||||
input_tokens: 24,
|
||||
output_tokens: 8,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_message_tool_uses_bound_owner_target_when_target_omitted() {
|
||||
let harness = build_harness(single_message_trace(
|
||||
serde_json::json!({
|
||||
"content": "Walk Conan",
|
||||
"channel": "telegram",
|
||||
}),
|
||||
"Sent on Telegram.",
|
||||
))
|
||||
.await;
|
||||
|
||||
harness.store_telegram_owner_binding(424242).await;
|
||||
|
||||
harness
|
||||
.gateway
|
||||
.send_message("remind me to walk conan")
|
||||
.await;
|
||||
let responses = harness
|
||||
.gateway
|
||||
.wait_for_responses(1, Duration::from_secs(10))
|
||||
.await;
|
||||
assert!(
|
||||
responses
|
||||
.iter()
|
||||
.any(|response| response.content.contains("Sent on Telegram")),
|
||||
"expected assistant confirmation, got: {:?}",
|
||||
responses
|
||||
.iter()
|
||||
.map(|response| &response.content)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let broadcasts = harness
|
||||
.wait_for_telegram_broadcasts(1, Duration::from_secs(10))
|
||||
.await;
|
||||
assert_eq!(
|
||||
broadcasts.len(),
|
||||
1,
|
||||
"expected exactly one telegram broadcast"
|
||||
);
|
||||
assert_eq!(broadcasts[0].0, "424242");
|
||||
assert_eq!(broadcasts[0].1.content, "Walk Conan");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_message_tool_prefers_explicit_target_over_bound_owner_target() {
|
||||
let harness = build_harness(single_message_trace(
|
||||
serde_json::json!({
|
||||
"content": "Walk Conan",
|
||||
"channel": "telegram",
|
||||
"target": "999999",
|
||||
}),
|
||||
"Sent on Telegram.",
|
||||
))
|
||||
.await;
|
||||
|
||||
harness.store_telegram_owner_binding(424242).await;
|
||||
|
||||
harness.gateway.send_message("send the reminder").await;
|
||||
let _ = harness
|
||||
.gateway
|
||||
.wait_for_responses(1, Duration::from_secs(10))
|
||||
.await;
|
||||
|
||||
let broadcasts = harness
|
||||
.wait_for_telegram_broadcasts(1, Duration::from_secs(10))
|
||||
.await;
|
||||
assert_eq!(
|
||||
broadcasts.len(),
|
||||
1,
|
||||
"expected exactly one telegram broadcast"
|
||||
);
|
||||
assert_eq!(broadcasts[0].0, "999999");
|
||||
assert_eq!(broadcasts[0].1.content, "Walk Conan");
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,6 @@
|
||||
"output_tokens": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "I saw the Telegram message.",
|
||||
"input_tokens": 90,
|
||||
"output_tokens": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
|
||||
@@ -35,14 +35,6 @@
|
||||
"output_tokens": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "I saw the Telegram message.",
|
||||
"input_tokens": 90,
|
||||
"output_tokens": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
|
||||
@@ -653,7 +653,7 @@ impl TestRigBuilder {
|
||||
|
||||
// 7b. Register message tool so routines can send messages to channels.
|
||||
deps.tools
|
||||
.register_message_tools(Arc::clone(&channels))
|
||||
.register_message_tools(Arc::clone(&channels), deps.extension_manager.clone())
|
||||
.await;
|
||||
|
||||
// 8. Create Agent.
|
||||
|
||||
Reference in New Issue
Block a user