Files
openhuman/tests/tools_channels_raw_coverage_e2e.rs
T
2026-06-04 23:44:39 -04:00

963 lines
34 KiB
Rust

//! Focused raw integration coverage for the public tools and channels surfaces.
//!
//! These tests stay local-only: temp workspaces, in-memory adapters, and
//! payload parsing instead of real network calls.
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Map, Value};
use tempfile::tempdir;
use openhuman_core::openhuman::channels::controllers::{
all_channel_definitions, all_channels_controller_schemas, all_channels_registered_controllers,
find_channel_definition, ChannelAuthMode, ChannelCapability,
};
use openhuman_core::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage};
use openhuman_core::openhuman::channels::yuanbao::config::YuanbaoConfig;
use openhuman_core::openhuman::channels::yuanbao::errors::{
AUTH_FAILED_CODES, AUTH_RETRYABLE_CODES, NO_RECONNECT_CLOSE_CODES,
};
use openhuman_core::openhuman::channels::yuanbao::media::{
build_file_msg_body, build_image_msg_body, guess_mime_type, image_format_code, is_image,
parse_image_size,
};
use openhuman_core::openhuman::channels::yuanbao::proto::{
decode_auth_bind_rsp, decode_conn_msg, decode_inbound_json, decode_inbound_push,
decode_push_msg, encode_auth_bind, encode_conn_msg, encode_msg_body_element, encode_ping,
encode_push_ack,
};
use openhuman_core::openhuman::channels::yuanbao::proto_constants::{cmd, cmd_type, module};
use openhuman_core::openhuman::channels::yuanbao::splitter::split_markdown;
use openhuman_core::openhuman::channels::yuanbao::types::{
ConnFrame as YuanbaoConnFrame, MsgBodyElement as YuanbaoMsgBodyElement,
MsgContent as YuanbaoMsgContent,
};
use openhuman_core::openhuman::channels::yuanbao::wire::{
decode_varint, encode_field_bytes, encode_field_string, encode_field_varint, encode_varint,
get_bytes, get_repeated_bytes, get_string, get_varint, next_seq_no, parse_fields, FieldValue,
};
use openhuman_core::openhuman::channels::{CliChannel, WhatsAppChannel};
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::memory::{
Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts,
};
use openhuman_core::openhuman::security::{AuditLogger, SecurityPolicy};
use openhuman_core::openhuman::tools::generated::{
admit_generated_tool_definitions, generated_tools_from_definitions, GeneratedToolAdapter,
GeneratedToolAdmissionConfig, GeneratedToolDefinition, GeneratedToolRisk,
};
use openhuman_core::openhuman::tools::local_cli::tools_wrappers_list_json;
use openhuman_core::openhuman::tools::{
all_tools, all_tools_controller_schemas, all_tools_registered_controllers,
decode_data_url_bytes, default_tools, extract_data_url, extract_saved_path,
write_bytes_to_path, DefaultToolPolicy, PermissionLevel, PolicyDecision, ToolCategory,
ToolPolicy, ToolResult, ToolScope,
};
#[path = "tools_approval_channels_raw_coverage_e2e.rs"]
mod prior_tools_approval_channels_raw_coverage_e2e;
#[derive(Default)]
struct StubMemory;
#[async_trait]
impl Memory for StubMemory {
fn name(&self) -> &str {
"tools-channels-stub"
}
async fn store(
&self,
_namespace: &str,
_key: &str,
_content: &str,
_category: MemoryCategory,
_session_id: Option<&str>,
) -> Result<()> {
Ok(())
}
async fn recall(
&self,
_query: &str,
_limit: usize,
_opts: RecallOpts<'_>,
) -> Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
async fn get(&self, _namespace: &str, _key: &str) -> Result<Option<MemoryEntry>> {
Ok(None)
}
async fn list(
&self,
_namespace: Option<&str>,
_category: Option<&MemoryCategory>,
_session_id: Option<&str>,
) -> Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
async fn forget(&self, _namespace: &str, _key: &str) -> Result<bool> {
Ok(false)
}
async fn namespace_summaries(&self) -> Result<Vec<NamespaceSummary>> {
Ok(Vec::new())
}
async fn count(&self) -> Result<usize> {
Ok(0)
}
async fn health_check(&self) -> bool {
true
}
}
struct RecordingGeneratedAdapter;
#[async_trait]
impl GeneratedToolAdapter for RecordingGeneratedAdapter {
fn id(&self) -> &str {
"recording-adapter"
}
async fn execute(
&self,
definition: &GeneratedToolDefinition,
args: Value,
) -> Result<ToolResult> {
Ok(ToolResult::success(
json!({
"tool": definition.name,
"adapter": definition.adapter_id,
"args": args,
})
.to_string(),
))
}
}
fn basic_generated_definition(name: &str) -> GeneratedToolDefinition {
let mut definition = GeneratedToolDefinition::new(
name,
format!("Execute {name}"),
json!({
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"]
}),
"recording-adapter",
);
definition.provider_id = Some(" Trusted.Provider ".to_string());
definition.capability_id = Some(format!("{name}.capability"));
definition.source_digest = Some(format!("sha256:{name}"));
definition.risk = Some(GeneratedToolRisk::Read);
definition
}
fn temp_config() -> (tempfile::TempDir, Config) {
let tmp = tempdir().expect("tempdir");
let mut config = Config::default();
config.workspace_dir = tmp.path().join("workspace");
config.config_path = tmp.path().join("config.toml");
config.node.enabled = false;
config.browser.enabled = true;
config.gitbooks.enabled = true;
config.http_request.allowed_domains = vec![
"*".to_string(),
"example.com".to_string(),
"docs.openhuman.ai".to_string(),
];
config.search.engine = "managed".to_string();
std::fs::create_dir_all(&config.workspace_dir).expect("workspace");
(tmp, config)
}
#[test]
fn generated_tool_admission_covers_provenance_and_rejection_paths() {
let mut trusted = BTreeSet::new();
trusted.insert("trusted.provider".to_string());
trusted.insert(" invalid provider ".to_string());
let mut disabled_providers = BTreeSet::new();
disabled_providers.insert("blocked.provider".to_string());
let mut disabled_capabilities = BTreeSet::new();
disabled_capabilities.insert("blocked.capability".to_string());
let mut existing_tool_names = BTreeSet::new();
existing_tool_names.insert("already_registered".to_string());
let config = GeneratedToolAdmissionConfig {
enforce_provenance: true,
trusted_providers: trusted,
disabled_providers,
disabled_capabilities,
existing_tool_names,
};
let mut duplicate = basic_generated_definition("already_registered");
duplicate.capability_id = Some("duplicate.capability".to_string());
let mut disabled_provider = basic_generated_definition("blocked_provider_tool");
disabled_provider.provider_id = Some("Blocked.Provider".to_string());
let mut untrusted = basic_generated_definition("untrusted_tool");
untrusted.provider_id = Some("unknown.provider".to_string());
let mut disabled_capability = basic_generated_definition("disabled_capability_tool");
disabled_capability.capability_id = Some("blocked.capability".to_string());
let mut missing_risk = basic_generated_definition("missing_risk_tool");
missing_risk.risk = None;
let mut missing_digest = basic_generated_definition("missing_digest_tool");
missing_digest.source_digest = None;
let mut unsafe_name = basic_generated_definition("-unsafe");
unsafe_name.provider_id = Some("trusted.provider".to_string());
let mut invalid_schema = basic_generated_definition("invalid_schema");
invalid_schema.parameters_schema = json!({"type": "object", "additionalProperties": true});
let report = admit_generated_tool_definitions(
vec![
basic_generated_definition("accepted_tool"),
duplicate,
disabled_provider,
untrusted,
disabled_capability,
missing_risk,
missing_digest,
unsafe_name,
invalid_schema,
],
&config,
);
assert!(report
.admitted
.iter()
.any(|definition| definition.name == "accepted_tool"
&& definition.provider_id.as_deref() == Some("trusted.provider")));
let reasons = report
.rejected
.iter()
.map(|rejection| rejection.reason.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(reasons.contains("duplicate generated tool"));
assert!(reasons.contains("provider `blocked.provider` is disabled"));
assert!(reasons.contains("provider `unknown.provider` is not trusted"));
assert!(reasons.contains("capability `blocked.capability` is disabled"));
assert!(reasons.contains("missing risk metadata"));
assert!(reasons.contains("missing source_digest"));
assert!(reasons.contains("name contains unsupported characters"));
assert!(report
.admitted
.iter()
.any(|definition| definition.name == "invalid_schema"));
}
#[tokio::test]
async fn generated_tool_wrapper_executes_and_exposes_metadata() {
let adapter = Arc::new(RecordingGeneratedAdapter);
let mut write_tool = basic_generated_definition("write_status");
write_tool.permission_level = PermissionLevel::Write;
write_tool.category = ToolCategory::Workflow;
write_tool.scope = ToolScope::AgentOnly;
write_tool.risk = Some(GeneratedToolRisk::ExternalWrite);
let tools = generated_tools_from_definitions(vec![write_tool], adapter).expect("wrap tool");
let tool = tools.first().expect("generated tool");
assert_eq!(tool.name(), "write_status");
assert_eq!(tool.permission_level(), PermissionLevel::Write);
assert_eq!(tool.category(), ToolCategory::Workflow);
assert_eq!(tool.scope(), ToolScope::AgentOnly);
assert!(tool.external_effect());
assert_eq!(
tool.permission_level_with_args(&json!({"message": "hi"})),
PermissionLevel::Write
);
assert!(!tool.supports_markdown());
assert!(!tool.is_concurrency_safe(&Value::Null));
let result = tool
.execute(json!({"message": "hello"}))
.await
.expect("execute generated tool");
let output = result.output();
assert!(output.contains("write_status"));
assert!(output.contains("recording-adapter"));
let bad_adapter = Arc::new(RecordingGeneratedAdapter);
let bad_definition = GeneratedToolDefinition::new(
"needs_other_adapter",
"Adapter mismatch",
json!({"type": "object"}),
"other-adapter",
);
let err = match generated_tools_from_definitions(vec![bad_definition], bad_adapter) {
Ok(_) => panic!("adapter mismatch should fail"),
Err(err) => err.to_string(),
};
assert!(err.contains("requires adapter `other-adapter`"));
}
#[test]
fn tool_registries_schemas_and_local_helpers_cover_safe_branches() {
let (_tmp, config) = temp_config();
let config = Arc::new(config);
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
&config.workspace_dir,
));
let audit = AuditLogger::disabled();
let memory: Arc<dyn Memory> = Arc::new(StubMemory);
let baseline = default_tools(Arc::clone(&security));
assert_eq!(baseline.len(), 3);
assert!(baseline.iter().any(|tool| tool.name() == "shell"));
assert!(baseline.iter().any(|tool| tool.name() == "file_read"));
assert!(baseline.iter().any(|tool| tool.name() == "file_write"));
let tools = all_tools(
Arc::clone(&config),
&security,
audit,
memory,
&config.browser,
&config.http_request,
&config.workspace_dir,
&HashMap::new(),
&config,
);
let names = tools
.iter()
.map(|tool| tool.name())
.collect::<BTreeSet<_>>();
for expected in [
"browser",
"browser_open",
"http_request",
"web_fetch",
"curl",
"gitbooks_search",
"gitbooks_get_page",
"mcp_setup_search",
"mcp_setup_install_and_connect",
] {
assert!(names.contains(expected), "missing tool {expected}");
}
for tool in &tools {
assert!(!tool.name().trim().is_empty());
assert!(!tool.description().trim().is_empty());
let schema = tool.parameters_schema();
assert_eq!(
schema.get("type").and_then(Value::as_str),
Some("object"),
"{} schema should be an object",
tool.name()
);
let _ = tool.permission_level_with_args(&json!({"action": "list"}));
}
let schema_names = all_tools_controller_schemas()
.into_iter()
.map(|schema| schema.function)
.collect::<BTreeSet<_>>();
assert!(schema_names.contains("web_search"));
assert!(schema_names.contains("composio_execute"));
let registered_names = all_tools_registered_controllers()
.into_iter()
.map(|registered| registered.schema.function)
.collect::<BTreeSet<_>>();
assert_eq!(schema_names, registered_names);
let wrappers = tools_wrappers_list_json();
assert_eq!(wrappers["result"]["wrappers"][0]["name"], "screenshot");
assert_eq!(wrappers["logs"][0], "tools wrappers listed");
let raw = "Screenshot saved to: /tmp/shot.png\ndata:image/png;base64,aGVsbG8=";
assert_eq!(
extract_saved_path(raw).unwrap().display().to_string(),
"/tmp/shot.png"
);
assert_eq!(
extract_data_url(raw).as_deref(),
Some("data:image/png;base64,aGVsbG8=")
);
assert_eq!(
decode_data_url_bytes("data:image/png;base64,aGVsbG8=").unwrap(),
b"hello"
);
assert!(decode_data_url_bytes("data:text/plain;base64,aGVsbG8=").is_err());
let out = config.workspace_dir.join("nested").join("bytes.bin");
write_bytes_to_path(&out, b"abc").expect("write bytes");
assert_eq!(std::fs::read(out).expect("read bytes"), b"abc");
let policy = DefaultToolPolicy;
assert_eq!(
policy.evaluate("anything", &json!({"arg": true})),
PolicyDecision::Allow
);
}
#[test]
fn channel_definitions_validate_all_auth_modes_and_controller_metadata() {
let definitions = all_channel_definitions();
let ids = definitions
.iter()
.map(|definition| definition.id)
.collect::<BTreeSet<_>>();
assert_eq!(ids.len(), definitions.len(), "channel ids should be unique");
for expected in [
"telegram", "discord", "web", "imessage", "lark", "dingtalk", "yuanbao",
] {
assert!(
ids.contains(expected),
"missing channel definition {expected}"
);
assert!(find_channel_definition(expected).is_some());
}
assert!(find_channel_definition("missing").is_none());
let telegram = find_channel_definition("telegram").expect("telegram");
assert!(telegram
.capabilities
.contains(&ChannelCapability::DraftUpdates));
assert_eq!(
telegram
.auth_mode_spec(ChannelAuthMode::ManagedDm)
.and_then(|mode| mode.auth_action),
Some("telegram_managed_dm")
);
assert!(telegram
.validate_credentials(ChannelAuthMode::ManagedDm, &Map::new())
.is_ok());
let err = telegram
.validate_credentials(ChannelAuthMode::BotToken, &Map::new())
.expect_err("bot token should be required");
assert!(err.contains("missing required fields"));
assert!(err.contains("bot_token"));
let mut creds = Map::new();
creds.insert("bot_token".to_string(), json!("123456:token"));
assert!(telegram
.validate_credentials(ChannelAuthMode::BotToken, &creds)
.is_ok());
assert!(telegram
.validate_credentials(ChannelAuthMode::OAuth, &creds)
.expect_err("unsupported auth mode")
.contains("does not support auth mode"));
for (raw, parsed) in [
("api_key", ChannelAuthMode::ApiKey),
("bot_token", ChannelAuthMode::BotToken),
("oauth", ChannelAuthMode::OAuth),
("managed_dm", ChannelAuthMode::ManagedDm),
] {
assert_eq!(raw.parse::<ChannelAuthMode>().unwrap(), parsed);
assert_eq!(parsed.to_string(), raw);
}
assert!("bad-mode".parse::<ChannelAuthMode>().is_err());
let schema_names = all_channels_controller_schemas()
.into_iter()
.map(|schema| schema.function)
.collect::<BTreeSet<_>>();
for expected in [
"list",
"describe",
"connect",
"disconnect",
"status",
"test",
"send_message",
"send_reaction",
"create_thread",
"update_thread",
"list_threads",
] {
assert!(schema_names.contains(expected), "missing schema {expected}");
}
let registered_names = all_channels_registered_controllers()
.into_iter()
.map(|registered| registered.schema.function)
.collect::<BTreeSet<_>>();
assert_eq!(schema_names, registered_names);
}
#[test]
fn whatsapp_webhook_parser_covers_allowed_and_skipped_payloads() {
let channel = WhatsAppChannel::new(
"token".to_string(),
"phone-id".to_string(),
"verify-me".to_string(),
vec!["+15551234567".to_string(), "+15557654321".to_string()],
);
assert_eq!(channel.name(), "whatsapp");
assert_eq!(channel.verify_token(), "verify-me");
assert!(channel
.parse_webhook_payload(&json!({"entry": "bad"}))
.is_empty());
assert!(channel
.parse_webhook_payload(&json!({"entry": [{"changes": "bad"}]}))
.is_empty());
let payload = json!({
"entry": [
{
"changes": [
{
"value": {
"messages": [
{
"from": "15551234567",
"timestamp": "1710000000",
"text": { "body": "hello from allowed" }
},
{
"from": "+15557654321",
"timestamp": "not-a-number",
"text": { "body": "timestamp fallback" }
},
{
"from": "15550000000",
"timestamp": "1710000001",
"text": { "body": "blocked" }
},
{
"from": "15551234567",
"timestamp": "1710000002",
"image": { "id": "media" }
},
{
"from": "15551234567",
"timestamp": "1710000003",
"text": { "body": "" }
},
{
"timestamp": "1710000004",
"text": { "body": "missing sender" }
}
]
}
},
{ "value": { "messages": "bad" } }
]
},
{ "changes": [] }
]
});
let messages = channel.parse_webhook_payload(&payload);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].sender, "+15551234567");
assert_eq!(messages[0].reply_target, "+15551234567");
assert_eq!(messages[0].content, "hello from allowed");
assert_eq!(messages[0].channel, "whatsapp");
assert_eq!(messages[0].timestamp, 1_710_000_000);
assert_eq!(messages[1].sender, "+15557654321");
assert!(messages[1].timestamp > 0);
let wildcard = WhatsAppChannel::new(
"token".to_string(),
"phone-id".to_string(),
"verify".to_string(),
vec!["*".to_string()],
);
let wildcard_messages = wildcard.parse_webhook_payload(&json!({
"entry": [{"changes": [{"value": {"messages": [{
"from": "14155550100",
"timestamp": "1710000100",
"text": {"body": "wildcard"}
}]}}]}]
}));
assert_eq!(wildcard_messages.len(), 1);
assert_eq!(wildcard_messages[0].sender, "+14155550100");
}
#[test]
fn yuanbao_config_wire_and_splitter_helpers_cover_public_deterministic_paths() {
assert!(NO_RECONNECT_CLOSE_CODES.contains(&4012));
assert!(AUTH_FAILED_CODES.contains(&40001));
assert!(AUTH_RETRYABLE_CODES.contains(&40010));
let mut cfg = YuanbaoConfig::default();
assert_eq!(cfg.env, "prod");
assert_eq!(cfg.bot_version, "0.1.0");
assert_eq!(cfg.dm_access, "open");
assert_eq!(cfg.group_access, "allowlist");
assert!(cfg.group_at_required);
assert_eq!(cfg.max_message_length, 4500);
assert_eq!(cfg.max_media_mb, 50);
assert!(cfg
.validate()
.expect_err("default config invalid")
.to_string()
.contains("app_key"));
cfg.env = "pre".into();
cfg.apply_env_defaults();
assert_eq!(cfg.api_domain, "https://bot-pre.yuanbao.tencent.com");
assert_eq!(
cfg.ws_domain,
"wss://bot-wss-pre.yuanbao.tencent.com/wss/connection"
);
cfg.app_key = "app-key".into();
assert!(cfg
.validate()
.expect_err("missing token invalid")
.to_string()
.contains("token"));
cfg.token = "pre-provisioned-token".into();
assert!(cfg.validate().is_ok());
let mut explicit = YuanbaoConfig {
app_key: "app-key".into(),
token: "token".into(),
api_domain: "https://custom-api.example.test".into(),
ws_domain: "wss://custom-ws.example.test".into(),
..YuanbaoConfig::default()
};
explicit.apply_env_defaults();
assert_eq!(explicit.api_domain, "https://custom-api.example.test");
assert_eq!(explicit.ws_domain, "wss://custom-ws.example.test");
assert!(explicit.validate().is_ok());
let seq_a = next_seq_no();
let seq_b = next_seq_no();
assert_eq!(seq_b, seq_a + 1);
let mut varint = Vec::new();
encode_varint(300, &mut varint);
assert_eq!(varint, vec![0xac, 0x02]);
assert_eq!(decode_varint(&varint, 0).expect("decode varint"), (300, 2));
assert!(decode_varint(&[0x80], 0)
.expect_err("truncated varint")
.to_string()
.contains("truncated varint"));
assert!(decode_varint(&[0xff; 10], 0)
.expect_err("overflow varint")
.to_string()
.contains("overflow"));
let mut fields_buf = Vec::new();
encode_field_varint(1, 42, &mut fields_buf);
encode_field_string(2, "hello", &mut fields_buf);
encode_field_bytes(2, b"again", &mut fields_buf);
fields_buf.push((3 << 3) | 5);
fields_buf.extend_from_slice(&0x1234_5678_u32.to_le_bytes());
fields_buf.push((4 << 3) | 1);
fields_buf.extend_from_slice(&0x0102_0304_0506_0708_u64.to_le_bytes());
let fields = parse_fields(&fields_buf).expect("parse mixed fields");
assert_eq!(get_varint(&fields, 1), 42);
assert_eq!(get_string(&fields, 2), "hello");
assert_eq!(get_bytes(&fields, 2), b"hello".to_vec());
assert_eq!(
get_repeated_bytes(&fields, 2),
vec![b"hello".to_vec(), b"again".to_vec()]
);
assert_eq!(get_varint(&fields, 99), 0);
assert_eq!(get_string(&fields, 99), "");
assert!(get_bytes(&fields, 99).is_empty());
assert!(fields
.iter()
.any(|(_, value)| matches!(value, FieldValue::Fixed32(0x1234_5678))));
assert!(fields
.iter()
.any(|(_, value)| matches!(value, FieldValue::Fixed64(0x0102_0304_0506_0708))));
assert!(parse_fields(&[((9 << 3) | 3) as u8])
.expect_err("unsupported wire type")
.to_string()
.contains("unsupported wire type"));
assert!(parse_fields(&[((9 << 3) | 2) as u8, 5, b'a'])
.expect_err("truncated len field")
.to_string()
.contains("truncated len field"));
assert_eq!(split_markdown("short", 100), vec!["short"]);
let fenced = "intro\n```rust\nfn alpha() {}\nfn beta() {}\n```\noutro\n";
let chunks = split_markdown(fenced, 32);
assert!(chunks.len() > 1);
assert!(chunks.iter().any(|chunk| chunk.contains("```rust")));
assert!(chunks.iter().all(|chunk| !chunk.trim().is_empty()));
let hard_split = split_markdown("é".repeat(8).as_str(), 3);
assert!(hard_split.len() > 1);
assert!(hard_split.iter().all(|chunk| chunk.len() <= 4));
}
#[test]
fn yuanbao_media_and_proto_helpers_cover_public_roundtrips() {
assert_eq!(guess_mime_type("PHOTO.JPG"), "image/jpeg");
assert_eq!(
guess_mime_type("slides.pptx"),
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
);
assert_eq!(
guess_mime_type("archive.unknown"),
"application/octet-stream"
);
assert!(is_image("avatar.webp", ""));
assert!(is_image("no-extension", "image/png"));
assert!(!is_image("notes.txt", ""));
assert_eq!(image_format_code("image/jpeg"), 1);
assert_eq!(image_format_code("image/gif"), 2);
assert_eq!(image_format_code("image/png"), 3);
assert_eq!(image_format_code("image/bmp"), 4);
assert_eq!(image_format_code("image/heic"), 255);
let png = [
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x01, 0x40, 0x00, 0x00, 0x00, 0xF0,
];
let png_dims = parse_image_size(&png).expect("png dims");
assert_eq!(png_dims.width, 320);
assert_eq!(png_dims.height, 240);
let gif_dims = parse_image_size(b"GIF89a\x40\x01\xF0\x00rest").expect("gif dims");
assert_eq!(gif_dims.width, 320);
assert_eq!(gif_dims.height, 240);
let mut webp_vp8x = b"RIFF\x00\x00\x00\x00WEBPVP8X".to_vec();
webp_vp8x.extend_from_slice(&[0u8; 8]);
webp_vp8x.extend_from_slice(&[0x3F, 0x01, 0x00, 0xEF, 0x00, 0x00]);
let webp_dims = parse_image_size(&webp_vp8x).expect("webp dims");
assert_eq!(webp_dims.width, 320);
assert_eq!(webp_dims.height, 240);
assert!(parse_image_size(b"not-an-image").is_none());
let image_body = build_image_msg_body(
"https://cdn.example.test/cat.png",
None,
Some("cat.png"),
1024,
800,
600,
"image/png",
);
assert_eq!(image_body[0].msg_type, "TIMImageElem");
assert_eq!(image_body[0].msg_content.uuid.as_deref(), Some("cat.png"));
assert_eq!(image_body[0].msg_content.image_format, Some(3));
assert_eq!(
image_body[0].msg_content.image_info_array[0].url,
"https://cdn.example.test/cat.png"
);
let file_body = build_file_msg_body(
"https://cdn.example.test/report.pdf",
"report.pdf",
Some("file-uuid"),
2048,
);
assert_eq!(file_body[0].msg_type, "TIMFileElem");
assert_eq!(
file_body[0].msg_content.file_name.as_deref(),
Some("report.pdf")
);
assert_eq!(file_body[0].msg_content.file_size, Some(2048));
let frame_buf = encode_conn_msg(
cmd_type::REQUEST,
cmd::PING,
7,
"msg-7",
module::CONN_ACCESS,
b"payload",
);
let frame = decode_conn_msg(&frame_buf).expect("decode conn msg");
assert_eq!(frame.cmd_type, cmd_type::REQUEST);
assert_eq!(frame.cmd, cmd::PING);
assert_eq!(frame.seq_no, 7);
assert_eq!(frame.msg_id, "msg-7");
assert_eq!(frame.data, b"payload");
let ping = decode_conn_msg(&encode_ping("ping-1")).expect("decode ping");
assert_eq!(ping.cmd, cmd::PING);
let ack = decode_conn_msg(&encode_push_ack(&YuanbaoConnFrame {
cmd_type: cmd_type::PUSH,
cmd: "push".into(),
seq_no: 9,
msg_id: "push-1".into(),
need_ack: true,
status: 0,
module: module::BIZ_PKG.into(),
data: Vec::new(),
}))
.expect("decode ack");
assert_eq!(ack.cmd_type, cmd_type::PUSH_ACK);
assert_eq!(ack.msg_id, "push-1");
let auth = decode_conn_msg(&encode_auth_bind(
"biz", "uid", "openclaw", "token", "auth-1", "1.0.0", "linux", "2.0.0", "pre",
))
.expect("decode auth bind");
assert_eq!(auth.cmd, cmd::AUTH_BIND);
assert_eq!(auth.module, module::CONN_ACCESS);
assert!(!auth.data.is_empty());
let mut auth_rsp = Vec::new();
encode_field_varint(1, 0, &mut auth_rsp);
encode_field_string(2, "ok", &mut auth_rsp);
encode_field_string(3, "connect-1", &mut auth_rsp);
let auth_rsp = decode_auth_bind_rsp(&auth_rsp).expect("decode auth rsp");
assert_eq!(auth_rsp.message, "ok");
assert_eq!(auth_rsp.connect_id, "connect-1");
let mut push_msg = Vec::new();
encode_field_string(1, "inbound_message", &mut push_msg);
encode_field_string(2, module::BIZ_PKG, &mut push_msg);
encode_field_string(3, "push-msg-1", &mut push_msg);
encode_field_bytes(4, b"biz-payload", &mut push_msg);
let decoded_push = decode_push_msg(&push_msg).expect("decode push msg");
assert_eq!(decoded_push.cmd, "inbound_message");
assert_eq!(decoded_push.data, b"biz-payload");
let text_el = YuanbaoMsgBodyElement {
msg_type: "TIMTextElem".into(),
msg_content: YuanbaoMsgContent {
text: Some("hello from proto".into()),
..Default::default()
},
};
let mut inbound = Vec::new();
encode_field_string(1, "C2C.Callback", &mut inbound);
encode_field_string(2, "sender", &mut inbound);
encode_field_string(3, "bot", &mut inbound);
encode_field_string(4, "Alice", &mut inbound);
encode_field_varint(8, 11, &mut inbound);
encode_field_varint(10, 1_780_000_000, &mut inbound);
encode_field_string(12, "msg-11", &mut inbound);
encode_field_bytes(13, &encode_msg_body_element(&text_el), &mut inbound);
let mut recall = Vec::new();
encode_field_varint(1, 10, &mut recall);
encode_field_string(2, "old-msg", &mut recall);
encode_field_bytes(17, &recall, &mut inbound);
let mut log_ext = Vec::new();
encode_field_string(1, "trace-11", &mut log_ext);
encode_field_bytes(20, &log_ext, &mut inbound);
let decoded_inbound = decode_inbound_push(&inbound).expect("decode inbound push");
assert_eq!(decoded_inbound.callback_command, "C2C.Callback");
assert_eq!(decoded_inbound.extract_text(), "hello from proto");
assert_eq!(decoded_inbound.recall_msg_seq_list[0].msg_id, "old-msg");
assert_eq!(decoded_inbound.trace_id, "trace-11");
let decoded_json = decode_inbound_json(
br#"{
"callback_command": "Group.Callback",
"from_account": "sender-json",
"group_code": "group-json",
"msg_seq": 12,
"msg_body": [{
"msg_type": "TIMImageElem",
"msg_content": {
"uuid": "img-json",
"image_format": 3,
"image_info_array": [{
"image_type": 1,
"size": 50,
"width": 10,
"height": 20,
"url": "https://cdn.example.test/json.png"
}]
}
}],
"recall_msg_seq_list": [{ "msg_seq": 11, "msg_id": "old-json" }],
"log_ext": { "trace_id": "trace-json" }
}"#,
)
.expect("decode inbound json");
assert!(decoded_json.is_group());
assert_eq!(
decoded_json.extract_image_urls(),
vec!["https://cdn.example.test/json.png".to_string()]
);
assert_eq!(decoded_json.recall_msg_seq_list[0].msg_seq, 11);
assert_eq!(decoded_json.trace_id, "trace-json");
assert!(decode_inbound_json(b"[]")
.expect_err("json root must be object")
.to_string()
.contains("json root is not an object"));
}
#[tokio::test]
async fn channel_trait_defaults_and_cli_channel_cover_message_paths() {
struct TestChannel;
#[async_trait]
impl Channel for TestChannel {
fn name(&self) -> &str {
"test"
}
async fn send(&self, _message: &SendMessage) -> Result<()> {
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
tx.send(ChannelMessage {
id: "m1".to_string(),
sender: "sender".to_string(),
reply_target: "reply".to_string(),
content: "content".to_string(),
channel: "test".to_string(),
timestamp: 123,
thread_ts: Some("thread-1".to_string()),
})
.await?;
Ok(())
}
}
let message = SendMessage::with_subject("hello", "recipient", "subject")
.in_thread(Some("thread-42".to_string()));
assert_eq!(message.content, "hello");
assert_eq!(message.recipient, "recipient");
assert_eq!(message.subject.as_deref(), Some("subject"));
assert_eq!(message.thread_ts.as_deref(), Some("thread-42"));
let simple = SendMessage::new("simple", "target");
assert!(simple.subject.is_none());
assert!(simple.thread_ts.is_none());
let channel = TestChannel;
assert_eq!(channel.name(), "test");
assert!(channel.health_check().await);
assert!(!channel.supports_reactions());
assert!(!channel.supports_draft_updates());
assert!(channel.start_typing("recipient").await.is_ok());
assert!(channel.stop_typing("recipient").await.is_ok());
assert!(channel.send(&message).await.is_ok());
assert!(channel.send_draft(&message).await.unwrap().is_none());
assert!(channel
.update_draft("recipient", "message-id", "draft")
.await
.is_ok());
assert!(channel
.finalize_draft("recipient", "message-id", "final", Some("thread-42"))
.await
.is_ok());
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
channel.listen(tx).await.expect("listen");
let received = rx.recv().await.expect("message");
assert_eq!(received.id, "m1");
assert_eq!(received.thread_ts.as_deref(), Some("thread-1"));
let cli = CliChannel::default();
assert_eq!(cli.name(), "cli");
assert!(cli
.send(&SendMessage::new("printed by test", "stdout"))
.await
.is_ok());
assert!(cli.health_check().await);
}