diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5698d658..cdd08b40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -181,6 +181,9 @@ jobs: - name: Build CLI if: matrix.target != 'aarch64-unknown-linux-gnu' run: cargo build --release --target ${{ matrix.target }} --bin openfang + - name: Ad-hoc codesign CLI binary (macOS) + if: runner.os == 'macOS' + run: codesign --force --sign - target/${{ matrix.target }}/release/openfang - name: Package (Unix) if: matrix.archive == 'tar.gz' run: | diff --git a/Cargo.lock b/Cargo.lock index b764783a..cfbf154b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2506,6 +2506,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -3866,7 +3875,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.3.23" +version = "0.3.24" dependencies = [ "async-trait", "axum", @@ -3903,7 +3912,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.3.23" +version = "0.3.24" dependencies = [ "async-trait", "axum", @@ -3913,6 +3922,7 @@ dependencies = [ "futures", "hex", "hmac", + "html-escape", "imap", "lettre", "mailparse", @@ -3934,7 +3944,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.3.23" +version = "0.3.24" dependencies = [ "clap", "clap_complete", @@ -3961,7 +3971,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.3.23" +version = "0.3.24" dependencies = [ "axum", "open", @@ -3987,7 +3997,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.3.23" +version = "0.3.24" dependencies = [ "aes-gcm", "argon2", @@ -4015,7 +4025,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.3.23" +version = "0.3.24" dependencies = [ "chrono", "dashmap", @@ -4032,7 +4042,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.3.23" +version = "0.3.24" dependencies = [ "async-trait", "chrono", @@ -4068,7 +4078,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.3.23" +version = "0.3.24" dependencies = [ "async-trait", "chrono", @@ -4087,7 +4097,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.3.23" +version = "0.3.24" dependencies = [ "chrono", "dirs 6.0.0", @@ -4106,7 +4116,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.3.23" +version = "0.3.24" dependencies = [ "anyhow", "async-trait", @@ -4138,7 +4148,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.3.23" +version = "0.3.24" dependencies = [ "chrono", "hex", @@ -4161,7 +4171,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.3.23" +version = "0.3.24" dependencies = [ "async-trait", "chrono", @@ -4180,7 +4190,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.3.23" +version = "0.3.24" dependencies = [ "async-trait", "chrono", @@ -7377,6 +7387,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -8802,7 +8818,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.3.23" +version = "0.3.24" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index e9c080e6..f84bf1a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.3.24" +version = "0.3.25" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" @@ -119,6 +119,9 @@ colored = "3" aes-gcm = "0.10" argon2 = "0.5" +# HTML entity decoding +html-escape = "0.2" + # Lightweight regex regex-lite = "0.1" diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 89353f39..abc1c518 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -390,68 +390,109 @@ pub async fn get_agent_session( match state.kernel.memory.get_session(entry.session_id) { Ok(Some(session)) => { - let messages: Vec = session - .messages - .iter() - .filter_map(|m| { - let mut tools: Vec = Vec::new(); - let content = match &m.content { - openfang_types::message::MessageContent::Text(t) => t.clone(), - openfang_types::message::MessageContent::Blocks(blocks) => { - // Extract human-readable text and tool info from blocks - let mut texts = Vec::new(); - for b in blocks { - match b { - openfang_types::message::ContentBlock::Text { text } => { - texts.push(text.clone()); - } - openfang_types::message::ContentBlock::Image { .. } => { - texts.push("[Image]".to_string()); - } - openfang_types::message::ContentBlock::ToolUse { - name, .. - } => { - tools.push(serde_json::json!({ - "name": name, - "running": false, - "expanded": false, - })); - } - openfang_types::message::ContentBlock::ToolResult { - content: result, - is_error, - .. - } => { - // Attach result to the most recent tool without a result - if let Some(last_tool) = tools.last_mut() { + // Two-pass approach: ToolUse blocks live in Assistant messages while + // ToolResult blocks arrive in subsequent User messages. Pass 1 + // collects all tool_use entries keyed by id; pass 2 attaches results. + + // Pass 1: build messages and a lookup from tool_use_id → (msg_idx, tool_idx) + let mut built_messages: Vec = Vec::new(); + let mut tool_use_index: std::collections::HashMap = + std::collections::HashMap::new(); + + for m in &session.messages { + let mut tools: Vec = Vec::new(); + let content = match &m.content { + openfang_types::message::MessageContent::Text(t) => t.clone(), + openfang_types::message::MessageContent::Blocks(blocks) => { + let mut texts = Vec::new(); + for b in blocks { + match b { + openfang_types::message::ContentBlock::Text { text } => { + texts.push(text.clone()); + } + openfang_types::message::ContentBlock::Image { .. } => { + texts.push("[Image]".to_string()); + } + openfang_types::message::ContentBlock::ToolUse { + id, + name, + input, + } => { + let tool_idx = tools.len(); + tools.push(serde_json::json!({ + "name": name, + "input": input, + "running": false, + "expanded": false, + })); + // Will be filled after this loop when we know msg_idx + tool_use_index + .insert(id.clone(), (usize::MAX, tool_idx)); + } + // ToolResult blocks are handled in pass 2 + openfang_types::message::ContentBlock::ToolResult { .. } => {} + _ => {} + } + } + texts.join("\n") + } + }; + // Skip messages that are purely tool results (User role with only ToolResult blocks) + if content.is_empty() && tools.is_empty() { + continue; + } + let msg_idx = built_messages.len(); + // Fix up the msg_idx for tool_use entries registered with sentinel + for (_, (mi, _)) in tool_use_index.iter_mut() { + if *mi == usize::MAX { + *mi = msg_idx; + } + } + let mut msg = serde_json::json!({ + "role": format!("{:?}", m.role), + "content": content, + }); + if !tools.is_empty() { + msg["tools"] = serde_json::Value::Array(tools); + } + built_messages.push(msg); + } + + // Pass 2: walk messages again and attach ToolResult to the correct tool + for m in &session.messages { + if let openfang_types::message::MessageContent::Blocks(blocks) = &m.content { + for b in blocks { + if let openfang_types::message::ContentBlock::ToolResult { + tool_use_id, + content: result, + is_error, + .. + } = b + { + if let Some(&(msg_idx, tool_idx)) = + tool_use_index.get(tool_use_id) + { + if let Some(msg) = built_messages.get_mut(msg_idx) { + if let Some(tools_arr) = + msg.get_mut("tools").and_then(|v| v.as_array_mut()) + { + if let Some(tool_obj) = tools_arr.get_mut(tool_idx) { let preview: String = - result.chars().take(300).collect(); - last_tool["result"] = + result.chars().take(2000).collect(); + tool_obj["result"] = serde_json::Value::String(preview); - last_tool["is_error"] = + tool_obj["is_error"] = serde_json::Value::Bool(*is_error); } } - _ => {} } } - texts.join("\n") } - }; - // Skip messages that are purely tool results (User role with only ToolResult blocks) - if content.is_empty() && tools.is_empty() { - return None; } - let mut msg = serde_json::json!({ - "role": format!("{:?}", m.role), - "content": content, - }); - if !tools.is_empty() { - msg["tools"] = serde_json::Value::Array(tools); - } - Some(msg) - }) - .collect(); + } + } + + let messages = built_messages; ( StatusCode::OK, Json(serde_json::json!({ @@ -540,6 +581,7 @@ pub async fn status(State(state): State>) -> impl IntoResponse { Json(serde_json::json!({ "status": "running", + "version": env!("CARGO_PKG_VERSION"), "agent_count": agent_count, "default_provider": state.kernel.config.default_model.provider, "default_model": state.kernel.config.default_model.model, @@ -5601,7 +5643,7 @@ pub async fn a2a_send_task( let task = openfang_runtime::a2a::A2aTask { id: task_id.clone(), session_id: session_id.clone(), - status: openfang_runtime::a2a::A2aTaskStatus::Working, + status: openfang_runtime::a2a::A2aTaskStatus::Working.into(), messages: vec![openfang_runtime::a2a::A2aMessage { role: "user".to_string(), parts: vec![openfang_runtime::a2a::A2aPart::Text { @@ -5710,10 +5752,10 @@ pub async fn a2a_list_external_agents(State(state): State>) -> imp .unwrap_or_else(|e| e.into_inner()); let items: Vec = agents .iter() - .map(|(url, card)| { + .map(|(_, card)| { serde_json::json!({ "name": card.name, - "url": url, + "url": card.url, "description": card.description, "skills": card.skills, "version": card.version, @@ -6943,15 +6985,14 @@ fn upsert_channel_config( } } FieldType::List => { + // Always store list items as strings so that numeric IDs + // (e.g. Discord guild snowflakes, Telegram user IDs) are + // deserialized correctly into Vec config fields. let items: Vec = v .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) - .map(|s| { - s.parse::() - .map(toml::Value::Integer) - .unwrap_or_else(|_| toml::Value::String(s.to_string())) - }) + .map(|s| toml::Value::String(s.to_string())) .collect(); toml::Value::Array(items) } diff --git a/crates/openfang-api/static/css/layout.css b/crates/openfang-api/static/css/layout.css index 4b416319..880e6ca3 100644 --- a/crates/openfang-api/static/css/layout.css +++ b/crates/openfang-api/static/css/layout.css @@ -55,6 +55,11 @@ transform: scale(1.05); } +[data-theme="light"] .sidebar-logo img, +[data-theme="light"] .message-avatar img { + filter: invert(1); +} + .sidebar-header h1 { font-size: 14px; font-weight: 700; diff --git a/crates/openfang-channels/Cargo.toml b/crates/openfang-channels/Cargo.toml index 58f0a5df..49e57250 100644 --- a/crates/openfang-channels/Cargo.toml +++ b/crates/openfang-channels/Cargo.toml @@ -26,6 +26,7 @@ hmac = { workspace = true } sha2 = { workspace = true } base64 = { workspace = true } hex = { workspace = true } +html-escape = { workspace = true } lettre = { workspace = true } imap = { workspace = true } diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index efb9ca52..1daedb94 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -453,16 +453,21 @@ async fn dispatch_message( send_response(adapter, &message.sender, result, thread_id, output_format).await; return; } - _ => { - send_response( - adapter, - &message.sender, - "I can only handle text messages for now.".to_string(), - thread_id, - output_format, - ) - .await; - return; + ChannelContent::Image { ref url, ref caption } => { + let desc = match caption { + Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"), + None => format!("[User sent a photo: {url}]"), + }; + desc + } + ChannelContent::File { ref url, ref filename } => { + format!("[User sent a file ({filename}): {url}]") + } + ChannelContent::Voice { ref url, duration_seconds } => { + format!("[User sent a voice message ({duration_seconds}s): {url}]") + } + ChannelContent::Location { lat, lon } => { + format!("[User shared location: {lat}, {lon}]") } }; diff --git a/crates/openfang-channels/src/mastodon.rs b/crates/openfang-channels/src/mastodon.rs index 4499f2ab..81be9f5c 100644 --- a/crates/openfang-channels/src/mastodon.rs +++ b/crates/openfang-channels/src/mastodon.rs @@ -298,17 +298,8 @@ fn strip_html_tags(html: &str) -> String { } } - // Decode HTML entities - let decoded = result - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'") - .replace("'", "'") - .replace("'", "'") - .replace(" ", " "); - + // Decode HTML entities (handles named, decimal, and hex entities) + let decoded = html_escape::decode_html_entities(&result); decoded.trim().to_string() } diff --git a/crates/openfang-channels/src/nostr.rs b/crates/openfang-channels/src/nostr.rs index 132d8833..6d9adb9e 100644 --- a/crates/openfang-channels/src/nostr.rs +++ b/crates/openfang-channels/src/nostr.rs @@ -165,7 +165,7 @@ impl ChannelAdapter for NostrAdapter { ) -> Result + Send>>, Box> { let pubkey = self.derive_pubkey(); - info!("Nostr adapter starting (pubkey: {}...)", &pubkey[..16]); + info!("Nostr adapter starting (pubkey: {}...)", openfang_types::truncate_str(&pubkey, 16)); if self.relays.is_empty() { return Err("Nostr: no relay URLs configured".into()); diff --git a/crates/openfang-channels/src/telegram.rs b/crates/openfang-channels/src/telegram.rs index 6c3b4b88..1469d1af 100644 --- a/crates/openfang-channels/src/telegram.rs +++ b/crates/openfang-channels/src/telegram.rs @@ -28,7 +28,7 @@ pub struct TelegramAdapter { /// SECURITY: Bot token is zeroized on drop to prevent memory disclosure. token: Zeroizing, client: reqwest::Client, - allowed_users: Vec, + allowed_users: Vec, poll_interval: Duration, shutdown_tx: Arc>, shutdown_rx: watch::Receiver, @@ -39,7 +39,7 @@ impl TelegramAdapter { /// /// `token` is the raw bot token (read from env by the caller). /// `allowed_users` is the list of Telegram user IDs allowed to interact (empty = allow all). - pub fn new(token: String, allowed_users: Vec, poll_interval: Duration) -> Self { + pub fn new(token: String, allowed_users: Vec, poll_interval: Duration) -> Self { let (shutdown_tx, shutdown_rx) = watch::channel(false); Self { token: Zeroizing::new(token), @@ -371,7 +371,7 @@ impl ChannelAdapter for TelegramAdapter { } // Parse the message - let msg = match parse_telegram_update(update, &allowed_users) { + let msg = match parse_telegram_update(update, &allowed_users, token.as_str(), &client).await { Some(m) => m, None => continue, // filtered out or unparseable }; @@ -449,9 +449,34 @@ impl ChannelAdapter for TelegramAdapter { /// Parse a Telegram update JSON into a `ChannelMessage`, or `None` if filtered/unparseable. /// Handles both `message` and `edited_message` update types. -fn parse_telegram_update( +/// Resolve a Telegram file_id to a download URL via the Bot API. +async fn telegram_get_file_url( + token: &str, + client: &reqwest::Client, + file_id: &str, +) -> Option { + let url = format!("https://api.telegram.org/bot{token}/getFile"); + let resp = client + .post(&url) + .json(&serde_json::json!({"file_id": file_id})) + .send() + .await + .ok()?; + let body: serde_json::Value = resp.json().await.ok()?; + if body["ok"].as_bool() != Some(true) { + return None; + } + let file_path = body["result"]["file_path"].as_str()?; + Some(format!( + "https://api.telegram.org/file/bot{token}/{file_path}" + )) +} + +async fn parse_telegram_update( update: &serde_json::Value, - allowed_users: &[i64], + allowed_users: &[String], + token: &str, + client: &reqwest::Client, ) -> Option { let message = update .get("message") @@ -459,8 +484,9 @@ fn parse_telegram_update( let from = message.get("from")?; let user_id = from["id"].as_i64()?; - // Security: check allowed_users - if !allowed_users.is_empty() && !allowed_users.contains(&user_id) { + // Security: check allowed_users (compare as strings for consistency) + let user_id_str = user_id.to_string(); + if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == &user_id_str) { debug!("Telegram: ignoring message from unlisted user {user_id}"); return None; } @@ -476,41 +502,81 @@ fn parse_telegram_update( let chat_type = message["chat"]["type"].as_str().unwrap_or("private"); let is_group = chat_type == "group" || chat_type == "supergroup"; - - let text = message["text"].as_str()?; let message_id = message["message_id"].as_i64().unwrap_or(0); let timestamp = message["date"] .as_i64() .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)) .unwrap_or_else(chrono::Utc::now); - // Parse bot commands (Telegram sends entities for /commands) - let content = if let Some(entities) = message["entities"].as_array() { - let is_bot_command = entities - .iter() - .any(|e| e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0)); - if is_bot_command { - let parts: Vec<&str> = text.splitn(2, ' ').collect(); - let cmd_name = parts[0].trim_start_matches('/'); - // Strip @botname from command (e.g. /agents@mybot -> agents) - let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name); - let args = if parts.len() > 1 { - parts[1].split_whitespace().map(String::from).collect() + // Determine content: text, photo, document, voice, or location + let content = if let Some(text) = message["text"].as_str() { + // Parse bot commands (Telegram sends entities for /commands) + if let Some(entities) = message["entities"].as_array() { + let is_bot_command = entities.iter().any(|e| { + e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0) + }); + if is_bot_command { + let parts: Vec<&str> = text.splitn(2, ' ').collect(); + let cmd_name = parts[0].trim_start_matches('/'); + let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name); + let args = if parts.len() > 1 { + parts[1].split_whitespace().map(String::from).collect() + } else { + vec![] + }; + ChannelContent::Command { + name: cmd_name.to_string(), + args, + } } else { - vec![] - }; - ChannelContent::Command { - name: cmd_name.to_string(), - args, + ChannelContent::Text(text.to_string()) } } else { ChannelContent::Text(text.to_string()) } + } else if let Some(photos) = message["photo"].as_array() { + // Photos come as array of sizes; pick the largest (last) + let file_id = photos + .last() + .and_then(|p| p["file_id"].as_str()) + .unwrap_or(""); + let caption = message["caption"].as_str().map(String::from); + match telegram_get_file_url(token, client, file_id).await { + Some(url) => ChannelContent::Image { url, caption }, + None => ChannelContent::Text(format!( + "[Photo received{}]", + caption.as_deref().map(|c| format!(": {c}")).unwrap_or_default() + )), + } + } else if message.get("document").is_some() { + let file_id = message["document"]["file_id"].as_str().unwrap_or(""); + let filename = message["document"]["file_name"] + .as_str() + .unwrap_or("document") + .to_string(); + match telegram_get_file_url(token, client, file_id).await { + Some(url) => ChannelContent::File { url, filename }, + None => ChannelContent::Text(format!("[Document received: {filename}]")), + } + } else if message.get("voice").is_some() { + let file_id = message["voice"]["file_id"].as_str().unwrap_or(""); + let duration = message["voice"]["duration"].as_u64().unwrap_or(0) as u32; + match telegram_get_file_url(token, client, file_id).await { + Some(url) => ChannelContent::Voice { + url, + duration_seconds: duration, + }, + None => ChannelContent::Text(format!("[Voice message, {duration}s]")), + } + } else if message.get("location").is_some() { + let lat = message["location"]["latitude"].as_f64().unwrap_or(0.0); + let lon = message["location"]["longitude"].as_f64().unwrap_or(0.0); + ChannelContent::Location { lat, lon } } else { - ChannelContent::Text(text.to_string()) + // Unsupported message type (stickers, polls, etc.) + return None; }; - // Use chat_id as the platform_id (so responses go to the right chat) Some(ChannelMessage { channel: ChannelType::Telegram, platform_message_id: message_id.to_string(), @@ -594,8 +660,12 @@ fn sanitize_telegram_html(text: &str) -> String { mod tests { use super::*; - #[test] - fn test_parse_telegram_update() { + fn test_client() -> reqwest::Client { + reqwest::Client::new() + } + + #[tokio::test] + async fn test_parse_telegram_update() { let update = serde_json::json!({ "update_id": 123456, "message": { @@ -614,15 +684,16 @@ mod tests { } }); - let msg = parse_telegram_update(&update, &[]).unwrap(); + let client = test_client(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap(); assert_eq!(msg.channel, ChannelType::Telegram); assert_eq!(msg.sender.display_name, "Alice Smith"); assert_eq!(msg.sender.platform_id, "111222333"); assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello, agent!")); } - #[test] - fn test_parse_telegram_command() { + #[tokio::test] + async fn test_parse_telegram_command() { let update = serde_json::json!({ "update_id": 123457, "message": { @@ -645,7 +716,8 @@ mod tests { } }); - let msg = parse_telegram_update(&update, &[]).unwrap(); + let client = test_client(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap(); match &msg.content { ChannelContent::Command { name, args } => { assert_eq!(name, "agent"); @@ -655,8 +727,8 @@ mod tests { } } - #[test] - fn test_allowed_users_filter() { + #[tokio::test] + async fn test_allowed_users_filter() { let update = serde_json::json!({ "update_id": 123458, "message": { @@ -674,21 +746,25 @@ mod tests { } }); + let client = test_client(); + // Empty allowed_users = allow all - let msg = parse_telegram_update(&update, &[]); + let msg = parse_telegram_update(&update, &[], "fake:token", &client).await; assert!(msg.is_some()); // Non-matching allowed_users = filter out - let msg = parse_telegram_update(&update, &[111, 222]); + let blocked: Vec = vec!["111".to_string(), "222".to_string()]; + let msg = parse_telegram_update(&update, &blocked, "fake:token", &client).await; assert!(msg.is_none()); // Matching allowed_users = allow - let msg = parse_telegram_update(&update, &[999]); + let allowed: Vec = vec!["999".to_string()]; + let msg = parse_telegram_update(&update, &allowed, "fake:token", &client).await; assert!(msg.is_some()); } - #[test] - fn test_parse_telegram_edited_message() { + #[tokio::test] + async fn test_parse_telegram_edited_message() { let update = serde_json::json!({ "update_id": 123459, "edited_message": { @@ -708,7 +784,8 @@ mod tests { } }); - let msg = parse_telegram_update(&update, &[]).unwrap(); + let client = test_client(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap(); assert_eq!(msg.channel, ChannelType::Telegram); assert_eq!(msg.sender.display_name, "Alice Smith"); assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message!")); @@ -729,8 +806,8 @@ mod tests { assert_eq!(b4, Duration::from_secs(60)); // stays at cap } - #[test] - fn test_parse_command_with_botname() { + #[tokio::test] + async fn test_parse_command_with_botname() { let update = serde_json::json!({ "update_id": 100, "message": { @@ -743,7 +820,8 @@ mod tests { } }); - let msg = parse_telegram_update(&update, &[]).unwrap(); + let client = test_client(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap(); match &msg.content { ChannelContent::Command { name, args } => { assert_eq!(name, "agents"); @@ -752,4 +830,22 @@ mod tests { other => panic!("Expected Command, got {other:?}"), } } + + #[tokio::test] + async fn test_parse_telegram_location() { + let update = serde_json::json!({ + "update_id": 200, + "message": { + "message_id": 50, + "from": { "id": 123, "first_name": "Alice" }, + "chat": { "id": 123, "type": "private" }, + "date": 1700000000, + "location": { "latitude": 51.5074, "longitude": -0.1278 } + } + }); + + let client = test_client(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap(); + assert!(matches!(msg.content, ChannelContent::Location { .. })); + } } diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index 2ad2a3f9..65bc19cd 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -172,6 +172,14 @@ const PROVIDERS: &[ProviderInfo] = &[ needs_key: true, hint: "", }, + ProviderInfo { + name: "venice", + display: "Venice.ai", + env_var: "VENICE_API_KEY", + default_model: "venice-uncensored", + needs_key: true, + hint: "uncensored", + }, ProviderInfo { name: "ai21", display: "AI21", @@ -2006,11 +2014,7 @@ fn draw_routing_pick(f: &mut Frame, area: Rect, state: &mut State, tier: usize) .split('/') .next_back() .unwrap_or(&state.routing_models[t]); - let display = if short.len() > 14 { - &short[..14] - } else { - short - }; + let display = openfang_types::truncate_str(short, 14); summary_spans.push(Span::styled( format!("{name}:{display}"), Style::default().fg(*c), diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 7e17cf2d..266e4aa0 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -2479,7 +2479,7 @@ impl OpenFangKernel { .take(5) .enumerate() .map(|(i, t)| { - let truncated = if t.len() > 200 { &t[..200] } else { t }; + let truncated = openfang_types::truncate_str(t, 200); format!("{}. {}", i + 1, truncated) }) .collect::>() @@ -5229,7 +5229,7 @@ impl KernelHandle for OpenFangKernel { .unwrap_or_else(|e| e.into_inner()); agents .iter() - .map(|(url, card)| (card.name.clone(), url.clone())) + .map(|(_, card)| (card.name.clone(), card.url.clone())) .collect() } @@ -5242,7 +5242,7 @@ impl KernelHandle for OpenFangKernel { agents .iter() .find(|(_, card)| card.name.to_lowercase() == name_lower) - .map(|(url, _)| url.clone()) + .map(|(_, card)| card.url.clone()) } async fn send_channel_message( diff --git a/crates/openfang-kernel/src/metering.rs b/crates/openfang-kernel/src/metering.rs index 58dc4239..8a87a057 100644 --- a/crates/openfang-kernel/src/metering.rs +++ b/crates/openfang-kernel/src/metering.rs @@ -343,6 +343,11 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) { return (0.40, 0.40); } + // ── Venice.ai ────────────────────────────────────────────── + if model.contains("venice") { + return (0.20, 0.90); + } + // ── Open-source (Groq, Together, etc.) ───────────────────── if model.contains("llama-4-maverick") { return (0.50, 0.77); diff --git a/crates/openfang-runtime/src/a2a.rs b/crates/openfang-runtime/src/a2a.rs index 19317964..c52e2d69 100644 --- a/crates/openfang-runtime/src/a2a.rs +++ b/crates/openfang-runtime/src/a2a.rs @@ -87,8 +87,8 @@ pub struct A2aTask { /// Optional session identifier for conversation continuity. #[serde(default)] pub session_id: Option, - /// Current task status. - pub status: A2aTaskStatus, + /// Current task status (accepts both string and object forms). + pub status: A2aTaskStatusWrapper, /// Messages exchanged during the task. #[serde(default)] pub messages: Vec, @@ -115,6 +115,44 @@ pub enum A2aTaskStatus { Failed, } +/// Wrapper that accepts either a bare status string (`"completed"`) +/// or the object form (`{"state": "completed", "message": null}`) +/// used by some A2A implementations. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum A2aTaskStatusWrapper { + /// Object form: `{"state": "completed", "message": ...}`. + Object { + state: A2aTaskStatus, + #[serde(default)] + message: Option, + }, + /// Bare enum form: `"completed"`. + Enum(A2aTaskStatus), +} + +impl A2aTaskStatusWrapper { + /// Extract the underlying `A2aTaskStatus` regardless of encoding form. + pub fn state(&self) -> &A2aTaskStatus { + match self { + Self::Object { state, .. } => state, + Self::Enum(s) => s, + } + } +} + +impl From for A2aTaskStatusWrapper { + fn from(status: A2aTaskStatus) -> Self { + Self::Enum(status) + } +} + +impl PartialEq for A2aTaskStatusWrapper { + fn eq(&self, other: &A2aTaskStatus) -> bool { + self.state() == other + } +} + /// A2A message in a task conversation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct A2aMessage { @@ -145,9 +183,23 @@ pub enum A2aPart { /// A2A artifact produced by a task. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct A2aArtifact { - /// Artifact name. - pub name: String, + /// Artifact name (optional per spec). + #[serde(default)] + pub name: Option, + /// Human-readable description. + #[serde(default)] + pub description: Option, + /// Arbitrary metadata. + #[serde(default)] + pub metadata: Option, + /// Artifact index in the sequence. + #[serde(default)] + pub index: Option, + /// Whether this is the last chunk of a streamed artifact. + #[serde(default)] + pub last_chunk: Option, /// Artifact content parts. pub parts: Vec, } @@ -185,7 +237,7 @@ impl A2aTaskStore { .iter() .filter(|(_, t)| { matches!( - t.status, + t.status.state(), A2aTaskStatus::Completed | A2aTaskStatus::Failed | A2aTaskStatus::Cancelled ) }) @@ -211,7 +263,7 @@ impl A2aTaskStore { pub fn update_status(&self, task_id: &str, status: A2aTaskStatus) -> bool { let mut tasks = self.tasks.lock().unwrap_or_else(|e| e.into_inner()); if let Some(task) = tasks.get_mut(task_id) { - task.status = status; + task.status = status.into(); true } else { false @@ -224,7 +276,7 @@ impl A2aTaskStore { if let Some(task) = tasks.get_mut(task_id) { task.messages.push(response); task.artifacts.extend(artifacts); - task.status = A2aTaskStatus::Completed; + task.status = A2aTaskStatus::Completed.into(); } } @@ -233,7 +285,7 @@ impl A2aTaskStore { let mut tasks = self.tasks.lock().unwrap_or_else(|e| e.into_inner()); if let Some(task) = tasks.get_mut(task_id) { task.messages.push(error_message); - task.status = A2aTaskStatus::Failed; + task.status = A2aTaskStatus::Failed.into(); } } @@ -490,7 +542,7 @@ mod tests { let task = A2aTask { id: "task-1".to_string(), session_id: None, - status: A2aTaskStatus::Submitted, + status: A2aTaskStatus::Submitted.into(), messages: vec![], artifacts: vec![], }; @@ -498,30 +550,71 @@ mod tests { // Simulate progression let working = A2aTask { - status: A2aTaskStatus::Working, + status: A2aTaskStatus::Working.into(), ..task.clone() }; assert_eq!(working.status, A2aTaskStatus::Working); let completed = A2aTask { - status: A2aTaskStatus::Completed, + status: A2aTaskStatus::Completed.into(), ..task.clone() }; assert_eq!(completed.status, A2aTaskStatus::Completed); let cancelled = A2aTask { - status: A2aTaskStatus::Cancelled, + status: A2aTaskStatus::Cancelled.into(), ..task.clone() }; assert_eq!(cancelled.status, A2aTaskStatus::Cancelled); let failed = A2aTask { - status: A2aTaskStatus::Failed, + status: A2aTaskStatus::Failed.into(), ..task }; assert_eq!(failed.status, A2aTaskStatus::Failed); } + #[test] + fn test_a2a_task_status_wrapper_object_form() { + // Test deserialization of the object form: {"state": "completed", "message": null} + let json = r#"{"state":"completed","message":null}"#; + let wrapper: A2aTaskStatusWrapper = serde_json::from_str(json).unwrap(); + assert_eq!(wrapper, A2aTaskStatus::Completed); + assert_eq!(wrapper.state(), &A2aTaskStatus::Completed); + + // Test with a message payload + let json_with_msg = + r#"{"state":"working","message":{"text":"Processing..."}}"#; + let wrapper2: A2aTaskStatusWrapper = serde_json::from_str(json_with_msg).unwrap(); + assert_eq!(wrapper2, A2aTaskStatus::Working); + + // Test bare string form + let json_bare = r#""completed""#; + let wrapper3: A2aTaskStatusWrapper = serde_json::from_str(json_bare).unwrap(); + assert_eq!(wrapper3, A2aTaskStatus::Completed); + } + + #[test] + fn test_a2a_artifact_optional_fields() { + // name is now optional — artifact with no name should deserialize + let json = r#"{"parts":[{"type":"text","text":"hello"}]}"#; + let artifact: A2aArtifact = serde_json::from_str(json).unwrap(); + assert!(artifact.name.is_none()); + assert!(artifact.description.is_none()); + assert!(artifact.metadata.is_none()); + assert!(artifact.index.is_none()); + assert!(artifact.last_chunk.is_none()); + assert_eq!(artifact.parts.len(), 1); + + // Full artifact with all optional fields + let json_full = r#"{"name":"output.txt","description":"The result","metadata":{"key":"val"},"index":0,"lastChunk":true,"parts":[]}"#; + let full: A2aArtifact = serde_json::from_str(json_full).unwrap(); + assert_eq!(full.name.as_deref(), Some("output.txt")); + assert_eq!(full.description.as_deref(), Some("The result")); + assert_eq!(full.index, Some(0)); + assert_eq!(full.last_chunk, Some(true)); + } + #[test] fn test_a2a_message_serde() { let msg = A2aMessage { @@ -554,7 +647,7 @@ mod tests { let task = A2aTask { id: "t-1".to_string(), session_id: None, - status: A2aTaskStatus::Working, + status: A2aTaskStatus::Working.into(), messages: vec![], artifacts: vec![], }; @@ -571,7 +664,7 @@ mod tests { let task = A2aTask { id: "t-2".to_string(), session_id: None, - status: A2aTaskStatus::Working, + status: A2aTaskStatus::Working.into(), messages: vec![], artifacts: vec![], }; @@ -599,7 +692,7 @@ mod tests { let task = A2aTask { id: "t-3".to_string(), session_id: None, - status: A2aTaskStatus::Working, + status: A2aTaskStatus::Working.into(), messages: vec![], artifacts: vec![], }; @@ -618,7 +711,7 @@ mod tests { let task = A2aTask { id: format!("t-{i}"), session_id: None, - status: A2aTaskStatus::Completed, + status: A2aTaskStatus::Completed.into(), messages: vec![], artifacts: vec![], }; @@ -630,7 +723,7 @@ mod tests { let task = A2aTask { id: "t-2".to_string(), session_id: None, - status: A2aTaskStatus::Working, + status: A2aTaskStatus::Working.into(), messages: vec![], artifacts: vec![], }; diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index 0e6fbef6..5a69938e 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -17,7 +17,7 @@ use openfang_types::model_catalog::{ FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, - REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, + REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, }; @@ -194,6 +194,11 @@ fn provider_defaults(provider: &str) -> Option { api_key_env: "VOLCENGINE_API_KEY", key_required: true, }), + "venice" => Some(ProviderDefaults { + base_url: VENICE_BASE_URL, + api_key_env: "VENICE_API_KEY", + key_required: true, + }), _ => None, } } @@ -345,7 +350,7 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr "Unknown provider '{}'. Supported: anthropic, gemini, openai, groq, openrouter, \ deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \ cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \ - codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.", + venice, codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.", provider ), }) @@ -382,6 +387,7 @@ pub fn known_providers() -> &'static [&'static str] { "zhipu_coding", "qianfan", "volcengine", + "venice", "codex", "claude-code", ] @@ -480,7 +486,7 @@ mod tests { assert!(providers.contains(&"volcengine")); assert!(providers.contains(&"codex")); assert!(providers.contains(&"claude-code")); - assert_eq!(providers.len(), 30); + assert_eq!(providers.len(), 31); } #[test] diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 3c4198e9..63c7cdfd 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -1,6 +1,6 @@ //! Model catalog — registry of known models with metadata, pricing, and auth detection. //! -//! Provides a comprehensive catalog of 130+ builtin models across 27 providers, +//! Provides a comprehensive catalog of 130+ builtin models across 28 providers, //! with alias resolution, auth status detection, and pricing lookups. use openfang_types::model_catalog::{ @@ -9,7 +9,7 @@ use openfang_types::model_catalog::{ GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, - REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, + REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, }; @@ -582,6 +582,16 @@ fn builtin_providers() -> Vec { auth_status: AuthStatus::Missing, model_count: 0, }, + // ── Venice.ai ──────────────────────────────────────────────── + ProviderInfo { + id: "venice".into(), + display_name: "Venice.ai".into(), + api_key_env: "VENICE_API_KEY".into(), + base_url: VENICE_BASE_URL.into(), + key_required: true, + auth_status: AuthStatus::Missing, + model_count: 0, + }, // ── Chinese providers (5) ──────────────────────────────────── ProviderInfo { id: "qwen".into(), @@ -767,6 +777,8 @@ fn builtin_aliases() -> HashMap { ("codex", "codex/gpt-4.1"), ("codex-4.1", "codex/gpt-4.1"), ("codex-o4", "codex/o4-mini"), + // Venice aliases + ("venice", "venice-uncensored"), // Claude Code aliases ("claude-code", "claude-code/sonnet"), ("claude-code-opus", "claude-code/opus"), @@ -3170,6 +3182,51 @@ fn builtin_models() -> Vec { supports_streaming: true, aliases: vec!["claude-code-haiku".into()], }, + // ══════════════════════════════════════════════════════════════ + // Venice.ai (3) + // ══════════════════════════════════════════════════════════════ + ModelCatalogEntry { + id: "venice-uncensored".into(), + display_name: "Venice Uncensored".into(), + provider: "venice".into(), + tier: ModelTier::Fast, + context_window: 32_000, + max_output_tokens: 8_192, + input_cost_per_m: 0.20, + output_cost_per_m: 0.90, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec!["venice".into()], + }, + ModelCatalogEntry { + id: "llama-3.3-70b".into(), + display_name: "Llama 3.3 70B (Venice)".into(), + provider: "venice".into(), + tier: ModelTier::Balanced, + context_window: 128_000, + max_output_tokens: 8_192, + input_cost_per_m: 0.20, + output_cost_per_m: 0.90, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "qwen3-235b-a22b-instruct-2507".into(), + display_name: "Qwen3 235B A22B (Venice)".into(), + provider: "venice".into(), + tier: ModelTier::Smart, + context_window: 128_000, + max_output_tokens: 8_192, + input_cost_per_m: 0.20, + output_cost_per_m: 0.90, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, ] } @@ -3186,7 +3243,7 @@ mod tests { #[test] fn test_catalog_has_providers() { let catalog = ModelCatalog::new(); - assert_eq!(catalog.list_providers().len(), 34); + assert_eq!(catalog.list_providers().len(), 35); } #[test] diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 617c09bf..5e54a674 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -4,6 +4,26 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +/// Deserialize a `Vec` that tolerates both string and integer elements. +/// +/// When channel configs are saved from the web dashboard, numeric IDs (e.g. Discord +/// guild snowflakes, Telegram user IDs) are stored as TOML integers. This helper +/// transparently converts integers back to strings so deserialization never fails. +fn deserialize_string_or_int_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let values: Vec = serde::Deserialize::deserialize(deserializer)?; + Ok(values + .into_iter() + .map(|v| match v { + serde_json::Value::String(s) => s, + serde_json::Value::Number(n) => n.to_string(), + other => other.to_string(), + }) + .collect()) +} + /// DM (direct message) policy for a channel. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -1530,7 +1550,9 @@ pub struct TelegramConfig { /// Env var name holding the bot token (NOT the token itself). pub bot_token_env: String, /// Telegram user IDs allowed to interact (empty = allow all). - pub allowed_users: Vec, + /// Accepts strings for consistency; numeric TOML integers are coerced to strings. + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] + pub allowed_users: Vec, /// Default agent name to route messages to. pub default_agent: Option, /// Polling interval in seconds. @@ -1560,9 +1582,10 @@ pub struct DiscordConfig { pub bot_token_env: String, /// Guild (server) IDs allowed to interact (empty = allow all). /// Accepts strings for consistency with other channel configs. + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_guilds: Vec, /// User IDs allowed to interact (empty = allow all). - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_users: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -1595,6 +1618,7 @@ pub struct SlackConfig { /// Env var name holding the bot token (xoxb-) for REST API. pub bot_token_env: String, /// Channel IDs allowed to interact (empty = allow all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_channels: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -1631,6 +1655,7 @@ pub struct WhatsAppConfig { /// When set, outgoing messages are routed through the gateway instead of Cloud API. pub gateway_url_env: String, /// Allowed phone numbers (empty = allow all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_users: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -1663,6 +1688,7 @@ pub struct SignalConfig { /// Registered phone number. pub phone_number: String, /// Allowed phone numbers (empty = allow all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_users: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -1694,6 +1720,7 @@ pub struct MatrixConfig { /// Env var name holding the access token. pub access_token_env: String, /// Room IDs to listen in (empty = all joined rooms). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_rooms: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -1734,8 +1761,10 @@ pub struct EmailConfig { /// Poll interval in seconds. pub poll_interval_secs: u64, /// IMAP folders to monitor. + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub folders: Vec, /// Only process emails from these senders (empty = all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_senders: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -1773,6 +1802,7 @@ pub struct TeamsConfig { /// Port for the incoming webhook. pub webhook_port: u16, /// Allowed tenant IDs (empty = allow all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_tenants: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -1803,6 +1833,7 @@ pub struct MattermostConfig { /// Env var name holding the bot token. pub token_env: String, /// Allowed channel IDs (empty = all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_channels: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -1836,6 +1867,7 @@ pub struct IrcConfig { /// Env var name holding the server password (optional). pub password_env: Option, /// Channels to join (e.g., `["#openfang", "#general"]`). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub channels: Vec, /// Use TLS (requires tokio-native-tls). pub use_tls: bool, @@ -1868,6 +1900,7 @@ pub struct GoogleChatConfig { /// Env var name holding the service account JSON key. pub service_account_env: String, /// Space IDs to listen in. + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub space_ids: Vec, /// Port for the incoming webhook. pub webhook_port: u16, @@ -1897,6 +1930,7 @@ pub struct TwitchConfig { /// Env var name holding the OAuth token. pub oauth_token_env: String, /// Twitch channels to join (without #). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub channels: Vec, /// Bot nickname. pub nick: String, @@ -1930,6 +1964,7 @@ pub struct RocketChatConfig { /// User ID for the bot. pub user_id: String, /// Allowed channel IDs (empty = all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_channels: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -1962,6 +1997,7 @@ pub struct ZulipConfig { /// Env var name holding the API key. pub api_key_env: String, /// Streams to listen in. + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub streams: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -1996,6 +2032,7 @@ pub struct XmppConfig { /// XMPP server port. pub port: u16, /// MUC rooms to join. + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub rooms: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -2120,6 +2157,7 @@ pub struct RedditConfig { /// Env var name holding the bot password. pub password_env: String, /// Subreddits to monitor. + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub subreddits: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -2263,6 +2301,7 @@ pub struct NextcloudConfig { /// Env var name holding the auth token. pub token_env: String, /// Room tokens to listen in (empty = all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_rooms: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -2290,6 +2329,7 @@ pub struct GuildedConfig { /// Env var name holding the bot token. pub bot_token_env: String, /// Server IDs to listen in (empty = all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub server_ids: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -2318,6 +2358,7 @@ pub struct KeybaseConfig { /// Env var name holding the paper key. pub paperkey_env: String, /// Team names to listen in (empty = all DMs). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_teams: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -2374,6 +2415,7 @@ pub struct NostrConfig { /// Env var name holding the private key (nsec or hex). pub private_key_env: String, /// Relay URLs to connect to. + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub relays: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -2400,6 +2442,7 @@ pub struct WebexConfig { /// Env var name holding the bot token. pub bot_token_env: String, /// Room IDs to listen in (empty = all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_rooms: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -2480,6 +2523,7 @@ pub struct TwistConfig { /// Workspace ID. pub workspace_id: String, /// Channel IDs to listen in (empty = all). + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub allowed_channels: Vec, /// Default agent name to route messages to. pub default_agent: Option, @@ -2577,6 +2621,7 @@ pub struct DiscourseConfig { /// API username. pub api_username: String, /// Category slugs to monitor. + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] pub categories: Vec, /// Default agent name to route messages to. pub default_agent: Option, diff --git a/crates/openfang-types/src/lib.rs b/crates/openfang-types/src/lib.rs index 339337b1..fbfd88fa 100644 --- a/crates/openfang-types/src/lib.rs +++ b/crates/openfang-types/src/lib.rs @@ -59,6 +59,16 @@ mod tests { assert_eq!(truncate_str(s, 6), "hi\u{1F600}"); // after emoji } + #[test] + fn truncate_str_em_dash() { + // Em dash (—) is 3 bytes (0xE2 0x80 0x94) — the exact char that caused + // production panics in kernel.rs and session.rs (issue #104) + let s = "Here is a summary — with details"; + assert_eq!(truncate_str(s, 19), "Here is a summary "); + assert_eq!(truncate_str(s, 20), "Here is a summary "); + assert_eq!(truncate_str(s, 21), "Here is a summary \u{2014}"); + } + #[test] fn truncate_str_no_truncation() { assert_eq!(truncate_str("short", 100), "short"); diff --git a/crates/openfang-types/src/model_catalog.rs b/crates/openfang-types/src/model_catalog.rs index e1fbb17d..3e98e76b 100644 --- a/crates/openfang-types/src/model_catalog.rs +++ b/crates/openfang-types/src/model_catalog.rs @@ -28,6 +28,7 @@ pub const SAMBANOVA_BASE_URL: &str = "https://api.sambanova.ai/v1"; pub const HUGGINGFACE_BASE_URL: &str = "https://api-inference.huggingface.co/v1"; pub const XAI_BASE_URL: &str = "https://api.x.ai/v1"; pub const REPLICATE_BASE_URL: &str = "https://api.replicate.com/v1"; +pub const VENICE_BASE_URL: &str = "https://api.venice.ai/api/v1"; // ── GitHub Copilot ────────────────────────────────────────────── pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com"; diff --git a/scripts/install.sh b/scripts/install.sh index a36ca743..65c53b35 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -110,6 +110,11 @@ install() { tar xzf "$ARCHIVE" -C "$INSTALL_DIR" chmod +x "$INSTALL_DIR/openfang" + # Ad-hoc codesign on macOS (prevents SIGKILL on Apple Silicon) + if [ "$OS" = "darwin" ] && command -v codesign &>/dev/null; then + codesign --force --sign - "$INSTALL_DIR/openfang" 2>/dev/null || true + fi + # Add to PATH SHELL_RC="" case "${SHELL:-}" in