diff --git a/Cargo.lock b/Cargo.lock index 5c609179..4be34fa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3866,7 +3866,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.3.11" +version = "0.3.12" dependencies = [ "async-trait", "axum", @@ -3902,7 +3902,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.3.11" +version = "0.3.12" dependencies = [ "async-trait", "axum", @@ -3933,7 +3933,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.3.11" +version = "0.3.12" dependencies = [ "clap", "clap_complete", @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.3.11" +version = "0.3.12" dependencies = [ "axum", "open", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.3.11" +version = "0.3.12" dependencies = [ "aes-gcm", "argon2", @@ -4014,7 +4014,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.3.11" +version = "0.3.12" dependencies = [ "chrono", "dashmap", @@ -4031,7 +4031,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.3.11" +version = "0.3.12" dependencies = [ "async-trait", "chrono", @@ -4067,7 +4067,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.3.11" +version = "0.3.12" dependencies = [ "async-trait", "chrono", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.3.11" +version = "0.3.12" dependencies = [ "chrono", "dirs 6.0.0", @@ -4105,7 +4105,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.3.11" +version = "0.3.12" dependencies = [ "anyhow", "async-trait", @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.3.11" +version = "0.3.12" dependencies = [ "chrono", "hex", @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.3.11" +version = "0.3.12" dependencies = [ "async-trait", "chrono", @@ -4179,7 +4179,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.3.11" +version = "0.3.12" dependencies = [ "async-trait", "chrono", @@ -8791,7 +8791,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.3.11" +version = "0.3.12" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index 5d8b0a6c..49c84283 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.3.12" +version = "0.3.13" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index 6d30b191..9285d060 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -1041,6 +1041,7 @@ pub async fn start_channel_bridge_with_config( let adapter = Arc::new(DiscordAdapter::new( token, dc_config.allowed_guilds.clone(), + dc_config.allowed_users.clone(), dc_config.intents, )); adapters.push((adapter, dc_config.default_agent.clone())); diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index d2f66392..e8f2b5e2 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -1169,6 +1169,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[ fields: &[ ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("DISCORD_BOT_TOKEN"), required: true, placeholder: "MTIz...", advanced: false }, ChannelField { key: "allowed_guilds", label: "Allowed Guild IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true }, + ChannelField { key: "allowed_users", label: "Allowed User IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true }, ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "37376", advanced: true }, ], @@ -5099,10 +5100,11 @@ pub async fn list_models( true }) .map(|m| { + // Custom models from unknown providers are assumed available let available = catalog .get_provider(&m.provider) .map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing) - .unwrap_or(false); + .unwrap_or(m.tier == openfang_types::model_catalog::ModelTier::Custom); serde_json::json!({ "id": m.id, "display_name": m.display_name, @@ -5176,7 +5178,7 @@ pub async fn get_model( let available = catalog .get_provider(&m.provider) .map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing) - .unwrap_or(false); + .unwrap_or(m.tier == openfang_types::model_catalog::ModelTier::Custom); ( StatusCode::OK, Json(serde_json::json!({ @@ -6847,6 +6849,19 @@ fn upsert_channel_config( toml::Value::String(v.clone()) } } + FieldType::List => { + 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())) + }) + .collect(); + toml::Value::Array(items) + } _ => toml::Value::String(v.clone()), }; ch_table.insert(k.clone(), toml_val); diff --git a/crates/openfang-api/src/ws.rs b/crates/openfang-api/src/ws.rs index 412fec46..6b6a526e 100644 --- a/crates/openfang-api/src/ws.rs +++ b/crates/openfang-api/src/ws.rs @@ -1114,7 +1114,11 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String } llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(), llm_errors::LlmErrorCategory::ModelNotFound => { - "Model unavailable. Use /model to see options.".to_string() + if inner.contains("localhost:11434") || inner.contains("ollama") { + "Model not found on Ollama. Run `ollama pull ` to download it, then try again. Use /model to see options.".to_string() + } else { + "Model unavailable. Use /model to see options or check your provider configuration.".to_string() + } } llm_errors::LlmErrorCategory::Format => { "LLM request failed. Check your API key and model configuration in Settings.".to_string() diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 3c9aad68..efb9ca52 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -407,8 +407,15 @@ async fn dispatch_message( } } GroupPolicy::MentionOnly => { - // Pass through — adapters should only forward mentioned messages. - // This is a hint for adapters, not enforced here. + // Only allow messages where the bot was @mentioned or commands. + let was_mentioned = message.metadata.get("was_mentioned") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_command = matches!(&message.content, ChannelContent::Command { .. }); + if !was_mentioned && !is_command { + debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)"); + return; + } } GroupPolicy::All => {} } diff --git a/crates/openfang-channels/src/discord.rs b/crates/openfang-channels/src/discord.rs index 696b677c..993bb490 100644 --- a/crates/openfang-channels/src/discord.rs +++ b/crates/openfang-channels/src/discord.rs @@ -39,6 +39,7 @@ pub struct DiscordAdapter { token: Zeroizing, client: reqwest::Client, allowed_guilds: Vec, + allowed_users: Vec, intents: u64, shutdown_tx: Arc>, shutdown_rx: watch::Receiver, @@ -51,12 +52,18 @@ pub struct DiscordAdapter { } impl DiscordAdapter { - pub fn new(token: String, allowed_guilds: Vec, intents: u64) -> Self { + pub fn new( + token: String, + allowed_guilds: Vec, + allowed_users: Vec, + intents: u64, + ) -> Self { let (shutdown_tx, shutdown_rx) = watch::channel(false); Self { token: Zeroizing::new(token), client: reqwest::Client::new(), allowed_guilds, + allowed_users, intents, shutdown_tx: Arc::new(shutdown_tx), shutdown_rx, @@ -147,6 +154,7 @@ impl ChannelAdapter for DiscordAdapter { let token = self.token.clone(); let intents = self.intents; let allowed_guilds = self.allowed_guilds.clone(); + let allowed_users = self.allowed_users.clone(); let bot_user_id = self.bot_user_id.clone(); let session_id_store = self.session_id.clone(); let resume_url_store = self.resume_gateway_url.clone(); @@ -307,7 +315,7 @@ impl ChannelAdapter for DiscordAdapter { "MESSAGE_CREATE" | "MESSAGE_UPDATE" => { if let Some(msg) = - parse_discord_message(d, &bot_user_id, &allowed_guilds) + parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users) .await { debug!( @@ -423,6 +431,7 @@ async fn parse_discord_message( d: &serde_json::Value, bot_user_id: &Arc>>, allowed_guilds: &[String], + allowed_users: &[String], ) -> Option { let author = d.get("author")?; let author_id = author["id"].as_str()?; @@ -439,6 +448,12 @@ async fn parse_discord_message( return None; } + // Filter by allowed users + if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == author_id) { + debug!("Discord: ignoring message from unlisted user {author_id}"); + return None; + } + // Filter by allowed guilds if !allowed_guilds.is_empty() { if let Some(guild_id) = d["guild_id"].as_str() { @@ -486,6 +501,29 @@ async fn parse_discord_message( ChannelContent::Text(content_text.to_string()) }; + // Determine if this is a group message (guild_id present = server channel) + let is_group = d["guild_id"].as_str().is_some(); + + // Check if bot was @mentioned (for MentionOnly policy enforcement) + let was_mentioned = if let Some(ref bid) = *bot_user_id.read().await { + // Check Discord mentions array + let mentioned_in_array = d["mentions"] + .as_array() + .map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str()))) + .unwrap_or(false); + // Also check content for <@bot_id> or <@!bot_id> patterns + let mentioned_in_content = + content_text.contains(&format!("<@{bid}>")) || content_text.contains(&format!("<@!{bid}>")); + mentioned_in_array || mentioned_in_content + } else { + false + }; + + let mut metadata = HashMap::new(); + if was_mentioned { + metadata.insert("was_mentioned".to_string(), serde_json::json!(true)); + } + Some(ChannelMessage { channel: ChannelType::Discord, platform_message_id: message_id.to_string(), @@ -497,9 +535,9 @@ async fn parse_discord_message( content, target_agent: None, timestamp, - is_group: true, + is_group, thread_id: None, - metadata: HashMap::new(), + metadata, }) } @@ -523,7 +561,7 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap(); + let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap(); assert_eq!(msg.channel, ChannelType::Discord); assert_eq!(msg.sender.display_name, "alice"); assert_eq!(msg.sender.platform_id, "ch1"); @@ -545,7 +583,7 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[]).await; + let msg = parse_discord_message(&d, &bot_id, &[], &[]).await; assert!(msg.is_none()); } @@ -565,7 +603,7 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[]).await; + let msg = parse_discord_message(&d, &bot_id, &[], &[]).await; assert!(msg.is_none()); } @@ -586,11 +624,11 @@ mod tests { }); // Not in allowed guilds - let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()]).await; + let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[]).await; assert!(msg.is_none()); // In allowed guilds - let msg = parse_discord_message(&d, &bot_id, &["999".into()]).await; + let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[]).await; assert!(msg.is_some()); } @@ -609,7 +647,7 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap(); + let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap(); match &msg.content { ChannelContent::Command { name, args } => { assert_eq!(name, "agent"); @@ -634,7 +672,7 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[]).await; + let msg = parse_discord_message(&d, &bot_id, &[], &[]).await; assert!(msg.is_none()); } @@ -653,7 +691,7 @@ mod tests { "timestamp": "2024-01-01T00:00:00+00:00" }); - let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap(); + let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap(); assert_eq!(msg.sender.display_name, "alice#1234"); } @@ -675,16 +713,105 @@ mod tests { }); // MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE - let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap(); + let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap(); assert_eq!(msg.channel, ChannelType::Discord); assert!( matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content") ); } + #[tokio::test] + async fn test_parse_discord_allowed_users_filter() { + let bot_id = Arc::new(RwLock::new(Some("bot123".to_string()))); + let d = serde_json::json!({ + "id": "msg1", + "channel_id": "ch1", + "content": "Hello", + "author": { + "id": "user999", + "username": "bob", + "discriminator": "0" + }, + "timestamp": "2024-01-01T00:00:00+00:00" + }); + + // Not in allowed users + let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()]).await; + assert!(msg.is_none()); + + // In allowed users + let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()]).await; + assert!(msg.is_some()); + + // Empty allowed_users = allow all + let msg = parse_discord_message(&d, &bot_id, &[], &[]).await; + assert!(msg.is_some()); + } + + #[tokio::test] + async fn test_parse_discord_mention_detection() { + let bot_id = Arc::new(RwLock::new(Some("bot123".to_string()))); + + // Message with bot mentioned in mentions array + let d = serde_json::json!({ + "id": "msg1", + "channel_id": "ch1", + "guild_id": "guild1", + "content": "Hey <@bot123> help me", + "mentions": [{"id": "bot123", "username": "openfang"}], + "author": { + "id": "user1", + "username": "alice", + "discriminator": "0" + }, + "timestamp": "2024-01-01T00:00:00+00:00" + }); + + let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap(); + assert!(msg.is_group); + assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true)); + + // Message without mention in group + let d2 = serde_json::json!({ + "id": "msg2", + "channel_id": "ch1", + "guild_id": "guild1", + "content": "Just chatting", + "author": { + "id": "user1", + "username": "alice", + "discriminator": "0" + }, + "timestamp": "2024-01-01T00:00:00+00:00" + }); + + let msg2 = parse_discord_message(&d2, &bot_id, &[], &[]).await.unwrap(); + assert!(msg2.is_group); + assert!(!msg2.metadata.contains_key("was_mentioned")); + } + + #[tokio::test] + async fn test_parse_discord_dm_not_group() { + let bot_id = Arc::new(RwLock::new(None)); + let d = serde_json::json!({ + "id": "msg1", + "channel_id": "dm-ch1", + "content": "Hello", + "author": { + "id": "user1", + "username": "alice", + "discriminator": "0" + }, + "timestamp": "2024-01-01T00:00:00+00:00" + }); + + let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap(); + assert!(!msg.is_group); + } + #[test] fn test_discord_adapter_creation() { - let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], 37376); + let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], 37376); assert_eq!(adapter.name(), "discord"); assert_eq!(adapter.channel_type(), ChannelType::Discord); } diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 405ef7dc..7ba9a345 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -4652,17 +4652,23 @@ fn apply_budget_defaults( /// This is a defense-in-depth fallback — models should ideally be in the catalog. fn infer_provider_from_model(model: &str) -> Option { let lower = model.to_lowercase(); - // Check for explicit provider prefix (e.g., "minimax/MiniMax-M2.5") - if let Some(prefix) = lower.split('/').next() { + // Check for explicit provider prefix with / or : delimiter + // (e.g., "minimax/MiniMax-M2.5" or "qwen:qwen-plus") + let (prefix, has_delim) = if let Some(idx) = lower.find('/') { + (&lower[..idx], true) + } else if let Some(idx) = lower.find(':') { + (&lower[..idx], true) + } else { + (lower.as_str(), false) + }; + if has_delim { match prefix { "minimax" | "gemini" | "anthropic" | "openai" | "groq" | "deepseek" | "mistral" | "cohere" | "xai" | "ollama" | "together" | "fireworks" | "perplexity" | "cerebras" | "sambanova" | "replicate" | "huggingface" | "ai21" | "codex" | "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "moonshot" - | "openrouter" => { - if model.contains('/') { - return Some(prefix.to_string()); - } + | "openrouter" | "volcengine" | "doubao" | "dashscope" => { + return Some(prefix.to_string()); } _ => {} } diff --git a/crates/openfang-kernel/src/metering.rs b/crates/openfang-kernel/src/metering.rs index 3c2b58b6..58dc4239 100644 --- a/crates/openfang-kernel/src/metering.rs +++ b/crates/openfang-kernel/src/metering.rs @@ -376,6 +376,12 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) { } // ── Zhipu / GLM ───────────────────────────────────────────── + if model.contains("glm-5") { + return (2.00, 8.00); + } + if model.contains("glm-4.7") { + return (1.50, 5.00); + } if model.contains("glm-4-flash") { return (0.10, 0.10); } diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index f71b33a7..c326c8b6 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -57,9 +57,12 @@ const MAX_HISTORY_MESSAGES: usize = 20; /// but the upstream API expects just `org/model`. This also handles special routers /// like `openrouter/auto` → `auto`. pub fn strip_provider_prefix(model: &str, provider: &str) -> String { - let prefix = format!("{}/", provider); - if model.starts_with(&prefix) { - model[prefix.len()..].to_string() + let slash_prefix = format!("{}/", provider); + let colon_prefix = format!("{}:", provider); + if model.starts_with(&slash_prefix) { + model[slash_prefix.len()..].to_string() + } else if model.starts_with(&colon_prefix) { + model[colon_prefix.len()..].to_string() } else { model.to_string() } diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 5bbff21b..3eb8baee 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -693,7 +693,7 @@ fn builtin_aliases() -> HashMap { ("copilot-gpt4", "copilot/gpt-4"), // Chinese model aliases ("qwen", "qwen-plus"), - ("glm", "glm-4-plus"), + ("glm", "glm-5-20250605"), ("ernie", "ernie-4.5-8k"), ("kimi", "moonshot-v1-128k"), ("minimax", "MiniMax-M2.5"), @@ -2594,7 +2594,7 @@ fn builtin_models() -> Vec { aliases: vec![], }, // ══════════════════════════════════════════════════════════════ - // Zhipu AI / GLM (4) + // Zhipu AI / GLM (6) // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { id: "glm-4-plus".into(), @@ -2652,6 +2652,34 @@ fn builtin_models() -> Vec { supports_streaming: true, aliases: vec![], }, + ModelCatalogEntry { + id: "glm-5-20250605".into(), + display_name: "GLM-5".into(), + provider: "zhipu".into(), + tier: ModelTier::Frontier, + context_window: 131_072, + max_output_tokens: 16_384, + input_cost_per_m: 2.00, + output_cost_per_m: 8.00, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["glm-5".into()], + }, + ModelCatalogEntry { + id: "glm-4.7".into(), + display_name: "GLM-4.7".into(), + provider: "zhipu".into(), + tier: ModelTier::Smart, + context_window: 131_072, + max_output_tokens: 16_384, + input_cost_per_m: 1.50, + output_cost_per_m: 5.00, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, // ══════════════════════════════════════════════════════════════ // Zhipu Coding / CodeGeeX (1) // ══════════════════════════════════════════════════════════════ diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index a5455d79..617c09bf 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1561,6 +1561,9 @@ pub struct DiscordConfig { /// Guild (server) IDs allowed to interact (empty = allow all). /// Accepts strings for consistency with other channel configs. pub allowed_guilds: Vec, + /// User IDs allowed to interact (empty = allow all). + #[serde(default)] + pub allowed_users: Vec, /// Default agent name to route messages to. pub default_agent: Option, /// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT). @@ -1575,6 +1578,7 @@ impl Default for DiscordConfig { Self { bot_token_env: "DISCORD_BOT_TOKEN".to_string(), allowed_guilds: vec![], + allowed_users: vec![], default_agent: None, intents: 37376, overrides: ChannelOverrides::default(),