bugfix batch

This commit is contained in:
jaberjaber23
2026-03-04 15:22:10 +03:00
parent c3dcf02e3c
commit 1037ef768d
12 changed files with 246 additions and 45 deletions
Generated
+14 -14
View File
@@ -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"
+1 -1
View File
@@ -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"
@@ -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()));
+17 -2
View File
@@ -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<toml::Value> = v
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<i64>()
.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);
+5 -1
View File
@@ -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 <model>` 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()
+9 -2
View File
@@ -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 => {}
}
+141 -14
View File
@@ -39,6 +39,7 @@ pub struct DiscordAdapter {
token: Zeroizing<String>,
client: reqwest::Client,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
intents: u64,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
@@ -51,12 +52,18 @@ pub struct DiscordAdapter {
}
impl DiscordAdapter {
pub fn new(token: String, allowed_guilds: Vec<String>, intents: u64) -> Self {
pub fn new(
token: String,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
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<RwLock<Option<String>>>,
allowed_guilds: &[String],
allowed_users: &[String],
) -> Option<ChannelMessage> {
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);
}
+12 -6
View File
@@ -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<String> {
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());
}
_ => {}
}
+6
View File
@@ -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);
}
+6 -3
View File
@@ -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()
}
+30 -2
View File
@@ -693,7 +693,7 @@ fn builtin_aliases() -> HashMap<String, String> {
("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<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Zhipu AI / GLM (4)
// Zhipu AI / GLM (6)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "glm-4-plus".into(),
@@ -2652,6 +2652,34 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
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)
// ══════════════════════════════════════════════════════════════
+4
View File
@@ -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<String>,
/// User IDs allowed to interact (empty = allow all).
#[serde(default)]
pub allowed_users: Vec<String>,
/// Default agent name to route messages to.
pub default_agent: Option<String>,
/// 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(),