Skip to main content

chipzen_bot/
connect.rs

1//! Environment-aware connection helper for the external-API lobby.
2//!
3//! Devs running an external-API bot against Chipzen shouldn't need to
4//! remember the exact lobby WebSocket URL per environment.
5//! [`connect_to_chipzen`] returns a fully-populated [`ConnectionConfig`]
6//! with the resolved `url`, `token`, and `retry_policy`, ready to hand off
7//! to [`crate::run_external_bot`].
8//!
9//! # Environment → URL mapping
10//!
11//! ```text
12//! prod    -> wss://chipzen.ai/ws/external/bot/{bot_id}
13//! staging -> wss://staging.chipzen.ai/ws/external/bot/{bot_id}
14//! local   -> ws://localhost:8001/ws/external/bot/{bot_id}
15//! ```
16//!
17//! # Precedence
18//!
19//! Resolution order for the final WebSocket URL, highest priority first:
20//!
21//! 1. `[external_api].url` from a discovered `chipzen.toml`. A config-file
22//!    URL ALWAYS wins.
23//! 2. An **explicitly passed** `env` argument (`Some(...)`).
24//! 3. The `CHIPZEN_ENV` environment variable, if set to a recognized value.
25//! 4. The default of `prod`.
26//!
27//! The explicit `url=` override valve lives one layer up, on
28//! [`crate::run_external_bot`].
29
30use crate::config::{load_chipzen_config, resolve_token, resolve_url, ChipzenConfig};
31use crate::error::Error;
32use crate::retry::RetryPolicy;
33
34/// Recognized environment names. Kept as a constant so error messages can
35/// list the valid values and the env-var path can validate at runtime.
36pub const ENV_NAMES: &[&str] = &["prod", "staging", "local"];
37
38/// Name of the environment variable consulted when `env` is not explicitly
39/// passed.
40pub const CHIPZEN_ENV_VAR: &str = "CHIPZEN_ENV";
41
42/// One of the three recognized Chipzen environments.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum EnvName {
45    Prod,
46    Staging,
47    Local,
48}
49
50impl EnvName {
51    /// Parse a recognized env name. Returns `None` for anything else so the
52    /// caller can build a clear error listing the legal values.
53    pub fn parse(s: &str) -> Option<Self> {
54        match s {
55            "prod" => Some(EnvName::Prod),
56            "staging" => Some(EnvName::Staging),
57            "local" => Some(EnvName::Local),
58            _ => None,
59        }
60    }
61
62    pub fn as_str(&self) -> &'static str {
63        match self {
64            EnvName::Prod => "prod",
65            EnvName::Staging => "staging",
66            EnvName::Local => "local",
67        }
68    }
69
70    /// Format the canonical lobby URL for this env + bot id.
71    fn lobby_url(&self, bot_id: &str) -> String {
72        match self {
73            EnvName::Prod => format!("wss://chipzen.ai/ws/external/bot/{bot_id}"),
74            EnvName::Staging => format!("wss://staging.chipzen.ai/ws/external/bot/{bot_id}"),
75            EnvName::Local => format!("ws://localhost:8001/ws/external/bot/{bot_id}"),
76        }
77    }
78}
79
80/// Fully-resolved connection parameters ready for [`crate::run_external_bot`].
81#[derive(Debug, Clone)]
82pub struct ConnectionConfig {
83    /// WebSocket URL the bot should connect to. Either env-derived or the
84    /// verbatim `[external_api].url` from a discovered `chipzen.toml`.
85    pub url: String,
86    /// Long-lived API token from a discovered `chipzen.toml`, or `None`.
87    /// `None` doesn't mean "auth-less"; just that the helper didn't find
88    /// one — the caller may pass an explicit token separately.
89    pub token: Option<String>,
90    /// Reconnect-pacing policy.
91    pub retry_policy: RetryPolicy,
92    /// The resolved environment name, or `None` if the URL came verbatim
93    /// from a config file (no env mapping applied). Mostly for logs/debug.
94    pub env: Option<EnvName>,
95    /// The [`ChipzenConfig`] discovered during resolution (if any). Exposed
96    /// so callers can pass it through and avoid a second filesystem stat.
97    pub config: Option<ChipzenConfig>,
98}
99
100/// Pick the env name to use: explicit arg, then `$CHIPZEN_ENV`, then `prod`.
101///
102/// Returns [`Error::Protocol`] if an explicit `env` or the env-var value
103/// isn't a recognized name, so a typo (`prd`) surfaces immediately.
104fn resolve_env_name(explicit: Option<EnvName>, env_var: Option<&str>) -> Result<EnvName, Error> {
105    if let Some(env) = explicit {
106        return Ok(env);
107    }
108    // Empty string is treated as "not set" so an accidental `CHIPZEN_ENV=`
109    // falls through to the default rather than tripping the unknown-env error.
110    if let Some(val) = env_var.filter(|s| !s.is_empty()) {
111        return EnvName::parse(val).ok_or_else(|| {
112            Error::Protocol(format!(
113                "{CHIPZEN_ENV_VAR}={val:?} is not a recognized environment. Valid values: {}.",
114                ENV_NAMES.join(", ")
115            ))
116        });
117    }
118    Ok(EnvName::Prod)
119}
120
121/// Resolve a [`ConnectionConfig`] for the external-API lobby.
122///
123/// Maps `env` to a canonical lobby URL and combines it with whatever
124/// config-file token / URL / retry policy the dev has set up.
125///
126/// # Arguments
127///
128/// * `bot_id` — external-API bot UUID issued by the platform. Required +
129///   non-empty.
130/// * `env` — `None` consults `$CHIPZEN_ENV` then defaults to `prod`.
131/// * `retry_policy` — `None` uses [`RetryPolicy::default`].
132/// * `config` — `Some` avoids a second filesystem stat; `None` triggers
133///   discovery.
134///
135/// # Errors
136///
137/// Returns [`Error::Protocol`] if `bot_id` is empty, `env`/`$CHIPZEN_ENV`
138/// is unrecognized, or a discovered `chipzen.toml` is malformed.
139pub fn connect_to_chipzen(
140    bot_id: &str,
141    env: Option<EnvName>,
142    retry_policy: Option<RetryPolicy>,
143    config: Option<ChipzenConfig>,
144) -> Result<ConnectionConfig, Error> {
145    if bot_id.is_empty() {
146        return Err(Error::Protocol(
147            "connect_to_chipzen() requires a non-empty bot_id. Pass the external-API \
148             bot UUID issued by the Chipzen platform."
149                .to_string(),
150        ));
151    }
152
153    // Resolve the env name first so an explicit `env`/`$CHIPZEN_ENV` typo is
154    // validated even when a config-file URL ends up overriding the derived URL.
155    let env_var = std::env::var(CHIPZEN_ENV_VAR).ok();
156    let resolved_env = resolve_env_name(env, env_var.as_deref())?;
157    let env_derived_url = resolved_env.lobby_url(bot_id);
158
159    // Discover the config exactly once and thread it through.
160    let config = match config {
161        Some(c) => Some(c),
162        None => load_chipzen_config(None)?,
163    };
164
165    let (url, env_for_return) = match resolve_url(None, config.as_ref()) {
166        Some(config_url) => (config_url, None),
167        None => (env_derived_url, Some(resolved_env)),
168    };
169
170    let token = resolve_token(None, config.as_ref());
171    let policy = retry_policy.unwrap_or_default();
172
173    Ok(ConnectionConfig {
174        url,
175        token,
176        retry_policy: policy,
177        env: env_for_return,
178        config,
179    })
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn env_name_parse_round_trips() {
188        for name in ENV_NAMES {
189            assert_eq!(EnvName::parse(name).unwrap().as_str(), *name);
190        }
191        assert!(EnvName::parse("prd").is_none());
192    }
193
194    #[test]
195    fn lobby_url_templates_match_python() {
196        assert_eq!(
197            EnvName::Prod.lobby_url("b"),
198            "wss://chipzen.ai/ws/external/bot/b"
199        );
200        assert_eq!(
201            EnvName::Staging.lobby_url("b"),
202            "wss://staging.chipzen.ai/ws/external/bot/b"
203        );
204        assert_eq!(
205            EnvName::Local.lobby_url("b"),
206            "ws://localhost:8001/ws/external/bot/b"
207        );
208    }
209
210    #[test]
211    fn resolve_env_explicit_wins() {
212        // Explicit arg beats the env var.
213        let env = resolve_env_name(Some(EnvName::Local), Some("staging")).unwrap();
214        assert_eq!(env, EnvName::Local);
215    }
216
217    #[test]
218    fn resolve_env_uses_env_var_then_default() {
219        assert_eq!(
220            resolve_env_name(None, Some("staging")).unwrap(),
221            EnvName::Staging
222        );
223        // Empty env var → default prod.
224        assert_eq!(resolve_env_name(None, Some("")).unwrap(), EnvName::Prod);
225        // Unset → default prod.
226        assert_eq!(resolve_env_name(None, None).unwrap(), EnvName::Prod);
227    }
228
229    #[test]
230    fn resolve_env_rejects_unknown_env_var() {
231        let err = resolve_env_name(None, Some("prd")).unwrap_err();
232        assert!(format!("{err}").contains("not a recognized environment"));
233    }
234
235    #[test]
236    fn connect_requires_bot_id() {
237        let err = connect_to_chipzen("", Some(EnvName::Prod), None, None).unwrap_err();
238        assert!(format!("{err}").contains("non-empty bot_id"));
239    }
240
241    #[test]
242    fn connect_builds_env_derived_url() {
243        let conn = connect_to_chipzen(
244            "abc",
245            Some(EnvName::Staging),
246            None,
247            Some(ChipzenConfig::default()),
248        )
249        .unwrap();
250        assert_eq!(conn.url, "wss://staging.chipzen.ai/ws/external/bot/abc");
251        assert_eq!(conn.env, Some(EnvName::Staging));
252    }
253
254    #[test]
255    fn connect_config_url_overrides_env_derived() {
256        let cfg = ChipzenConfig {
257            url: Some("wss://verbatim/url".into()),
258            token: Some("cz_extbot_t".into()),
259            ..Default::default()
260        };
261        let conn = connect_to_chipzen("abc", Some(EnvName::Prod), None, Some(cfg)).unwrap();
262        assert_eq!(conn.url, "wss://verbatim/url");
263        // env is None when the URL came verbatim from config.
264        assert_eq!(conn.env, None);
265        assert_eq!(conn.token.as_deref(), Some("cz_extbot_t"));
266    }
267}