mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 20:46:24 +00:00
Classifies resume-on-reopen failures (session_expired / network / poisoned-history) instead of the generic catch-all, and evicts a 400-poisoned warm session so the thread self-heals. Closes #3714.
570 lines
20 KiB
Rust
570 lines
20 KiB
Rust
//! Round19 focused raw coverage for leftover channel provider branches.
|
|
//!
|
|
//! These tests use loopback mocks, public debug seams, and short-lived
|
|
//! in-process listeners. They do not require real channel credentials or tokens.
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use axum::{
|
|
body::Bytes,
|
|
extract::{Path, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::IntoResponse,
|
|
routing::post,
|
|
Router,
|
|
};
|
|
use openhuman_core::openhuman::channels::providers::telegram::TelegramChannel;
|
|
use openhuman_core::openhuman::channels::providers::web::{
|
|
cancel_chat, start_chat, subscribe_web_channel_events, test_support as web_test_support,
|
|
ChatRequestMetadata,
|
|
};
|
|
use openhuman_core::openhuman::channels::providers::yuanbao::{
|
|
connection::YuanbaoConnection, YuanbaoChannel, YuanbaoConfig,
|
|
};
|
|
use openhuman_core::openhuman::channels::{Channel, LarkChannel, SendMessage};
|
|
use openhuman_core::openhuman::config::{schema::LarkConfig, StreamMode};
|
|
use serde_json::{json, Value};
|
|
use tokio::sync::{mpsc, watch};
|
|
use tokio::time::timeout;
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct RecordedTelegramRequest {
|
|
method: String,
|
|
headers: HeaderMap,
|
|
body: Value,
|
|
raw_body: String,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct TelegramMockState {
|
|
requests: Mutex<Vec<RecordedTelegramRequest>>,
|
|
updates_seen: Mutex<u32>,
|
|
}
|
|
|
|
async fn telegram_handler(
|
|
Path((_token, method)): Path<(String, String)>,
|
|
State(state): State<Arc<TelegramMockState>>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> impl IntoResponse {
|
|
let raw_body = String::from_utf8_lossy(&body).to_string();
|
|
let parsed = serde_json::from_slice::<Value>(&body).unwrap_or_else(|_| json!({}));
|
|
state
|
|
.requests
|
|
.lock()
|
|
.expect("telegram request lock")
|
|
.push(RecordedTelegramRequest {
|
|
method: method.clone(),
|
|
headers,
|
|
body: parsed.clone(),
|
|
raw_body,
|
|
});
|
|
|
|
match method.as_str() {
|
|
"getMe" => (
|
|
StatusCode::OK,
|
|
axum::Json(json!({
|
|
"ok": true,
|
|
"result": { "id": 19, "username": "Round19Bot" },
|
|
})),
|
|
),
|
|
"getUpdates" => {
|
|
let mut seen = state.updates_seen.lock().expect("updates lock");
|
|
*seen += 1;
|
|
let payload = if *seen == 1 {
|
|
json!({
|
|
"ok": true,
|
|
"result": [
|
|
{
|
|
"update_id": 20,
|
|
"message": {
|
|
"message_id": 300,
|
|
"text": "group message without mention is ignored",
|
|
"from": { "id": 88, "username": "allowed" },
|
|
"chat": { "id": -100, "type": "supergroup" }
|
|
}
|
|
},
|
|
{
|
|
"update_id": 21,
|
|
"edited_message": {
|
|
"message_id": 301,
|
|
"text": "hi @Round19Bot normalize this",
|
|
"from": { "id": 88, "username": "allowed" },
|
|
"chat": { "id": -100, "type": "supergroup" }
|
|
}
|
|
},
|
|
{
|
|
"update_id": 22,
|
|
"message_reaction": {
|
|
"chat": { "id": -100 },
|
|
"message_id": 301,
|
|
"user": { "id": 88 },
|
|
"new_reaction": [{ "type": "emoji", "emoji": "✅" }]
|
|
}
|
|
},
|
|
{
|
|
"update_id": 23,
|
|
"message": {
|
|
"message_id": 302,
|
|
"text": "/bind missing",
|
|
"from": { "id": 99, "username": "blocked" },
|
|
"chat": { "id": 555, "type": "private" }
|
|
}
|
|
}
|
|
]
|
|
})
|
|
} else {
|
|
json!({ "ok": true, "result": [] })
|
|
};
|
|
(StatusCode::OK, axum::Json(payload))
|
|
}
|
|
"sendChatAction" => {
|
|
if parsed.get("message_thread_id").is_some() {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
axum::Json(json!({ "ok": false, "description": "topic action rejected" })),
|
|
)
|
|
} else {
|
|
(
|
|
StatusCode::OK,
|
|
axum::Json(json!({ "ok": true, "result": true })),
|
|
)
|
|
}
|
|
}
|
|
"editMessageText" => {
|
|
let markdown = parsed.get("parse_mode").and_then(Value::as_str) == Some("Markdown");
|
|
if markdown {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
axum::Json(json!({ "ok": false, "description": "markdown edit rejected" })),
|
|
)
|
|
} else {
|
|
(
|
|
StatusCode::OK,
|
|
axum::Json(json!({ "ok": true, "result": true })),
|
|
)
|
|
}
|
|
}
|
|
"deleteMessage" | "setMessageReaction" => (
|
|
StatusCode::OK,
|
|
axum::Json(json!({ "ok": true, "result": true })),
|
|
),
|
|
"sendMessage" => (
|
|
StatusCode::OK,
|
|
axum::Json(json!({ "ok": true, "result": { "message_id": 777 } })),
|
|
),
|
|
"sendDocument" | "sendPhoto" | "sendVideo" | "sendAudio" | "sendVoice" => (
|
|
StatusCode::OK,
|
|
axum::Json(json!({ "ok": true, "result": true })),
|
|
),
|
|
_ => (
|
|
StatusCode::OK,
|
|
axum::Json(json!({ "ok": true, "result": true })),
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn spawn_telegram_mock() -> (String, Arc<TelegramMockState>, tokio::task::JoinHandle<()>) {
|
|
let state = Arc::new(TelegramMockState::default());
|
|
let app = Router::new()
|
|
.route(
|
|
"/bot{token}/{method}",
|
|
post(telegram_handler).get(telegram_handler),
|
|
)
|
|
.with_state(Arc::clone(&state));
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind telegram mock");
|
|
let addr = listener.local_addr().expect("telegram mock addr");
|
|
let handle = tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app).await;
|
|
});
|
|
(format!("http://{addr}"), state, handle)
|
|
}
|
|
|
|
struct EnvGuard {
|
|
key: &'static str,
|
|
old: Option<String>,
|
|
}
|
|
|
|
impl EnvGuard {
|
|
fn set(key: &'static str, value: impl AsRef<str>) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
unsafe {
|
|
std::env::set_var(key, value.as_ref());
|
|
}
|
|
Self { key, old }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvGuard {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
match self.old.as_deref() {
|
|
Some(value) => std::env::set_var(self.key, value),
|
|
None => std::env::remove_var(self.key),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn telegram_round19_covers_mention_filter_typing_fallback_and_attachment_forms() {
|
|
let (base, state, server) = spawn_telegram_mock().await;
|
|
let _api_base = EnvGuard::set("OPENHUMAN_TELEGRAM_BOT_API_BASE", &base);
|
|
let _legacy_base = EnvGuard::set("OPENHUMAN_TELEGRAM_API_BASE", "");
|
|
|
|
let listener_channel = TelegramChannel::new("ROUND19_TOKEN".into(), vec!["88".into()], true)
|
|
.with_streaming(StreamMode::Partial, 0, false);
|
|
let (tx, mut rx) = mpsc::channel(4);
|
|
let listen_handle = tokio::spawn(async move { listener_channel.listen(tx).await });
|
|
let inbound = timeout(Duration::from_secs(10), rx.recv())
|
|
.await
|
|
.expect("telegram inbound timeout")
|
|
.expect("telegram inbound message");
|
|
assert_eq!(inbound.content, "hi normalize this");
|
|
assert_eq!(inbound.sender, "allowed");
|
|
assert_eq!(inbound.thread_ts.as_deref(), Some("301"));
|
|
listen_handle.abort();
|
|
|
|
let channel = TelegramChannel::new("ROUND19_TOKEN".into(), vec!["*".into()], false)
|
|
.with_streaming(StreamMode::Partial, 0, false);
|
|
channel
|
|
.start_typing("123:456")
|
|
.await
|
|
.expect("typing fallback");
|
|
channel.stop_typing("123:456").await.expect("stop typing");
|
|
|
|
let draft_id = channel
|
|
.send_draft(&SendMessage::new("draft", "123:456").in_thread(Some("33".to_string())))
|
|
.await
|
|
.expect("draft send")
|
|
.expect("draft message id");
|
|
channel
|
|
.finalize_draft(
|
|
"123:456",
|
|
&draft_id,
|
|
"**markdown edit fallback**",
|
|
Some("33"),
|
|
)
|
|
.await
|
|
.expect("plain edit fallback");
|
|
channel
|
|
.update_draft("123:456", "not-an-int", "ignored invalid edit id")
|
|
.await
|
|
.expect("invalid edit id is ignored");
|
|
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
let doc = tmp.path().join("round19.txt");
|
|
let photo = tmp.path().join("round19.jpg");
|
|
tokio::fs::write(&doc, b"doc bytes")
|
|
.await
|
|
.expect("doc write");
|
|
tokio::fs::write(&photo, b"photo bytes")
|
|
.await
|
|
.expect("photo write");
|
|
channel
|
|
.send_document("123", Some("456"), &doc, Some("doc caption"))
|
|
.await
|
|
.expect("document multipart");
|
|
channel
|
|
.send_photo("123", Some("456"), &photo, Some("photo caption"))
|
|
.await
|
|
.expect("photo multipart");
|
|
let missing = channel
|
|
.send(&SendMessage::new(
|
|
format!("[DOCUMENT:{}]", tmp.path().join("missing.pdf").display()),
|
|
"123:456",
|
|
))
|
|
.await
|
|
.expect_err("missing attachment path");
|
|
assert!(missing.to_string().contains("path not found"));
|
|
|
|
let requests = state
|
|
.requests
|
|
.lock()
|
|
.expect("telegram requests lock")
|
|
.clone();
|
|
server.abort();
|
|
|
|
assert!(requests.iter().any(|req| req.method == "getMe"));
|
|
assert!(requests.iter().any(|req| req.method == "getUpdates"));
|
|
assert!(requests
|
|
.iter()
|
|
.any(|req| req.method == "sendChatAction" && req.body.get("message_thread_id").is_some()));
|
|
assert!(requests
|
|
.iter()
|
|
.any(|req| req.method == "sendChatAction" && req.body.get("message_thread_id").is_none()));
|
|
assert!(requests.iter().any(|req| req.method == "editMessageText"
|
|
&& req.body.get("parse_mode").and_then(Value::as_str) == Some("Markdown")));
|
|
assert!(requests
|
|
.iter()
|
|
.any(|req| req.method == "editMessageText" && req.body.get("parse_mode").is_none()));
|
|
assert!(requests
|
|
.iter()
|
|
.any(|req| req.method == "sendDocument" && req.raw_body.contains("doc caption")));
|
|
assert!(requests
|
|
.iter()
|
|
.any(|req| req.method == "sendPhoto" && req.raw_body.contains("photo caption")));
|
|
assert!(requests.iter().any(|req| req.headers.get("host").is_some()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn web_round19_covers_classifier_variants_and_cancel_cleanup() {
|
|
let auth = web_test_support::classify_error_for_test(
|
|
"custom_openai API error (401 Unauthorized): invalid api key",
|
|
);
|
|
assert_eq!(auth.error_type, "auth_error");
|
|
assert_eq!(auth.source, "config");
|
|
assert!(!auth.retryable);
|
|
|
|
// Issue #3088: budget-signal strings now classify as `budget_exhausted`
|
|
// instead of falling through to the generic `inference` branch — the
|
|
// user gets an actionable "top up or switch routing" message.
|
|
let budget = web_test_support::classify_error_for_test(
|
|
"inference budget exceeded: monthly limit reached",
|
|
);
|
|
assert_eq!(budget.error_type, "budget_exhausted");
|
|
assert_eq!(budget.source, "openhuman_billing");
|
|
|
|
// #3714: a DNS / transport drop now classifies as the dedicated `network`
|
|
// arm (was the generic `inference` catch-all), still retryable.
|
|
let network = web_test_support::classify_error_for_test(
|
|
"request error: dns error while trying to connect",
|
|
);
|
|
assert_eq!(network.error_type, "network");
|
|
assert!(network.retryable);
|
|
|
|
web_test_support::set_forced_run_chat_task_error_for_test(Some(
|
|
"Agent exceeded maximum tool iterations",
|
|
))
|
|
.await;
|
|
let mut rx = subscribe_web_channel_events();
|
|
let request_id = start_chat(
|
|
"round19-client",
|
|
"round19-thread",
|
|
"exercise deterministic max iteration classification",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
ChatRequestMetadata::default(),
|
|
)
|
|
.await
|
|
.expect("start forced web chat");
|
|
let event = timeout(Duration::from_secs(10), async {
|
|
loop {
|
|
let event = rx.recv().await.expect("web channel event");
|
|
if event.request_id == request_id && event.event == "chat_error" {
|
|
break event;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("forced chat_error");
|
|
assert_eq!(event.error_type.as_deref(), Some("max_iterations"));
|
|
web_test_support::set_forced_run_chat_task_error_for_test(None).await;
|
|
|
|
assert_eq!(
|
|
cancel_chat("round19-client", "round19-thread")
|
|
.await
|
|
.expect("cancel cleaned up forced request"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lark_round19_covers_parse_leftovers_and_config_defaults() {
|
|
let mut cfg = LarkConfig {
|
|
app_id: "round19-app".into(),
|
|
app_secret: "round19-secret".into(),
|
|
encrypt_key: None,
|
|
verification_token: Some("round19-token".into()),
|
|
port: Some(0),
|
|
allowed_users: vec!["ou_allowed".into()],
|
|
use_feishu: true,
|
|
receive_mode: Default::default(),
|
|
};
|
|
let lark = LarkChannel::from_config(&cfg);
|
|
assert_eq!(lark.name(), "lark");
|
|
|
|
let fallback_locale_post = json!({
|
|
"header": { "event_type": "im.message.receive_v1" },
|
|
"event": {
|
|
"sender": { "sender_id": { "open_id": "ou_allowed" } },
|
|
"message": {
|
|
"message_type": "post",
|
|
"content": serde_json::to_string(&json!({
|
|
"fr_fr": {
|
|
"content": [[
|
|
{ "tag": "a", "href": "https://example.test/fallback" },
|
|
{ "tag": "at", "user_id": "ou_friend" },
|
|
{ "tag": "img", "image_key": "ignored" }
|
|
]]
|
|
}
|
|
})).expect("post json"),
|
|
"chat_id": "oc_round19"
|
|
}
|
|
}
|
|
});
|
|
let messages = lark.parse_event_payload(&fallback_locale_post);
|
|
assert_eq!(messages.len(), 1);
|
|
assert!(messages[0]
|
|
.content
|
|
.contains("https://example.test/fallback"));
|
|
assert!(messages[0].content.contains("@ou_friend"));
|
|
|
|
let empty_post = json!({
|
|
"header": { "event_type": "im.message.receive_v1" },
|
|
"event": {
|
|
"sender": { "sender_id": { "open_id": "ou_allowed" } },
|
|
"message": { "message_type": "post", "content": "{\"en_us\":{\"content\":[]}}" }
|
|
}
|
|
});
|
|
assert!(lark.parse_event_payload(&empty_post).is_empty());
|
|
|
|
let unsupported = json!({
|
|
"header": { "event_type": "im.message.receive_v1" },
|
|
"event": {
|
|
"sender": { "sender_id": { "open_id": "ou_allowed" } },
|
|
"message": { "message_type": "image", "content": "{}" }
|
|
}
|
|
});
|
|
assert!(lark.parse_event_payload(&unsupported).is_empty());
|
|
|
|
cfg.use_feishu = false;
|
|
cfg.allowed_users = vec!["*".into()];
|
|
let wildcard = LarkChannel::from_config(&cfg);
|
|
let mut text_payload = unsupported;
|
|
text_payload["event"]["message"]["message_type"] = json!("text");
|
|
text_payload["event"]["message"]["content"] = json!("{\"text\":\"wildcard accepted\"}");
|
|
assert_eq!(wildcard.parse_event_payload(&text_payload).len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lark_round19_listen_http_missing_port_and_ephemeral_bind_paths() {
|
|
let missing_port = LarkChannel::from_config(&LarkConfig {
|
|
app_id: "round19-app".into(),
|
|
app_secret: "round19-secret".into(),
|
|
encrypt_key: None,
|
|
verification_token: Some("round19-token".into()),
|
|
port: None,
|
|
allowed_users: vec!["*".into()],
|
|
use_feishu: true,
|
|
receive_mode: Default::default(),
|
|
});
|
|
let (tx, _rx) = mpsc::channel(1);
|
|
let err = missing_port
|
|
.listen_http(tx)
|
|
.await
|
|
.expect_err("missing webhook port errors");
|
|
assert!(err.to_string().contains("requires `port`"));
|
|
|
|
let channel = LarkChannel::from_config(&LarkConfig {
|
|
app_id: "round19-app".into(),
|
|
app_secret: "round19-secret".into(),
|
|
encrypt_key: None,
|
|
verification_token: Some("round19-token".into()),
|
|
port: Some(0),
|
|
allowed_users: vec!["*".into()],
|
|
use_feishu: true,
|
|
receive_mode: Default::default(),
|
|
});
|
|
let (tx, _rx) = mpsc::channel(1);
|
|
let handle = tokio::spawn(async move { channel.listen_http(tx).await });
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
assert!(!handle.is_finished());
|
|
handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn yuanbao_round19_connection_run_shutdown_and_channel_error_paths() {
|
|
let cfg = YuanbaoConfig {
|
|
app_key: "round19-ak".into(),
|
|
token: "round19-token".into(),
|
|
bot_id: "round19-bot".into(),
|
|
ws_domain: "ws://127.0.0.1:9/round19".into(),
|
|
api_domain: "http://127.0.0.1:9".into(),
|
|
heartbeat_interval_secs: 1,
|
|
max_reconnect_attempts: 1,
|
|
max_message_length: 12,
|
|
..Default::default()
|
|
};
|
|
cfg.validate().expect("valid static-token config");
|
|
|
|
let (inbound_tx, _inbound_rx) = mpsc::unbounded_channel();
|
|
let connection = YuanbaoConnection::new(cfg.clone(), inbound_tx, None);
|
|
assert!(!connection.is_connected());
|
|
assert_eq!(connection.account().uid, "round19-bot");
|
|
let first = connection.next_msg_id("round19");
|
|
let second = connection.next_msg_id("round19");
|
|
assert_ne!(first, second);
|
|
|
|
let send_err = connection
|
|
.send_and_wait("missing", vec![1, 2, 3], Duration::from_millis(20))
|
|
.await
|
|
.expect_err("not connected send_and_wait");
|
|
assert!(send_err.to_string().contains("not connected"));
|
|
|
|
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
|
|
timeout(
|
|
Duration::from_secs(5),
|
|
Arc::clone(&connection).run(shutdown_rx),
|
|
)
|
|
.await
|
|
.expect("connection run exits after retry budget");
|
|
assert!(!connection.is_connected());
|
|
connection.shutdown().await;
|
|
|
|
let channel = YuanbaoChannel::new(cfg).expect("yuanbao channel");
|
|
assert_eq!(channel.name(), "yuanbao");
|
|
assert!(channel.supports_draft_updates());
|
|
assert!(!channel.health_check().await);
|
|
assert_eq!(
|
|
channel
|
|
.send_draft(&SendMessage::new("hello", "recipient"))
|
|
.await
|
|
.expect("yuanbao draft marker")
|
|
.as_deref(),
|
|
Some("yb-draft:recipient")
|
|
);
|
|
let err = channel
|
|
.send(&SendMessage::new("split me into chunks please", "g:group"))
|
|
.await
|
|
.expect_err("not connected outbound send");
|
|
assert!(err.to_string().contains("not connected"));
|
|
assert!(channel
|
|
.update_draft("recipient", "draft", "ignored")
|
|
.await
|
|
.is_ok());
|
|
|
|
let mut bad = YuanbaoConfig {
|
|
app_key: "round19-ak".into(),
|
|
token: String::new(),
|
|
app_secret: "round19-secret".into(),
|
|
ws_domain: "wss://example.test/ws".into(),
|
|
api_domain: String::new(),
|
|
..Default::default()
|
|
};
|
|
assert!(bad.validate().is_err());
|
|
bad.api_domain = "https://api.example.test".into();
|
|
bad.validate().expect("secret config needs api domain");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn round19_artifact_target_prefix_is_used() {
|
|
let dir = tempfile::Builder::new()
|
|
.prefix("channels-provider-leftovers-round19-")
|
|
.tempdir_in("target")
|
|
.expect("round19 artifact dir");
|
|
let marker = dir.path().join("marker.txt");
|
|
tokio::fs::write(&marker, b"round19")
|
|
.await
|
|
.expect("marker write");
|
|
assert!(marker
|
|
.to_string_lossy()
|
|
.contains("channels-provider-leftovers-round19-"));
|
|
}
|