diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc80f4..310d1da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,17 @@ ### Highlights +- add Discord channel binding verification so misbound repo→channel routes are caught before config writes (#198) + - new `clawhip config verify-bindings [--json]` command audits every channel ID in the config against live Discord state and exits non-zero on drift + - new `clawhip setup --bind REPO=CHANNEL_ID [--expect-name REPO=NAME]` resolves the channel via Discord, writes a route with a `channel_name` hint, and refuses stale/mismatched bindings before saving + - new optional `channel_name` hint field on `[[routes]]`, `[defaults]`, `[[monitors.git.repos]]`, and `[[monitors.tmux.sessions]]` — advisory only, old configs continue to load unchanged - add `clawhip release preflight` to verify `Cargo.toml` / `Cargo.lock` / `CHANGELOG.md` stay consistent with the intended release tag before pushing (#189) - wire the preflight into `.github/workflows/release.yml` so an inconsistent release tag is rejected before `dist plan` and `publish-crates` run ### How to use +- drift audit: `clawhip config verify-bindings` (text) or `--json` for CI. Exit code is non-zero on any failed binding. +- bind a repo to a Discord channel safely: `clawhip setup --bind oh-my-codex=1480171106324189335 --expect-name oh-my-codex=omx-dev`. Clawhip resolves the channel live, prints `bind: oh-my-codex -> 1480171106324189335 (#omx-dev)`, and writes `[[routes]] filter = { repo = "oh-my-codex" }, channel = "…", channel_name = "omx-dev"`. Name mismatches and 404s abort before the write. - run `clawhip release preflight` locally in the repo root before tagging — omit the version to default to the current `Cargo.toml` version, or pass an explicit tag (`clawhip release preflight v0.6.5`, `clawhip release preflight refs/tags/v0.6.5`) - the same command runs in CI via the new `preflight` job gating the release workflow diff --git a/Cargo.lock b/Cargo.lock index 02160db..f663f2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,7 +215,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clawhip" -version = "0.6.4" +version = "0.6.5" dependencies = [ "anyhow", "async-trait", diff --git a/src/binding_verify.rs b/src/binding_verify.rs new file mode 100644 index 0000000..13857c9 --- /dev/null +++ b/src/binding_verify.rs @@ -0,0 +1,555 @@ +//! Binding verification: audit Discord channel bindings against live server state. +//! +//! Walks the config to collect every channel-ID reference, then queries the +//! Discord API to confirm each channel exists and (optionally) that the live +//! name matches the operator's `channel_name` hint. + +use std::fmt; + +use serde::Serialize; + +use crate::config::AppConfig; +use crate::discord::DiscordClient; + +// ── Channel lookup result ──────────────────────────────────────────── + +/// Result of resolving a single Discord channel ID against the live API. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ChannelLookup { + /// Channel exists — `name` is `None` for DM-style channels without a name. + Found { id: String, name: Option }, + /// The channel ID returned 404. + NotFound, + /// Bot lacks permission (403). + Forbidden, + /// Bot token is invalid (401). + Unauthorized, + /// No bot token configured — lookup skipped. + NoToken, + /// Network or API error. + Transport(String), +} + +// ── Binding extraction ─────────────────────────────────────────────── + +/// Where a channel reference was found in the config. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BindingSource { + DefaultChannel, + Route { index: usize }, + GitMonitor { index: usize }, + TmuxMonitor { index: usize }, +} + +impl fmt::Display for BindingSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DefaultChannel => write!(f, "defaults.channel"), + Self::Route { index } => write!(f, "routes[{}]", index + 1), + Self::GitMonitor { index } => write!(f, "monitors.git.repos[{}]", index + 1), + Self::TmuxMonitor { index } => write!(f, "monitors.tmux.sessions[{}]", index + 1), + } + } +} + +/// A channel reference extracted from the config, with an optional expected-name hint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ChannelBinding { + pub channel_id: String, + pub expected_name: Option, + pub source: BindingSource, + /// Freeform label for operator context (e.g. route event+filter, repo name). + pub label: String, +} + +/// Walk the config and collect every distinct channel-ID reference. +/// +/// De-duplicates by `(channel_id, source)` so the same ID referenced from +/// both the default and a route appears twice (one per source) but the same +/// ID referenced twice in the same context does not. +pub fn collect_bindings(config: &AppConfig) -> Vec { + let mut bindings = Vec::new(); + + // defaults.channel + if let Some(channel) = config.defaults.channel.as_deref() + && !channel.is_empty() + { + bindings.push(ChannelBinding { + channel_id: channel.to_string(), + expected_name: config.defaults.channel_name.clone(), + source: BindingSource::DefaultChannel, + label: "default channel".to_string(), + }); + } + + // routes + for (index, route) in config.routes.iter().enumerate() { + if let Some(channel) = route.channel.as_deref() + && !channel.is_empty() + { + let label = if route.filter.is_empty() { + format!("event={}", route.event) + } else { + let filters: Vec = route + .filter + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(); + format!("event={} filter={{{}}}", route.event, filters.join(", ")) + }; + bindings.push(ChannelBinding { + channel_id: channel.to_string(), + expected_name: route.channel_name.clone(), + source: BindingSource::Route { index }, + label, + }); + } + } + + // git monitors + for (index, repo) in config.monitors.git.repos.iter().enumerate() { + if let Some(channel) = repo.channel.as_deref() + && !channel.is_empty() + { + let label = repo + .name + .clone() + .unwrap_or_else(|| format!("git:{}", repo.path)); + bindings.push(ChannelBinding { + channel_id: channel.to_string(), + expected_name: repo.channel_name.clone(), + source: BindingSource::GitMonitor { index }, + label, + }); + } + } + + // tmux monitors + for (index, session) in config.monitors.tmux.sessions.iter().enumerate() { + if let Some(channel) = session.channel.as_deref() + && !channel.is_empty() + { + bindings.push(ChannelBinding { + channel_id: channel.to_string(), + expected_name: session.channel_name.clone(), + source: BindingSource::TmuxMonitor { index }, + label: format!("tmux:{}", session.session), + }); + } + } + + bindings +} + +// ── Verification ───────────────────────────────────────────────────── + +/// Verdict for a single binding after comparing the live API response against +/// the expected-name hint (if one was set). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "verdict", rename_all = "snake_case")] +pub enum VerdictKind { + /// channel_name hint matches the live name. + Match { live_name: String }, + /// channel_name hint does NOT match the live name. + Mismatch { + live_name: String, + expected_name: String, + }, + /// No channel_name hint was set — the channel resolved, here's the name. + Resolved { live_name: Option }, + /// Channel ID returned 404. + NotFound, + /// Bot lacks access (403). + Forbidden, + /// Bot token invalid (401). + Unauthorized, + /// No bot token configured. + NoToken, + /// Network or API failure. + Transport { message: String }, +} + +impl VerdictKind { + pub fn is_ok(&self) -> bool { + matches!(self, Self::Match { .. } | Self::Resolved { .. }) + } +} + +/// One binding + its resolved verdict. +#[derive(Debug, Clone, Serialize)] +pub struct BindingVerdict { + pub binding: ChannelBinding, + pub verdict: VerdictKind, +} + +/// Aggregate audit result for all bindings. +#[derive(Debug, Clone, Serialize)] +pub struct BindingAudit { + pub verdicts: Vec, +} + +impl BindingAudit { + pub fn all_ok(&self) -> bool { + self.verdicts.iter().all(|entry| entry.verdict.is_ok()) + } +} + +/// Resolve a lookup result into a verdict given the expected-name hint. +fn resolve_verdict(lookup: ChannelLookup, expected: &Option) -> VerdictKind { + match lookup { + ChannelLookup::Found { name, .. } => match expected { + Some(expected_name) => { + let live = name.as_deref().unwrap_or(""); + let expect = expected_name.trim().trim_start_matches('#'); + if live.eq_ignore_ascii_case(expect) { + VerdictKind::Match { + live_name: live.to_string(), + } + } else { + VerdictKind::Mismatch { + live_name: live.to_string(), + expected_name: expect.to_string(), + } + } + } + None => VerdictKind::Resolved { live_name: name }, + }, + ChannelLookup::NotFound => VerdictKind::NotFound, + ChannelLookup::Forbidden => VerdictKind::Forbidden, + ChannelLookup::Unauthorized => VerdictKind::Unauthorized, + ChannelLookup::NoToken => VerdictKind::NoToken, + ChannelLookup::Transport(message) => VerdictKind::Transport { message }, + } +} + +/// Verify all extracted bindings against the live Discord API. +pub async fn verify(client: &DiscordClient, config: &AppConfig) -> BindingAudit { + let bindings = collect_bindings(config); + let mut verdicts = Vec::with_capacity(bindings.len()); + + for binding in bindings { + let lookup = client.lookup_channel(&binding.channel_id).await; + let verdict = resolve_verdict(lookup, &binding.expected_name); + verdicts.push(BindingVerdict { binding, verdict }); + } + + BindingAudit { verdicts } +} + +// ── Display ────────────────────────────────────────────────────────── + +impl fmt::Display for BindingAudit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.verdicts.is_empty() { + writeln!(f, "No channel bindings found in config.")?; + return Ok(()); + } + + for entry in &self.verdicts { + let tag = if entry.verdict.is_ok() { "ok" } else { "FAIL" }; + write!( + f, + "[{tag:>4}] {} -> {} ", + entry.binding.source, entry.binding.channel_id + )?; + match &entry.verdict { + VerdictKind::Match { live_name } => { + writeln!(f, "(#{live_name}) -- matches hint")?; + } + VerdictKind::Mismatch { + live_name, + expected_name, + } => { + writeln!(f, "(#{live_name}) -- MISMATCH: expected #{expected_name}")?; + } + VerdictKind::Resolved { + live_name: Some(name), + } => { + writeln!(f, "(#{name})")?; + } + VerdictKind::Resolved { live_name: None } => { + writeln!(f, "(unnamed channel)")?; + } + VerdictKind::NotFound => { + writeln!(f, "-- NOT FOUND (deleted or wrong ID)")?; + } + VerdictKind::Forbidden => { + writeln!(f, "-- FORBIDDEN (bot lacks access)")?; + } + VerdictKind::Unauthorized => { + writeln!(f, "-- UNAUTHORIZED (invalid bot token)")?; + } + VerdictKind::NoToken => { + writeln!(f, "-- SKIPPED (no bot token configured)")?; + } + VerdictKind::Transport { message } => { + writeln!(f, "-- ERROR: {message}")?; + } + } + } + + let total = self.verdicts.len(); + let ok_count = self.verdicts.iter().filter(|e| e.verdict.is_ok()).count(); + let fail_count = total - ok_count; + writeln!(f)?; + if fail_count == 0 { + writeln!(f, "{total} binding(s) verified, all OK.")?; + } else { + writeln!( + f, + "{total} binding(s) checked: {ok_count} OK, {fail_count} failed." + )?; + } + + Ok(()) + } +} + +// ── Tests ──────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + use crate::config::{ + DefaultsConfig, GitMonitorConfig, GitRepoMonitor, MonitorConfig, RouteRule, + TmuxMonitorConfig, TmuxSessionMonitor, + }; + + fn config_with_routes(routes: Vec) -> AppConfig { + AppConfig { + routes, + ..AppConfig::default() + } + } + + #[test] + fn collects_default_channel_binding() { + let config = AppConfig { + defaults: DefaultsConfig { + channel: Some("111".into()), + channel_name: Some("alerts".into()), + ..DefaultsConfig::default() + }, + ..AppConfig::default() + }; + let bindings = collect_bindings(&config); + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].channel_id, "111"); + assert_eq!(bindings[0].expected_name.as_deref(), Some("alerts")); + assert_eq!(bindings[0].source, BindingSource::DefaultChannel); + } + + #[test] + fn collects_route_binding_with_filter() { + let mut filter = BTreeMap::new(); + filter.insert("repo".into(), "clawhip".into()); + let config = config_with_routes(vec![RouteRule { + event: "*".into(), + filter, + channel: Some("222".into()), + channel_name: Some("clawhip-dev".into()), + ..RouteRule::default() + }]); + let bindings = collect_bindings(&config); + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].channel_id, "222"); + assert_eq!(bindings[0].expected_name.as_deref(), Some("clawhip-dev")); + assert!(bindings[0].label.contains("repo=clawhip")); + } + + #[test] + fn collects_git_monitor_binding() { + let config = AppConfig { + monitors: MonitorConfig { + git: GitMonitorConfig { + repos: vec![GitRepoMonitor { + path: "/repo".into(), + name: Some("my-repo".into()), + channel: Some("333".into()), + channel_name: Some("my-repo-dev".into()), + ..GitRepoMonitor::default() + }], + }, + ..MonitorConfig::default() + }, + ..AppConfig::default() + }; + let bindings = collect_bindings(&config); + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].label, "my-repo"); + } + + #[test] + fn collects_tmux_monitor_binding() { + let config = AppConfig { + monitors: MonitorConfig { + tmux: TmuxMonitorConfig { + sessions: vec![TmuxSessionMonitor { + session: "issue-42".into(), + channel: Some("444".into()), + channel_name: Some("dev".into()), + ..TmuxSessionMonitor::default() + }], + }, + ..MonitorConfig::default() + }, + ..AppConfig::default() + }; + let bindings = collect_bindings(&config); + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].label, "tmux:issue-42"); + } + + #[test] + fn skips_empty_channel_fields() { + let config = config_with_routes(vec![RouteRule { + event: "*".into(), + channel: None, + ..RouteRule::default() + }]); + assert!(collect_bindings(&config).is_empty()); + } + + #[test] + fn verdict_match_when_hint_matches() { + let lookup = ChannelLookup::Found { + id: "1".into(), + name: Some("clawhip-dev".into()), + }; + let verdict = resolve_verdict(lookup, &Some("clawhip-dev".into())); + assert!(matches!(verdict, VerdictKind::Match { .. })); + } + + #[test] + fn verdict_match_case_insensitive() { + let lookup = ChannelLookup::Found { + id: "1".into(), + name: Some("Clawhip-Dev".into()), + }; + let verdict = resolve_verdict(lookup, &Some("clawhip-dev".into())); + assert!(matches!(verdict, VerdictKind::Match { .. })); + } + + #[test] + fn verdict_match_strips_hash_prefix() { + let lookup = ChannelLookup::Found { + id: "1".into(), + name: Some("omc-dev".into()), + }; + let verdict = resolve_verdict(lookup, &Some("#omc-dev".into())); + assert!(matches!(verdict, VerdictKind::Match { .. })); + } + + #[test] + fn verdict_mismatch_on_different_name() { + let lookup = ChannelLookup::Found { + id: "1".into(), + name: Some("omx-dev".into()), + }; + let verdict = resolve_verdict(lookup, &Some("omc-dev".into())); + assert!(matches!(verdict, VerdictKind::Mismatch { .. })); + } + + #[test] + fn verdict_resolved_without_hint() { + let lookup = ChannelLookup::Found { + id: "1".into(), + name: Some("omc-dev".into()), + }; + let verdict = resolve_verdict(lookup, &None); + assert!(matches!(verdict, VerdictKind::Resolved { .. })); + } + + #[test] + fn verdict_not_found() { + assert!(matches!( + resolve_verdict(ChannelLookup::NotFound, &None), + VerdictKind::NotFound + )); + } + + #[test] + fn verdict_no_token() { + assert!(matches!( + resolve_verdict(ChannelLookup::NoToken, &None), + VerdictKind::NoToken + )); + } + + #[test] + fn audit_display_shows_summary() { + let audit = BindingAudit { + verdicts: vec![ + BindingVerdict { + binding: ChannelBinding { + channel_id: "111".into(), + expected_name: Some("omc-dev".into()), + source: BindingSource::Route { index: 0 }, + label: "event=* filter={repo=omc}".into(), + }, + verdict: VerdictKind::Match { + live_name: "omc-dev".into(), + }, + }, + BindingVerdict { + binding: ChannelBinding { + channel_id: "222".into(), + expected_name: Some("omc-dev".into()), + source: BindingSource::Route { index: 1 }, + label: "event=* filter={repo=omx}".into(), + }, + verdict: VerdictKind::Mismatch { + live_name: "omx-dev".into(), + expected_name: "omc-dev".into(), + }, + }, + ], + }; + let text = audit.to_string(); + assert!(text.contains("[ ok]")); + assert!(text.contains("[FAIL]")); + assert!(text.contains("MISMATCH")); + assert!(text.contains("1 OK, 1 failed")); + } + + #[test] + fn audit_display_empty() { + let audit = BindingAudit { + verdicts: Vec::new(), + }; + assert!(audit.to_string().contains("No channel bindings")); + } + + #[test] + fn apply_repo_binding_creates_route() { + let mut config = AppConfig::default(); + config + .apply_repo_binding("clawhip", "123456", Some("clawhip-dev")) + .unwrap(); + assert_eq!(config.routes.len(), 1); + assert_eq!(config.routes[0].channel.as_deref(), Some("123456")); + assert_eq!( + config.routes[0].channel_name.as_deref(), + Some("clawhip-dev") + ); + assert_eq!(config.routes[0].filter.get("repo").unwrap(), "clawhip"); + } + + #[test] + fn apply_repo_binding_updates_existing() { + let mut config = AppConfig::default(); + config + .apply_repo_binding("clawhip", "111", Some("old-name")) + .unwrap(); + config + .apply_repo_binding("clawhip", "222", Some("new-name")) + .unwrap(); + assert_eq!(config.routes.len(), 1); + assert_eq!(config.routes[0].channel.as_deref(), Some("222")); + assert_eq!(config.routes[0].channel_name.as_deref(), Some("new-name")); + } +} diff --git a/src/cli.rs b/src/cli.rs index 8e4ea69..1d6d530 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -213,6 +213,29 @@ pub struct SetupArgs { /// Set the daemon base URL in [daemon]. #[arg(long = "daemon-base-url")] pub daemon_base_url: Option, + /// Bind a repo to a Discord channel ID. Format: `repo=channel_id`. + /// + /// Resolves the channel ID against the live Discord API, surfaces the + /// live channel name, writes the resulting route with a `channel_name` + /// hint for drift detection, and refuses if the channel can't be + /// resolved (missing, forbidden, or no bot token). Repeatable. + #[arg(long = "bind", value_name = "REPO=CHANNEL_ID")] + pub bind: Vec, + /// When combined with `--bind`, refuse unless the live channel name + /// matches the expected name. Format: `repo=expected_name`. Repeatable. + #[arg(long = "expect-name", value_name = "REPO=NAME")] + pub expect_name: Vec, + /// Verify all resulting channel bindings against live Discord state + /// before writing the config. Fails the command if any binding drifts. + #[arg(long = "verify-bindings", default_value_t = false)] + pub verify_bindings: bool, +} + +#[derive(Debug, Clone, Default, Args)] +pub struct VerifyBindingsArgs { + /// Emit machine-readable JSON instead of the human-readable text report. + #[arg(long, default_value_t = false)] + pub json: bool, } impl EmitArgs { @@ -670,6 +693,12 @@ pub enum ConfigCommand { Show, /// Print the active config file path. Path, + /// Verify all channel bindings in the config against live Discord server state. + /// + /// Walks routes, defaults, and monitors to collect every channel ID reference, + /// then queries the Discord API to confirm each channel exists and (optionally) + /// matches the `channel_name` hint set alongside the ID. + VerifyBindings(VerifyBindingsArgs), } #[cfg(test)] @@ -928,6 +957,52 @@ mod tests { assert!(matches!(command, TmuxCommands::List)); } + #[test] + fn parses_setup_bind_subcommand() { + let cli = Cli::parse_from([ + "clawhip", + "setup", + "--bind", + "clawhip=1480171113253175356", + "--bind", + "oh-my-codex=1480171106324189335", + "--expect-name", + "clawhip=clawhip-dev", + ]); + let Commands::Setup(args) = cli.command.expect("setup command") else { + panic!("expected Setup"); + }; + assert_eq!(args.bind.len(), 2); + assert_eq!(args.bind[0], "clawhip=1480171113253175356"); + assert_eq!(args.bind[1], "oh-my-codex=1480171106324189335"); + assert_eq!(args.expect_name.len(), 1); + assert_eq!(args.expect_name[0], "clawhip=clawhip-dev"); + } + + #[test] + fn parses_config_verify_bindings_subcommand() { + let cli = Cli::parse_from(["clawhip", "config", "verify-bindings", "--json"]); + let Some(Commands::Config { command }) = cli.command else { + panic!("expected Config"); + }; + let Some(ConfigCommand::VerifyBindings(args)) = command else { + panic!("expected VerifyBindings"); + }; + assert!(args.json); + } + + #[test] + fn parses_config_verify_bindings_text_default() { + let cli = Cli::parse_from(["clawhip", "config", "verify-bindings"]); + let Some(Commands::Config { + command: Some(ConfigCommand::VerifyBindings(args)), + }) = cli.command + else { + panic!("expected verify-bindings"); + }; + assert!(!args.json); + } + #[test] fn parses_setup_webhook_subcommand() { let cli = Cli::parse_from([ diff --git a/src/config.rs b/src/config.rs index e144743..8d48bcf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -115,6 +115,9 @@ impl DispatchConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DefaultsConfig { pub channel: Option, + /// Human-readable channel name hint for the default channel (binding verification). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel_name: Option, #[serde(default)] pub format: MessageFormat, } @@ -123,6 +126,7 @@ impl Default for DefaultsConfig { fn default() -> Self { Self { channel: None, + channel_name: None, format: MessageFormat::Compact, } } @@ -136,6 +140,11 @@ pub struct RouteRule { #[serde(default = "default_sink_name")] pub sink: String, pub channel: Option, + /// Human-readable Discord channel name hint for binding verification. + /// When set, `clawhip config verify-bindings` compares the live channel + /// name against this value to detect drift. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel_name: Option, pub webhook: Option, pub slack_webhook: Option, pub mention: Option, @@ -152,6 +161,7 @@ impl Default for RouteRule { filter: BTreeMap::new(), sink: default_sink_name(), channel: None, + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -253,6 +263,9 @@ pub struct GitRepoMonitor { #[serde(default)] pub emit_pr_status: bool, pub channel: Option, + /// Human-readable channel name hint for binding verification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel_name: Option, pub mention: Option, pub format: Option, } @@ -269,6 +282,7 @@ impl Default for GitRepoMonitor { emit_issue_opened: true, emit_pr_status: false, channel: None, + channel_name: None, mention: None, format: None, } @@ -285,6 +299,9 @@ pub struct TmuxSessionMonitor { #[serde(default = "default_stale_minutes")] pub stale_minutes: u64, pub channel: Option, + /// Human-readable channel name hint for binding verification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel_name: Option, pub mention: Option, pub format: Option, } @@ -297,6 +314,7 @@ impl Default for TmuxSessionMonitor { keyword_window_secs: default_keyword_window_secs(), stale_minutes: default_stale_minutes(), channel: None, + channel_name: None, mention: None, format: None, } @@ -796,6 +814,7 @@ impl AppConfig { filter: BTreeMap::new(), sink: default_sink_name(), channel: None, + channel_name: None, webhook: Some(webhook), slack_webhook: None, mention: None, @@ -816,6 +835,66 @@ impl AppConfig { } } + /// Scaffold or update a repo→channel route with a binding-verify hint. + /// + /// Creates a `[[routes]]` entry shaped as: + /// + /// ```toml + /// [[routes]] + /// event = "*" + /// filter = { repo = "" } + /// sink = "discord" + /// channel = "" + /// channel_name = "" # hint, used by verify-bindings + /// ``` + /// + /// If an existing route matches the exact `(event="*", filter={repo=...}, + /// sink="discord")` shape, its channel and channel_name are updated in place + /// instead of appending a duplicate. + pub fn apply_repo_binding( + &mut self, + repo: &str, + channel_id: &str, + channel_name: Option<&str>, + ) -> Result<()> { + let repo = normalize_text(Some(repo.to_string())) + .ok_or_else(|| "repo binding requires a non-empty repo name".to_string())?; + let channel_id = normalize_text(Some(channel_id.to_string())) + .ok_or_else(|| "repo binding requires a non-empty channel id".to_string())?; + let channel_name = channel_name.and_then(|value| normalize_text(Some(value.to_string()))); + + let existing = self + .routes + .iter_mut() + .find(|route| is_repo_binding_route(route, &repo)); + + match existing { + Some(route) => { + route.channel = Some(channel_id); + route.channel_name = channel_name; + route.webhook = None; + } + None => { + let mut filter = BTreeMap::new(); + filter.insert("repo".to_string(), repo); + self.routes.push(RouteRule { + event: "*".to_string(), + filter, + sink: default_sink_name(), + channel: Some(channel_id), + channel_name, + webhook: None, + slack_webhook: None, + mention: None, + allow_dynamic_tokens: false, + format: None, + template: None, + }); + } + } + Ok(()) + } + pub fn set_discord_bot_token(&mut self, bot_token: String) { self.providers.discord.bot_token = normalize_secret(Some(bot_token)); } @@ -951,6 +1030,7 @@ impl AppConfig { for route in &mut self.routes { route.sink = normalize_text(Some(route.sink.clone())).unwrap_or_else(default_sink_name); route.channel = normalize_text(route.channel.clone()); + route.channel_name = normalize_text(route.channel_name.clone()); route.webhook = normalize_text(route.webhook.clone()); route.slack_webhook = normalize_text(route.slack_webhook.clone()); route.mention = normalize_text(route.mention.clone()); @@ -959,6 +1039,7 @@ impl AppConfig { for repo in &mut self.monitors.git.repos { repo.channel = normalize_text(repo.channel.clone()); + repo.channel_name = normalize_text(repo.channel_name.clone()); repo.mention = normalize_text(repo.mention.clone()); repo.name = normalize_text(repo.name.clone()); repo.github_repo = normalize_text(repo.github_repo.clone()); @@ -966,6 +1047,7 @@ impl AppConfig { for session in &mut self.monitors.tmux.sessions { session.channel = normalize_text(session.channel.clone()); + session.channel_name = normalize_text(session.channel_name.clone()); session.mention = normalize_text(session.mention.clone()); } @@ -1013,6 +1095,18 @@ impl AppConfig { } } +fn is_repo_binding_route(route: &RouteRule, repo: &str) -> bool { + route.event == "*" + && route.sink.trim() == "discord" + && route.slack_webhook.is_none() + && route.filter.len() == 1 + && route + .filter + .get("repo") + .map(|value| value == repo) + .unwrap_or(false) +} + fn is_canonical_quickstart_route(route: &RouteRule) -> bool { route.event == "*" && route.filter.is_empty() @@ -1292,6 +1386,7 @@ mod tests { }, defaults: DefaultsConfig { channel: Some("general".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { diff --git a/src/cron.rs b/src/cron.rs index d32a35a..9d6c70e 100644 --- a/src/cron.rs +++ b/src/cron.rs @@ -987,6 +987,7 @@ mod tests { AppConfig { defaults: DefaultsConfig { channel: Some("ops".into()), + channel_name: None, format: MessageFormat::Compact, }, cron: CronConfig { diff --git a/src/discord.rs b/src/discord.rs index 589c0f2..e1e9c8b 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use serde_json::json; use crate::Result; +use crate::binding_verify::ChannelLookup; use crate::config::AppConfig; use crate::core::circuit_breaker::CircuitBreaker; use crate::core::dlq::{Dlq, DlqEntry}; @@ -47,6 +48,12 @@ struct DiscordRateLimitBody { retry_after: Option, } +#[derive(Debug, Deserialize)] +struct DiscordChannelBody { + #[serde(default)] + name: Option, +} + impl DiscordClient { pub fn from_config(config: Arc) -> Result { let bot_client = if let Some(token) = config.effective_token() { @@ -132,6 +139,62 @@ impl DiscordClient { Err(error.into()) } + /// Look up a Discord channel by ID using the bot API. + /// + /// Returns a typed `ChannelLookup` that surfaces the live channel name on + /// success or a specific failure mode (not-found, forbidden, unauthorized, + /// no-token, transport error). The DLQ and circuit breaker are deliberately + /// NOT touched — binding verification is a read-only operator probe, not a + /// dispatch event, and should never mark the delivery circuit as degraded. + pub async fn lookup_channel(&self, channel_id: &str) -> ChannelLookup { + let Some(client) = self.bot_client.as_ref() else { + return ChannelLookup::NoToken; + }; + + let url = format!( + "{}/channels/{}", + self.api_base.trim_end_matches('/'), + channel_id + ); + + let response = match client.get(url).send().await { + Ok(response) => response, + Err(error) => { + return ChannelLookup::Transport(format!( + "Discord channel lookup request failed: {error}" + )); + } + }; + + let status = response.status(); + if status.is_success() { + let body = match response.json::().await { + Ok(body) => body, + Err(error) => { + return ChannelLookup::Transport(format!( + "Discord channel lookup body parse failed: {error}" + )); + } + }; + return ChannelLookup::Found { + id: channel_id.to_string(), + name: body.name, + }; + } + + match status { + StatusCode::NOT_FOUND => ChannelLookup::NotFound, + StatusCode::FORBIDDEN => ChannelLookup::Forbidden, + StatusCode::UNAUTHORIZED => ChannelLookup::Unauthorized, + other => { + let body = response.text().await.unwrap_or_default(); + ChannelLookup::Transport(format!( + "Discord channel lookup failed with {other}: {body}" + )) + } + } + } + async fn send_message( &self, channel_id: &str, @@ -259,6 +322,34 @@ impl DiscordClient { state.dlq.push(entry); } + #[cfg(test)] + pub(crate) fn for_tests_with_api_base(bot_token: &str, api_base: String) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bot {bot_token}"))?, + ); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + let bot_client = Some( + reqwest::Client::builder() + .default_headers(headers) + .build()?, + ); + let webhook_client = reqwest::Client::new(); + + Ok(Self { + bot_client, + webhook_client, + api_base, + state: Arc::new(Mutex::new(DiscordState { + limiter: RateLimiter::new(RATE_LIMIT_CAPACITY, RATE_LIMIT_REFILL_PER_SEC), + circuits: HashMap::new(), + dlq: Dlq::default(), + })), + }) + } + #[cfg(test)] fn dlq_entries(&self) -> Vec { self.state @@ -378,6 +469,136 @@ mod tests { assert!(client.dlq_entries().is_empty()); } + /// Serve a single HTTP response on a bound TCP listener. + async fn serve_once( + listener: tokio::net::TcpListener, + status_line: &'static str, + body: &'static str, + ) { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buf = vec![0_u8; 4096]; + let _ = stream.read(&mut buf).await.unwrap(); + let response = format!( + "{status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len(), + ); + stream.write_all(response.as_bytes()).await.unwrap(); + stream.shutdown().await.ok(); + } + + #[tokio::test] + async fn lookup_channel_returns_found_with_name() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(serve_once( + listener, + "HTTP/1.1 200 OK", + r#"{"id":"1480171113253175356","name":"clawhip-dev","type":0}"#, + )); + + let client = + DiscordClient::for_tests_with_api_base("test-token", format!("http://{addr}")).unwrap(); + let lookup = client.lookup_channel("1480171113253175356").await; + server.await.unwrap(); + + match lookup { + ChannelLookup::Found { id, name } => { + assert_eq!(id, "1480171113253175356"); + assert_eq!(name.as_deref(), Some("clawhip-dev")); + } + other => panic!("expected Found, got {other:?}"), + } + } + + #[tokio::test] + async fn lookup_channel_returns_not_found() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(serve_once( + listener, + "HTTP/1.1 404 Not Found", + r#"{"message":"Unknown Channel","code":10003}"#, + )); + + let client = + DiscordClient::for_tests_with_api_base("test-token", format!("http://{addr}")).unwrap(); + let lookup = client.lookup_channel("9999999999999999").await; + server.await.unwrap(); + + assert!(matches!(lookup, ChannelLookup::NotFound)); + } + + #[tokio::test] + async fn lookup_channel_returns_forbidden() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(serve_once( + listener, + "HTTP/1.1 403 Forbidden", + r#"{"message":"Missing Access","code":50001}"#, + )); + + let client = + DiscordClient::for_tests_with_api_base("test-token", format!("http://{addr}")).unwrap(); + let lookup = client.lookup_channel("1111").await; + server.await.unwrap(); + + assert!(matches!(lookup, ChannelLookup::Forbidden)); + } + + #[tokio::test] + async fn lookup_channel_returns_unauthorized() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(serve_once( + listener, + "HTTP/1.1 401 Unauthorized", + r#"{"message":"401: Unauthorized","code":0}"#, + )); + + let client = + DiscordClient::for_tests_with_api_base("bad-token", format!("http://{addr}")).unwrap(); + let lookup = client.lookup_channel("1111").await; + server.await.unwrap(); + + assert!(matches!(lookup, ChannelLookup::Unauthorized)); + } + + #[tokio::test] + async fn lookup_channel_returns_no_token_when_missing() { + // Build a DiscordClient with no bot token (no env, no config). + // Use a bogus env override so we never hit the real API. + unsafe { + std::env::set_var("CLAWHIP_DISCORD_API_BASE", "http://127.0.0.1:1"); + } + let client = DiscordClient::from_config(Arc::new(AppConfig::default())).unwrap(); + unsafe { + std::env::remove_var("CLAWHIP_DISCORD_API_BASE"); + } + // Config has no bot token and no webhook route; lookup should skip. + let lookup = client.lookup_channel("1111").await; + assert!(matches!(lookup, ChannelLookup::NoToken)); + } + + #[tokio::test] + async fn lookup_channel_does_not_touch_dlq_on_failure() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(serve_once( + listener, + "HTTP/1.1 404 Not Found", + r#"{"message":"Unknown Channel"}"#, + )); + + let client = + DiscordClient::for_tests_with_api_base("test-token", format!("http://{addr}")).unwrap(); + let _ = client.lookup_channel("1").await; + server.await.unwrap(); + + // Lookup failures must NOT pollute the DLQ — it's a read-only probe. + assert!(client.dlq_entries().is_empty()); + } + #[tokio::test] async fn exhausted_failures_land_in_dlq() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src/dispatch.rs b/src/dispatch.rs index 2bd3758..fd3672f 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -836,6 +836,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: None, + channel_name: None, webhook: Some(failing_webhook), slack_webhook: None, mention: None, @@ -848,6 +849,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: None, + channel_name: None, webhook: Some(successful_webhook), slack_webhook: None, mention: None, diff --git a/src/main.rs b/src/main.rs index 2306569..5ffa660 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod binding_verify; mod cli; mod client; mod config; @@ -32,10 +33,11 @@ use clap::Parser; use crate::cli::{ AgentCommands, Cli, Commands, ConfigCommand, CronCommands, ExplainArgs, GitCommands, GithubCommands, HooksCommands, MemoryCommands, NativeCommands, PluginCommands, ReleaseCommands, - TmuxCommands, UpdateCommands, + SetupArgs, TmuxCommands, UpdateCommands, VerifyBindingsArgs, }; use crate::client::DaemonClient; use crate::config::{AppConfig, SetupEdits}; +use crate::discord::DiscordClient; use crate::event::compat::from_incoming_event; use crate::events::IncomingEvent; @@ -78,20 +80,7 @@ async fn real_main() -> Result<()> { send_incoming_event(&client, args.into_event()?).await } Commands::Explain(args) => run_explain(config.as_ref(), args), - Commands::Setup(args) => { - let mut editable = AppConfig::load_or_default(&config_path)?; - editable.apply_setup_edits(SetupEdits { - webhook: args.webhook, - bot_token: args.bot_token, - default_channel: args.default_channel, - default_format: args.default_format, - daemon_base_url: args.daemon_base_url, - })?; - editable.validate()?; - editable.save(&config_path)?; - println!("Saved {}", config_path.display()); - Ok(()) - } + Commands::Setup(args) => run_setup(args, &config_path).await, Commands::Send { channel, message } => { let client = DaemonClient::from_config(config.as_ref()); send_incoming_event(&client, IncomingEvent::custom(channel, message)).await @@ -305,6 +294,7 @@ async fn real_main() -> Result<()> { println!("{}", config_path.display()); Ok(()) } + ConfigCommand::VerifyBindings(args) => run_verify_bindings(config, args).await, }, Commands::Plugin { command } => match command { PluginCommands::List => { @@ -346,6 +336,157 @@ async fn send_incoming_event(client: &DaemonClient, event: IncomingEvent) -> Res client.send_event(&event).await } +/// Parse `--expect-name REPO=NAME` entries into a `repo -> name` map. +/// +/// **Hard-fails** on any malformed entry instead of silently skipping it, so +/// a typo like `--expect-name clawhip` (missing `=`) cannot bypass the +/// name-match guard during `setup --bind`. This is a correctness guarantee: +/// when the operator asks us to enforce a name, we must either enforce it or +/// refuse the command — never quietly drop the assertion. +/// +/// Rejects: +/// - entries without `=` (`"clawhip"`) +/// - empty repo (`"=dev"` or `" =dev"`) +/// - empty name (`"clawhip="` or `"clawhip= "`) +/// - duplicate repo keys (prevents ambiguous overrides) +fn parse_expect_name_overrides( + entries: &[String], +) -> Result> { + let mut map = std::collections::HashMap::new(); + for entry in entries { + let (repo, name) = entry + .split_once('=') + .ok_or_else(|| format!("--expect-name must be REPO=NAME, got '{entry}'"))?; + let repo = repo.trim(); + let name = name.trim(); + if repo.is_empty() { + return Err(format!("--expect-name '{entry}' has an empty repo name").into()); + } + if name.is_empty() { + return Err(format!("--expect-name '{entry}' has an empty channel name").into()); + } + if map.insert(repo.to_string(), name.to_string()).is_some() { + return Err(format!("--expect-name has duplicate entries for repo '{repo}'").into()); + } + } + Ok(map) +} + +async fn run_setup(args: SetupArgs, config_path: &std::path::Path) -> Result<()> { + let mut editable = AppConfig::load_or_default(config_path)?; + + let standard_edits = SetupEdits { + webhook: args.webhook, + bot_token: args.bot_token, + default_channel: args.default_channel, + default_format: args.default_format, + daemon_base_url: args.daemon_base_url, + }; + + // Must have at least one meaningful action. + if standard_edits.is_empty() && args.bind.is_empty() && !args.verify_bindings { + return Err("setup requires at least one non-empty setup flag".into()); + } + + // Apply standard setup edits first (only if any are set). + if !standard_edits.is_empty() { + editable.apply_setup_edits(standard_edits)?; + } + + // Process --bind entries: resolve each channel against Discord and write a + // repo binding route with a channel_name hint. + if !args.bind.is_empty() { + let client = DiscordClient::from_config(Arc::new(editable.clone()))?; + + // Collect expected-name overrides (repo -> name). Hard-fails on + // malformed input so a typo like `--expect-name clawhip` cannot + // silently bypass the name-match guard. + let expect_map = parse_expect_name_overrides(&args.expect_name)?; + + for entry in &args.bind { + let (repo, channel_id) = entry + .split_once('=') + .ok_or_else(|| format!("--bind must be REPO=CHANNEL_ID, got '{entry}'"))?; + let repo = repo.trim(); + let channel_id = channel_id.trim(); + + let lookup = client.lookup_channel(channel_id).await; + match &lookup { + binding_verify::ChannelLookup::Found { name, .. } => { + let live_name = name.as_deref().unwrap_or(""); + + // Check expected-name override. + if let Some(expected) = expect_map.get(repo) { + let expected_clean = expected.trim().trim_start_matches('#'); + if !live_name.eq_ignore_ascii_case(expected_clean) { + return Err(format!( + "bind {repo}: channel {channel_id} live name is #{live_name} but --expect-name requires #{expected_clean}" + ).into()); + } + } + + println!("bind: {repo} -> {channel_id} (#{live_name})"); + editable.apply_repo_binding(repo, channel_id, name.as_deref())?; + } + binding_verify::ChannelLookup::NotFound => { + return Err( + format!("bind {repo}: channel {channel_id} not found on Discord").into(), + ); + } + binding_verify::ChannelLookup::Forbidden => { + return Err(format!( + "bind {repo}: bot cannot access channel {channel_id} (403 Forbidden)" + ) + .into()); + } + binding_verify::ChannelLookup::Unauthorized => { + return Err("bind: Discord bot token is invalid (401 Unauthorized)".into()); + } + binding_verify::ChannelLookup::NoToken => { + return Err( + "bind: --bind requires a Discord bot token; configure [providers.discord].token first".into() + ); + } + binding_verify::ChannelLookup::Transport(msg) => { + return Err(format!("bind {repo}: {msg}").into()); + } + } + } + } + + editable.validate()?; + + // Optional full binding audit before saving. + if args.verify_bindings { + let client = DiscordClient::from_config(Arc::new(editable.clone()))?; + let audit = binding_verify::verify(&client, &editable).await; + print!("{audit}"); + if !audit.all_ok() { + return Err("setup aborted: binding verification failed (see above)".into()); + } + } + + editable.save(config_path)?; + println!("Saved {}", config_path.display()); + Ok(()) +} + +async fn run_verify_bindings(config: Arc, args: VerifyBindingsArgs) -> Result<()> { + let client = DiscordClient::from_config(config.clone())?; + let audit = binding_verify::verify(&client, &config).await; + + if args.json { + println!("{}", serde_json::to_string_pretty(&audit)?); + } else { + print!("{audit}"); + } + + if !audit.all_ok() { + std::process::exit(1); + } + Ok(()) +} + fn run_explain(config: &AppConfig, args: ExplainArgs) -> Result<()> { let json_output = args.json; // Only normalize the event (for canonical_kind / template_context), skip @@ -409,10 +550,93 @@ fn format_tmux_list(registrations: &[crate::source::RegisteredTmuxSession]) -> S #[cfg(test)] mod tests { - use super::format_tmux_list; + use super::{format_tmux_list, parse_expect_name_overrides}; use crate::events::RoutingMetadata; use crate::source::tmux::{ParentProcessInfo, RegisteredTmuxSession, RegistrationSource}; + #[test] + fn parse_expect_name_overrides_accepts_well_formed_entries() { + let entries = vec![ + "clawhip=clawhip-dev".to_string(), + "oh-my-codex=omx-dev".to_string(), + ]; + let map = parse_expect_name_overrides(&entries).expect("valid entries"); + assert_eq!(map.get("clawhip").map(String::as_str), Some("clawhip-dev")); + assert_eq!(map.get("oh-my-codex").map(String::as_str), Some("omx-dev")); + } + + #[test] + fn parse_expect_name_overrides_trims_whitespace() { + let entries = vec![" clawhip = clawhip-dev ".to_string()]; + let map = parse_expect_name_overrides(&entries).expect("trimmed entries"); + assert_eq!(map.get("clawhip").map(String::as_str), Some("clawhip-dev")); + } + + #[test] + fn parse_expect_name_overrides_rejects_missing_equals() { + // Regression for #198 review: previously filter_map silently dropped + // malformed entries, so `--expect-name clawhip` bypassed the guard. + let entries = vec!["clawhip".to_string()]; + let error = parse_expect_name_overrides(&entries).expect_err("missing = must hard-fail"); + let msg = error.to_string(); + assert!( + msg.contains("--expect-name must be REPO=NAME"), + "unexpected error: {msg}" + ); + assert!(msg.contains("'clawhip'"), "error should quote entry: {msg}"); + } + + #[test] + fn parse_expect_name_overrides_rejects_empty_repo() { + let entries = vec!["=clawhip-dev".to_string()]; + let error = parse_expect_name_overrides(&entries).expect_err("empty repo must hard-fail"); + assert!(error.to_string().contains("empty repo name")); + } + + #[test] + fn parse_expect_name_overrides_rejects_whitespace_only_repo() { + let entries = vec![" =clawhip-dev".to_string()]; + let error = + parse_expect_name_overrides(&entries).expect_err("whitespace repo must hard-fail"); + assert!(error.to_string().contains("empty repo name")); + } + + #[test] + fn parse_expect_name_overrides_rejects_empty_name() { + let entries = vec!["clawhip=".to_string()]; + let error = parse_expect_name_overrides(&entries).expect_err("empty name must hard-fail"); + assert!(error.to_string().contains("empty channel name")); + } + + #[test] + fn parse_expect_name_overrides_rejects_whitespace_only_name() { + let entries = vec!["clawhip= ".to_string()]; + let error = + parse_expect_name_overrides(&entries).expect_err("whitespace name must hard-fail"); + assert!(error.to_string().contains("empty channel name")); + } + + #[test] + fn parse_expect_name_overrides_rejects_duplicate_repo() { + let entries = vec![ + "clawhip=clawhip-dev".to_string(), + "clawhip=omc-dev".to_string(), + ]; + let error = + parse_expect_name_overrides(&entries).expect_err("duplicate repo must hard-fail"); + assert!( + error + .to_string() + .contains("duplicate entries for repo 'clawhip'") + ); + } + + #[test] + fn parse_expect_name_overrides_accepts_empty_input() { + let map = parse_expect_name_overrides(&[]).expect("empty input is fine"); + assert!(map.is_empty()); + } + #[test] fn format_tmux_list_renders_metadata_columns() { let output = format_tmux_list(&[RegisteredTmuxSession { diff --git a/src/router.rs b/src/router.rs index 81954dc..24f544a 100644 --- a/src/router.rs +++ b/src/router.rs @@ -564,6 +564,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![ @@ -572,6 +573,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: Some("ops".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: Some("@ops".into()), @@ -584,6 +586,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: Some("eng".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: Some("@eng".into()), @@ -629,6 +632,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("fallback".into()), + channel_name: None, format: MessageFormat::Alert, }, routes: vec![RouteRule { @@ -636,6 +640,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: Some("github".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -697,6 +702,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: None, + channel_name: None, webhook: Some(failing_webhook), slack_webhook: None, mention: None, @@ -709,6 +715,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: None, + channel_name: None, webhook: Some(successful_webhook), slack_webhook: None, mention: None, @@ -743,6 +750,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -752,6 +760,7 @@ mod tests { .into_iter() .collect(), channel: Some("route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -779,6 +788,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -788,6 +798,7 @@ mod tests { .into_iter() .collect(), channel: Some("worktrees".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -824,6 +835,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -831,6 +843,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: Some("route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: Some("<@1465264645320474637>".into()), @@ -852,6 +865,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![ @@ -862,6 +876,7 @@ mod tests { .into_iter() .collect(), channel: Some("gh-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: Some("<@botid>".into()), @@ -876,6 +891,7 @@ mod tests { .into_iter() .collect(), channel: Some("tmux-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: Some("<@botid>".into()), @@ -906,6 +922,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -913,6 +930,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: Some("dynamic-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -933,6 +951,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -940,6 +959,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: Some("dynamic-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -960,6 +980,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -969,6 +990,7 @@ mod tests { .into_iter() .collect(), channel: Some("tmux-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -995,6 +1017,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1002,6 +1025,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: Some("tmux-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: Some("<@route>".into()), @@ -1026,6 +1050,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1035,6 +1060,7 @@ mod tests { .into_iter() .collect(), channel: Some("route-channel".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: Some("<@route>".into()), @@ -1063,6 +1089,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1072,6 +1099,7 @@ mod tests { .into_iter() .collect(), channel: Some("route-channel".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: Some("<@route>".into()), @@ -1108,6 +1136,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1117,6 +1146,7 @@ mod tests { .into_iter() .collect(), channel: Some("agent-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1167,6 +1197,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1179,6 +1210,7 @@ mod tests { .into_iter() .collect(), channel: Some("session-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1212,6 +1244,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1224,6 +1257,7 @@ mod tests { .into_iter() .collect(), channel: Some("session-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1269,6 +1303,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1281,6 +1316,7 @@ mod tests { .into_iter() .collect(), channel: Some("agent-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1318,6 +1354,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![ @@ -1328,6 +1365,7 @@ mod tests { .into_iter() .collect(), channel: Some("repo-a".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1342,6 +1380,7 @@ mod tests { .into_iter() .collect(), channel: Some("repo-b".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1363,6 +1402,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1372,6 +1412,7 @@ mod tests { .into_iter() .collect(), channel: Some("repo-name-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1399,6 +1440,7 @@ mod tests { let tmux_config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1408,6 +1450,7 @@ mod tests { .into_iter() .collect(), channel: Some("tmux-session-name".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1426,6 +1469,7 @@ mod tests { let session_config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1435,6 +1479,7 @@ mod tests { .into_iter() .collect(), channel: Some("session-alias-route".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1466,6 +1511,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![ @@ -1516,6 +1562,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![ @@ -1566,6 +1613,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1573,6 +1621,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: None, + channel_name: None, webhook: Some("https://discord.com/api/webhooks/123/abc".into()), slack_webhook: None, mention: None, @@ -1605,6 +1654,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1612,6 +1662,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: None, + channel_name: None, webhook: Some("https://discord.com/api/webhooks/123/abc".into()), slack_webhook: None, mention: None, @@ -1641,6 +1692,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1648,6 +1700,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: Some("route-channel".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1677,6 +1730,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1684,6 +1738,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: None, + channel_name: None, webhook: None, slack_webhook: None, mention: Some("<@route>".into()), @@ -1713,6 +1768,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default-ch".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1720,6 +1776,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: Some("route-ch".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1746,6 +1803,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default-ch".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1753,6 +1811,7 @@ mod tests { sink: "discord".into(), filter: Default::default(), channel: Some("route-ch".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1779,6 +1838,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1837,6 +1897,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1844,6 +1905,7 @@ mod tests { filter: BTreeMap::from([("session".into(), "xeroclaw-*".into())]), sink: "discord".into(), channel: Some("xeroclaw-dev".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1865,6 +1927,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1872,6 +1935,7 @@ mod tests { filter: BTreeMap::from([("session_name".into(), "xeroclaw-*".into())]), sink: "discord".into(), channel: Some("xeroclaw-dev".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -1893,6 +1957,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![ @@ -1935,6 +2000,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1968,6 +2034,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -1975,6 +2042,7 @@ mod tests { filter: BTreeMap::from([("session".into(), "xeroclaw-*".into())]), sink: "discord".into(), channel: None, + channel_name: None, webhook: Some("https://discord.com/api/webhooks/123/abc".into()), slack_webhook: None, mention: None, @@ -2042,6 +2110,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -2092,6 +2161,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -2133,6 +2203,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("fallback".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -2160,6 +2231,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![ @@ -2201,6 +2273,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: None, + channel_name: None, format: MessageFormat::Compact, }, routes: vec![], @@ -2220,6 +2293,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("general".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { diff --git a/src/source/github.rs b/src/source/github.rs index 8a6e576..439a2b4 100644 --- a/src/source/github.rs +++ b/src/source/github.rs @@ -861,6 +861,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("fallback".into()), + channel_name: None, format: MessageFormat::Compact, }, routes: vec![RouteRule { @@ -870,6 +871,7 @@ mod tests { .into_iter() .collect(), channel: Some("route-channel".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: Some("<@1465264645320474637>".into()), diff --git a/src/source/tmux.rs b/src/source/tmux.rs index 84b8521..f786dd9 100644 --- a/src/source/tmux.rs +++ b/src/source/tmux.rs @@ -983,6 +983,7 @@ PR created #7", let monitor = TmuxSessionMonitor { session: "issue-*".into(), channel: Some("alerts".into()), + channel_name: None, mention: None, keywords: vec!["panic".into()], keyword_window_secs: 30, diff --git a/src/tmux_wrapper.rs b/src/tmux_wrapper.rs index baa4f56..f87a547 100644 --- a/src/tmux_wrapper.rs +++ b/src/tmux_wrapper.rs @@ -730,6 +730,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: crate::events::MessageFormat::Compact, }, routes: vec![RouteRule { @@ -737,6 +738,7 @@ mod tests { filter: BTreeMap::from([("session".into(), "xeroclaw-*".into())]), sink: "discord".into(), channel: Some("xeroclaw-dev".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None, @@ -781,6 +783,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: crate::events::MessageFormat::Compact, }, routes: vec![ @@ -834,6 +837,7 @@ mod tests { let config = AppConfig { defaults: DefaultsConfig { channel: Some("default".into()), + channel_name: None, format: crate::events::MessageFormat::Compact, }, routes: vec![RouteRule { @@ -841,6 +845,7 @@ mod tests { filter: BTreeMap::from([("session".into(), "xeroclaw-*".into())]), sink: "discord".into(), channel: Some("xeroclaw-dev".into()), + channel_name: None, webhook: None, slack_webhook: None, mention: None,