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:
Vernon Stinebaker 2026-02-16 15:13:36 +08:00 committed by GitHub
parent d5e8fc1652
commit 40c41cf3d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 34 additions and 25 deletions

View file

@ -94,9 +94,9 @@ COPY --from=permissions /zeroclaw-data /zeroclaw-data
# Environment setup # Environment setup
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
ENV HOME=/zeroclaw-data 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 PROVIDER="openrouter"
ENV ZEROCLAW_MODEL="anthropic/claude-sonnet-4-20250514"
ENV ZEROCLAW_GATEWAY_PORT=3000 ENV ZEROCLAW_GATEWAY_PORT=3000
# API_KEY must be provided at runtime! # API_KEY must be provided at runtime!

View file

@ -489,7 +489,7 @@ pub async fn run(
let model_name = model_override let model_name = model_override
.as_deref() .as_deref()
.or(config.default_model.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( let provider: Box<dyn Provider> = providers::create_routed_provider(
provider_name, provider_name,

View file

@ -10,16 +10,18 @@ pub struct DiscordChannel {
bot_token: String, bot_token: String,
guild_id: Option<String>, guild_id: Option<String>,
allowed_users: Vec<String>, allowed_users: Vec<String>,
listen_to_bots: bool,
client: reqwest::Client, client: reqwest::Client,
typing_handle: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>, typing_handle: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
} }
impl DiscordChannel { 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 { Self {
bot_token, bot_token,
guild_id, guild_id,
allowed_users, allowed_users,
listen_to_bots,
client: reqwest::Client::new(), client: reqwest::Client::new(),
typing_handle: std::sync::Mutex::new(None), typing_handle: std::sync::Mutex::new(None),
} }
@ -309,8 +311,8 @@ impl Channel for DiscordChannel {
continue; continue;
} }
// Skip bot messages // Skip bot messages (unless listen_to_bots is enabled)
if d.get("author").and_then(|a| a.get("bot")).and_then(serde_json::Value::as_bool).unwrap_or(false) { 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; continue;
} }
@ -411,7 +413,7 @@ mod tests {
#[test] #[test]
fn discord_channel_name() { 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"); assert_eq!(ch.name(), "discord");
} }
@ -432,21 +434,21 @@ mod tests {
#[test] #[test]
fn empty_allowlist_denies_everyone() { 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("12345"));
assert!(!ch.is_user_allowed("anyone")); assert!(!ch.is_user_allowed("anyone"));
} }
#[test] #[test]
fn wildcard_allows_everyone() { 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("12345"));
assert!(ch.is_user_allowed("anyone")); assert!(ch.is_user_allowed("anyone"));
} }
#[test] #[test]
fn specific_allowlist_filters() { 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("111"));
assert!(ch.is_user_allowed("222")); assert!(ch.is_user_allowed("222"));
assert!(!ch.is_user_allowed("333")); assert!(!ch.is_user_allowed("333"));
@ -455,7 +457,7 @@ mod tests {
#[test] #[test]
fn allowlist_is_exact_match_not_substring() { 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("1111"));
assert!(!ch.is_user_allowed("11")); assert!(!ch.is_user_allowed("11"));
assert!(!ch.is_user_allowed("0111")); assert!(!ch.is_user_allowed("0111"));
@ -463,20 +465,20 @@ mod tests {
#[test] #[test]
fn allowlist_empty_string_user_id() { 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("")); assert!(!ch.is_user_allowed(""));
} }
#[test] #[test]
fn allowlist_with_wildcard_and_specific() { 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("111"));
assert!(ch.is_user_allowed("anyone_else")); assert!(ch.is_user_allowed("anyone_else"));
} }
#[test] #[test]
fn allowlist_case_sensitive() { 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")); 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] #[test]
fn typing_handle_starts_as_none() { 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(); let guard = ch.typing_handle.lock().unwrap();
assert!(guard.is_none()); assert!(guard.is_none());
} }
#[tokio::test] #[tokio::test]
async fn start_typing_sets_handle() { 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 _ = ch.start_typing("123456").await;
let guard = ch.typing_handle.lock().unwrap(); let guard = ch.typing_handle.lock().unwrap();
assert!(guard.is_some()); assert!(guard.is_some());
@ -666,7 +668,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn stop_typing_clears_handle() { 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.start_typing("123456").await;
let _ = ch.stop_typing("123456").await; let _ = ch.stop_typing("123456").await;
let guard = ch.typing_handle.lock().unwrap(); let guard = ch.typing_handle.lock().unwrap();
@ -675,14 +677,14 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn stop_typing_is_idempotent() { 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());
assert!(ch.stop_typing("123456").await.is_ok()); assert!(ch.stop_typing("123456").await.is_ok());
} }
#[tokio::test] #[tokio::test]
async fn start_typing_replaces_existing_task() { 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("111").await;
let _ = ch.start_typing("222").await; let _ = ch.start_typing("222").await;
let guard = ch.typing_handle.lock().unwrap(); let guard = ch.typing_handle.lock().unwrap();

View file

@ -544,6 +544,7 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
dc.bot_token.clone(), dc.bot_token.clone(),
dc.guild_id.clone(), dc.guild_id.clone(),
dc.allowed_users.clone(), dc.allowed_users.clone(),
dc.listen_to_bots,
)), )),
)); ));
} }
@ -671,7 +672,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
let model = config let model = config
.default_model .default_model
.clone() .clone()
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); .unwrap_or_else(|| "anthropic/claude-sonnet-4".into());
let temperature = config.default_temperature; let temperature = config.default_temperature;
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory( let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
&config.memory, &config.memory,
@ -752,6 +753,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
dc.bot_token.clone(), dc.bot_token.clone(),
dc.guild_id.clone(), dc.guild_id.clone(),
dc.allowed_users.clone(), dc.allowed_users.clone(),
dc.listen_to_bots,
))); )));
} }

View file

@ -774,6 +774,10 @@ pub struct DiscordConfig {
pub guild_id: Option<String>, pub guild_id: Option<String>,
#[serde(default)] #[serde(default)]
pub allowed_users: Vec<String>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -887,7 +891,7 @@ impl Default for Config {
config_path: zeroclaw_dir.join("config.toml"), config_path: zeroclaw_dir.join("config.toml"),
api_key: None, api_key: None,
default_provider: Some("openrouter".to_string()), 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, default_temperature: 0.7,
observability: ObservabilityConfig::default(), observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(), autonomy: AutonomyConfig::default(),

View file

@ -198,7 +198,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
let model = config let model = config
.default_model .default_model
.clone() .clone()
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); .unwrap_or_else(|| "anthropic/claude-sonnet-4".into());
let temperature = config.default_temperature; let temperature = config.default_temperature;
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory( let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
&config.memory, &config.memory,

View file

@ -406,7 +406,7 @@ fn default_model_for_provider(provider: &str) -> String {
"groq" => "llama-3.3-70b-versatile".into(), "groq" => "llama-3.3-70b-versatile".into(),
"deepseek" => "deepseek-chat".into(), "deepseek" => "deepseek-chat".into(),
"gemini" | "google" | "google-gemini" => "gemini-2.0-flash".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 { let models: Vec<(&str, &str)> = match provider_name {
"openrouter" => vec![ "openrouter" => vec![
( (
"anthropic/claude-sonnet-4-20250514", "anthropic/claude-sonnet-4",
"Claude Sonnet 4 (balanced, recommended)", "Claude Sonnet 4 (balanced, recommended)",
), ),
( (
@ -1378,6 +1378,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
bot_token: token, bot_token: token,
guild_id: if guild.is_empty() { None } else { Some(guild) }, guild_id: if guild.is_empty() { None } else { Some(guild) },
allowed_users, allowed_users,
listen_to_bots: false,
}); });
} }
2 => { 2 => {

View file

@ -14,7 +14,7 @@ pub struct Route {
/// based on a task hint encoded in the model parameter. /// based on a task hint encoded in the model parameter.
/// ///
/// The model parameter can be: /// 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 /// - 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. /// This wraps multiple pre-created providers and selects the right one per request.