feat(discord): add listen_to_bots config and fix model IDs across codebase (#280)
* fix(config): apply env overrides at runtime and fix Docker compose defaults - Call apply_env_overrides() after Config::load_or_init() in main.rs so environment variables (API_KEY, PROVIDER, ZEROCLAW_GATEWAY_PORT, etc.) are actually applied at runtime, not just in tests - Add ZEROCLAW_ALLOW_PUBLIC_BIND env var support for gateway bind policy - Fix docker-compose.yml: correct volume path (/zeroclaw-data not /data), add ZEROCLAW_ALLOW_PUBLIC_BIND=true for container networking, make host port configurable via HOST_PORT env var - Add docker-compose.override.yml to .gitignore for local dev overrides * feat(discord): add listen_to_bots config and fix model IDs across codebase Add listen_to_bots field to DiscordConfig so bot messages are processed when explicitly enabled (defaults to false for backward compat). Remove ZEROCLAW_MODEL from Dockerfile release stage so config.toml is the source of truth for model selection. Fix all hardcoded model IDs from the dated anthropic/claude-sonnet-4-20250514 to the valid OpenRouter identifier anthropic/claude-sonnet-4.
This commit is contained in:
parent
d5e8fc1652
commit
40c41cf3d2
8 changed files with 34 additions and 25 deletions
|
|
@ -94,9 +94,9 @@ COPY --from=permissions /zeroclaw-data /zeroclaw-data
|
|||
# Environment setup
|
||||
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
|
||||
ENV HOME=/zeroclaw-data
|
||||
# Defaults for prod (OpenRouter)
|
||||
# Default provider (model is set in config.toml, not here,
|
||||
# so config file edits are not silently overridden)
|
||||
ENV PROVIDER="openrouter"
|
||||
ENV ZEROCLAW_MODEL="anthropic/claude-sonnet-4-20250514"
|
||||
ENV ZEROCLAW_GATEWAY_PORT=3000
|
||||
|
||||
# API_KEY must be provided at runtime!
|
||||
|
|
|
|||
|
|
@ -489,7 +489,7 @@ pub async fn run(
|
|||
let model_name = model_override
|
||||
.as_deref()
|
||||
.or(config.default_model.as_deref())
|
||||
.unwrap_or("anthropic/claude-sonnet-4-20250514");
|
||||
.unwrap_or("anthropic/claude-sonnet-4");
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider(
|
||||
provider_name,
|
||||
|
|
|
|||
|
|
@ -10,16 +10,18 @@ pub struct DiscordChannel {
|
|||
bot_token: String,
|
||||
guild_id: Option<String>,
|
||||
allowed_users: Vec<String>,
|
||||
listen_to_bots: bool,
|
||||
client: reqwest::Client,
|
||||
typing_handle: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl DiscordChannel {
|
||||
pub fn new(bot_token: String, guild_id: Option<String>, allowed_users: Vec<String>) -> Self {
|
||||
pub fn new(bot_token: String, guild_id: Option<String>, allowed_users: Vec<String>, listen_to_bots: bool) -> Self {
|
||||
Self {
|
||||
bot_token,
|
||||
guild_id,
|
||||
allowed_users,
|
||||
listen_to_bots,
|
||||
client: reqwest::Client::new(),
|
||||
typing_handle: std::sync::Mutex::new(None),
|
||||
}
|
||||
|
|
@ -309,8 +311,8 @@ impl Channel for DiscordChannel {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Skip bot messages
|
||||
if d.get("author").and_then(|a| a.get("bot")).and_then(serde_json::Value::as_bool).unwrap_or(false) {
|
||||
// Skip bot messages (unless listen_to_bots is enabled)
|
||||
if !self.listen_to_bots && d.get("author").and_then(|a| a.get("bot")).and_then(serde_json::Value::as_bool).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -411,7 +413,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn discord_channel_name() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![], false);
|
||||
assert_eq!(ch.name(), "discord");
|
||||
}
|
||||
|
||||
|
|
@ -432,21 +434,21 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn empty_allowlist_denies_everyone() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![], false);
|
||||
assert!(!ch.is_user_allowed("12345"));
|
||||
assert!(!ch.is_user_allowed("anyone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_allows_everyone() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["*".into()]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["*".into()], false);
|
||||
assert!(ch.is_user_allowed("12345"));
|
||||
assert!(ch.is_user_allowed("anyone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specific_allowlist_filters() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into(), "222".into()]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into(), "222".into()], false);
|
||||
assert!(ch.is_user_allowed("111"));
|
||||
assert!(ch.is_user_allowed("222"));
|
||||
assert!(!ch.is_user_allowed("333"));
|
||||
|
|
@ -455,7 +457,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn allowlist_is_exact_match_not_substring() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false);
|
||||
assert!(!ch.is_user_allowed("1111"));
|
||||
assert!(!ch.is_user_allowed("11"));
|
||||
assert!(!ch.is_user_allowed("0111"));
|
||||
|
|
@ -463,20 +465,20 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn allowlist_empty_string_user_id() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false);
|
||||
assert!(!ch.is_user_allowed(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_with_wildcard_and_specific() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into(), "*".into()]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into(), "*".into()], false);
|
||||
assert!(ch.is_user_allowed("111"));
|
||||
assert!(ch.is_user_allowed("anyone_else"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_case_sensitive() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["ABC".into()]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec!["ABC".into()], false);
|
||||
assert!(ch.is_user_allowed("ABC"));
|
||||
assert!(!ch.is_user_allowed("abc"));
|
||||
assert!(!ch.is_user_allowed("Abc"));
|
||||
|
|
@ -651,14 +653,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn typing_handle_starts_as_none() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![], false);
|
||||
let guard = ch.typing_handle.lock().unwrap();
|
||||
assert!(guard.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_typing_sets_handle() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![], false);
|
||||
let _ = ch.start_typing("123456").await;
|
||||
let guard = ch.typing_handle.lock().unwrap();
|
||||
assert!(guard.is_some());
|
||||
|
|
@ -666,7 +668,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn stop_typing_clears_handle() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![], false);
|
||||
let _ = ch.start_typing("123456").await;
|
||||
let _ = ch.stop_typing("123456").await;
|
||||
let guard = ch.typing_handle.lock().unwrap();
|
||||
|
|
@ -675,14 +677,14 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn stop_typing_is_idempotent() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![], false);
|
||||
assert!(ch.stop_typing("123456").await.is_ok());
|
||||
assert!(ch.stop_typing("123456").await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_typing_replaces_existing_task() {
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![]);
|
||||
let ch = DiscordChannel::new("fake".into(), None, vec![], false);
|
||||
let _ = ch.start_typing("111").await;
|
||||
let _ = ch.start_typing("222").await;
|
||||
let guard = ch.typing_handle.lock().unwrap();
|
||||
|
|
|
|||
|
|
@ -544,6 +544,7 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
|
|||
dc.bot_token.clone(),
|
||||
dc.guild_id.clone(),
|
||||
dc.allowed_users.clone(),
|
||||
dc.listen_to_bots,
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
|
@ -671,7 +672,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
let model = config
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4".into());
|
||||
let temperature = config.default_temperature;
|
||||
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
|
||||
&config.memory,
|
||||
|
|
@ -752,6 +753,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
dc.bot_token.clone(),
|
||||
dc.guild_id.clone(),
|
||||
dc.allowed_users.clone(),
|
||||
dc.listen_to_bots,
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -774,6 +774,10 @@ pub struct DiscordConfig {
|
|||
pub guild_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_users: Vec<String>,
|
||||
/// When true, process messages from other bots (not just humans).
|
||||
/// The bot still ignores its own messages to prevent feedback loops.
|
||||
#[serde(default)]
|
||||
pub listen_to_bots: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -887,7 +891,7 @@ impl Default for Config {
|
|||
config_path: zeroclaw_dir.join("config.toml"),
|
||||
api_key: None,
|
||||
default_provider: Some("openrouter".to_string()),
|
||||
default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()),
|
||||
default_model: Some("anthropic/claude-sonnet-4".to_string()),
|
||||
default_temperature: 0.7,
|
||||
observability: ObservabilityConfig::default(),
|
||||
autonomy: AutonomyConfig::default(),
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
|||
let model = config
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4".into());
|
||||
let temperature = config.default_temperature;
|
||||
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
|
||||
&config.memory,
|
||||
|
|
|
|||
|
|
@ -406,7 +406,7 @@ fn default_model_for_provider(provider: &str) -> String {
|
|||
"groq" => "llama-3.3-70b-versatile".into(),
|
||||
"deepseek" => "deepseek-chat".into(),
|
||||
"gemini" | "google" | "google-gemini" => "gemini-2.0-flash".into(),
|
||||
_ => "anthropic/claude-sonnet-4-20250514".into(),
|
||||
_ => "anthropic/claude-sonnet-4".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -689,7 +689,7 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
let models: Vec<(&str, &str)> = match provider_name {
|
||||
"openrouter" => vec![
|
||||
(
|
||||
"anthropic/claude-sonnet-4-20250514",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"Claude Sonnet 4 (balanced, recommended)",
|
||||
),
|
||||
(
|
||||
|
|
@ -1378,6 +1378,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
bot_token: token,
|
||||
guild_id: if guild.is_empty() { None } else { Some(guild) },
|
||||
allowed_users,
|
||||
listen_to_bots: false,
|
||||
});
|
||||
}
|
||||
2 => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ pub struct Route {
|
|||
/// based on a task hint encoded in the model parameter.
|
||||
///
|
||||
/// The model parameter can be:
|
||||
/// - A regular model name (e.g. "anthropic/claude-sonnet-4-20250514") → uses default provider
|
||||
/// - A regular model name (e.g. "anthropic/claude-sonnet-4") → uses default provider
|
||||
/// - A hint-prefixed string (e.g. "hint:reasoning") → resolves via route table
|
||||
///
|
||||
/// This wraps multiple pre-created providers and selects the right one per request.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue