Skip to main content

apollo/channels/
registry.rs

1//! Channel registry — name → constructor.
2//!
3//! Before this existed, adding a channel meant editing the `serve` match in
4//! `main.rs`, and anything not in that match was unreachable no matter how well
5//! it worked: seven of the ten built-in channels compiled, passed conformance,
6//! and could not be selected. A registry makes the built-ins data rather than
7//! control flow, and lets a plugin add a channel via
8//! `PluginContext::register_channel` without touching core.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use super::traits::Channel;
14
15/// Parameters a channel needs to construct itself.
16///
17/// Values come from `[channel].settings` in the config file, falling back to
18/// `APOLLO_CHANNEL_<KEY>` in the environment so tokens need not be written to
19/// disk.
20#[derive(Debug, Default, Clone)]
21pub struct ChannelSettings {
22    pub token: Option<String>,
23    pub values: HashMap<String, String>,
24}
25
26impl ChannelSettings {
27    pub fn new(token: Option<String>, values: HashMap<String, String>) -> Self {
28        Self { token, values }
29    }
30
31    /// A setting, or `None` if neither config nor environment supplies it.
32    pub fn get(&self, key: &str) -> Option<String> {
33        if let Some(v) = self.values.get(key) {
34            return Some(v.clone());
35        }
36        std::env::var(format!("APOLLO_CHANNEL_{}", key.to_uppercase())).ok()
37    }
38
39    /// A required setting. The error names both places it could have come from,
40    /// because "missing channel_id" without that is a scavenger hunt.
41    pub fn require(&self, key: &str) -> anyhow::Result<String> {
42        self.get(key).ok_or_else(|| {
43            anyhow::anyhow!(
44                "channel setting `{key}` is required — set [channel].settings.{key} \
45                 in the config or APOLLO_CHANNEL_{} in the environment",
46                key.to_uppercase()
47            )
48        })
49    }
50
51    /// The channel token, from `[channel].token`, `settings.token`, or
52    /// `APOLLO_CHANNEL_TOKEN`.
53    pub fn token(&self) -> anyhow::Result<String> {
54        if let Some(t) = &self.token {
55            return Ok(t.clone());
56        }
57        self.require("token")
58    }
59}
60
61/// Builds a channel from its settings.
62pub type ChannelBuilder =
63    Arc<dyn Fn(&ChannelSettings) -> anyhow::Result<Box<dyn Channel>> + Send + Sync>;
64
65/// Every channel apollo knows how to construct by name.
66#[derive(Clone, Default)]
67pub struct ChannelRegistry {
68    builders: HashMap<String, ChannelBuilder>,
69}
70
71impl ChannelRegistry {
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// The registry with every compiled-in channel already registered.
77    pub fn with_builtins() -> Self {
78        let mut reg = Self::new();
79
80        #[cfg(feature = "channel-cli")]
81        reg.register("cli", |_| Ok(Box::new(super::cli::CliChannel::new())));
82
83        #[cfg(feature = "channel-telegram")]
84        reg.register("telegram", |s| {
85            let chat_id: i64 = s.require("chat_id")?.parse()?;
86            Ok(Box::new(super::telegram::TelegramChannel::new(
87                s.token()?,
88                chat_id,
89            )))
90        });
91
92        #[cfg(feature = "channel-discord")]
93        reg.register("discord", |s| {
94            use super::discord::Transport;
95            let mut ch = super::discord::DiscordChannel::new(s.token()?, s.require("channel_id")?);
96            // Gateway is the default; polling is the escape hatch for a bot
97            // without the privileged MESSAGE_CONTENT intent.
98            ch = match s.get("transport").as_deref() {
99                None | Some("gateway") => ch.with_transport(Transport::Gateway),
100                Some("polling") => ch.with_transport(Transport::Polling),
101                Some(other) => anyhow::bail!(
102                    "discord transport `{other}` is not valid (use `gateway` or `polling`)"
103                ),
104            };
105            Ok(Box::new(ch))
106        });
107
108        #[cfg(feature = "channel-slack")]
109        reg.register("slack", |s| {
110            Ok(Box::new(
111                super::slack::SlackChannel::new(s.token()?).with_channel(s.require("channel_id")?),
112            ))
113        });
114
115        #[cfg(feature = "channel-matrix")]
116        reg.register("matrix", |s| {
117            Ok(Box::new(
118                super::matrix::MatrixChannel::new(s.require("homeserver")?, s.token()?)
119                    .with_room(s.require("room")?),
120            ))
121        });
122
123        #[cfg(feature = "channel-irc")]
124        reg.register("irc", |s| {
125            let mut ch = super::irc::IrcChannel::new(
126                s.require("server")?,
127                s.require("channel")?,
128                s.get("nick").unwrap_or_else(|| "apollo".to_string()),
129            );
130            if let Some(port) = s.get("port") {
131                ch = ch.with_port(port.parse()?);
132            }
133            if let Some(password) = s.get("password") {
134                ch = ch.with_password(password);
135            }
136            Ok(Box::new(ch))
137        });
138
139        #[cfg(feature = "channel-signal")]
140        reg.register("signal", |s| {
141            Ok(Box::new(super::signal::SignalChannel::new(
142                s.require("api_base")?,
143                s.require("number")?,
144            )))
145        });
146
147        #[cfg(feature = "channel-whatsapp")]
148        reg.register("whatsapp", |s| {
149            Ok(Box::new(super::whatsapp::WhatsAppChannel::new(
150                s.token()?,
151                s.require("phone_number_id")?,
152                s.get("verify_token")
153                    .unwrap_or_else(|| "apollo-verify".to_string()),
154            )))
155        });
156
157        #[cfg(feature = "channel-googlechat")]
158        reg.register("googlechat", |s| {
159            Ok(Box::new(super::googlechat::GoogleChatChannel::new(
160                s.require("service_account_key")?,
161            )))
162        });
163
164        #[cfg(feature = "channel-msteams")]
165        reg.register("msteams", |s| {
166            Ok(Box::new(super::msteams::TeamsChannel::new(
167                s.require("app_id")?,
168                s.require("app_password")?,
169            )))
170        });
171
172        reg
173    }
174
175    /// Register a channel builder. A later registration of the same name wins,
176    /// so a plugin can deliberately replace a built-in.
177    pub fn register<F>(&mut self, name: impl Into<String>, builder: F)
178    where
179        F: Fn(&ChannelSettings) -> anyhow::Result<Box<dyn Channel>> + Send + Sync + 'static,
180    {
181        self.builders.insert(name.into(), Arc::new(builder));
182    }
183
184    /// Register a pre-boxed builder — the form plugins hand over.
185    pub fn register_builder(&mut self, name: impl Into<String>, builder: ChannelBuilder) {
186        self.builders.insert(name.into(), builder);
187    }
188
189    pub fn contains(&self, name: &str) -> bool {
190        self.builders.contains_key(name)
191    }
192
193    /// Registered names, sorted, for help text and error messages.
194    pub fn names(&self) -> Vec<String> {
195        let mut names: Vec<String> = self.builders.keys().cloned().collect();
196        names.sort();
197        names
198    }
199
200    /// Construct a channel by name.
201    pub fn build(
202        &self,
203        name: &str,
204        settings: &ChannelSettings,
205    ) -> anyhow::Result<Box<dyn Channel>> {
206        let builder = self.builders.get(name).ok_or_else(|| {
207            anyhow::anyhow!(
208                "unknown channel `{name}` (available: {})",
209                self.names().join(", ")
210            )
211        })?;
212        builder(settings)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn builtins_are_registered_and_named() {
222        let reg = ChannelRegistry::with_builtins();
223        // Whatever the feature set, the registry must not be empty and must
224        // report exactly what it can build.
225        for name in reg.names() {
226            assert!(reg.contains(&name), "names() listed unbuildable {name}");
227        }
228        #[cfg(feature = "channel-cli")]
229        assert!(reg.contains("cli"));
230        #[cfg(feature = "channel-slack")]
231        assert!(reg.contains("slack"), "slack must be reachable by name");
232    }
233
234    #[test]
235    fn unknown_channel_lists_the_alternatives() {
236        let reg = ChannelRegistry::with_builtins();
237        let err = match reg.build("nope", &ChannelSettings::default()) {
238            Err(e) => e.to_string(),
239            Ok(_) => panic!("built a channel that was never registered"),
240        };
241        assert!(err.contains("unknown channel `nope`"), "{err}");
242        assert!(err.contains("available:"), "{err}");
243    }
244
245    #[test]
246    fn missing_required_setting_names_both_sources() {
247        let err = ChannelSettings::default()
248            .require("channel_id")
249            .unwrap_err()
250            .to_string();
251        assert!(err.contains("[channel].settings.channel_id"), "{err}");
252        assert!(err.contains("APOLLO_CHANNEL_CHANNEL_ID"), "{err}");
253    }
254
255    #[test]
256    fn a_plugin_channel_can_replace_a_builtin() {
257        let mut reg = ChannelRegistry::with_builtins();
258        let before = reg.names().len();
259        reg.register("custom", |_| {
260            anyhow::bail!("constructed");
261        });
262        assert_eq!(reg.names().len(), before + 1);
263        assert!(reg.contains("custom"));
264    }
265}