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            Ok(Box::new(super::discord::DiscordChannel::new(
95                s.token()?,
96                s.require("channel_id")?,
97            )))
98        });
99
100        #[cfg(feature = "channel-slack")]
101        reg.register("slack", |s| {
102            Ok(Box::new(
103                super::slack::SlackChannel::new(s.token()?).with_channel(s.require("channel_id")?),
104            ))
105        });
106
107        #[cfg(feature = "channel-matrix")]
108        reg.register("matrix", |s| {
109            Ok(Box::new(
110                super::matrix::MatrixChannel::new(s.require("homeserver")?, s.token()?)
111                    .with_room(s.require("room")?),
112            ))
113        });
114
115        #[cfg(feature = "channel-irc")]
116        reg.register("irc", |s| {
117            let mut ch = super::irc::IrcChannel::new(
118                s.require("server")?,
119                s.require("channel")?,
120                s.get("nick").unwrap_or_else(|| "apollo".to_string()),
121            );
122            if let Some(port) = s.get("port") {
123                ch = ch.with_port(port.parse()?);
124            }
125            if let Some(password) = s.get("password") {
126                ch = ch.with_password(password);
127            }
128            Ok(Box::new(ch))
129        });
130
131        #[cfg(feature = "channel-signal")]
132        reg.register("signal", |s| {
133            Ok(Box::new(super::signal::SignalChannel::new(
134                s.require("api_base")?,
135                s.require("number")?,
136            )))
137        });
138
139        #[cfg(feature = "channel-whatsapp")]
140        reg.register("whatsapp", |s| {
141            Ok(Box::new(super::whatsapp::WhatsAppChannel::new(
142                s.token()?,
143                s.require("phone_number_id")?,
144                s.get("verify_token")
145                    .unwrap_or_else(|| "apollo-verify".to_string()),
146            )))
147        });
148
149        #[cfg(feature = "channel-googlechat")]
150        reg.register("googlechat", |s| {
151            Ok(Box::new(super::googlechat::GoogleChatChannel::new(
152                s.require("service_account_key")?,
153            )))
154        });
155
156        #[cfg(feature = "channel-msteams")]
157        reg.register("msteams", |s| {
158            Ok(Box::new(super::msteams::TeamsChannel::new(
159                s.require("app_id")?,
160                s.require("app_password")?,
161            )))
162        });
163
164        reg
165    }
166
167    /// Register a channel builder. A later registration of the same name wins,
168    /// so a plugin can deliberately replace a built-in.
169    pub fn register<F>(&mut self, name: impl Into<String>, builder: F)
170    where
171        F: Fn(&ChannelSettings) -> anyhow::Result<Box<dyn Channel>> + Send + Sync + 'static,
172    {
173        self.builders.insert(name.into(), Arc::new(builder));
174    }
175
176    /// Register a pre-boxed builder — the form plugins hand over.
177    pub fn register_builder(&mut self, name: impl Into<String>, builder: ChannelBuilder) {
178        self.builders.insert(name.into(), builder);
179    }
180
181    pub fn contains(&self, name: &str) -> bool {
182        self.builders.contains_key(name)
183    }
184
185    /// Registered names, sorted, for help text and error messages.
186    pub fn names(&self) -> Vec<String> {
187        let mut names: Vec<String> = self.builders.keys().cloned().collect();
188        names.sort();
189        names
190    }
191
192    /// Construct a channel by name.
193    pub fn build(
194        &self,
195        name: &str,
196        settings: &ChannelSettings,
197    ) -> anyhow::Result<Box<dyn Channel>> {
198        let builder = self.builders.get(name).ok_or_else(|| {
199            anyhow::anyhow!(
200                "unknown channel `{name}` (available: {})",
201                self.names().join(", ")
202            )
203        })?;
204        builder(settings)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn builtins_are_registered_and_named() {
214        let reg = ChannelRegistry::with_builtins();
215        // Whatever the feature set, the registry must not be empty and must
216        // report exactly what it can build.
217        for name in reg.names() {
218            assert!(reg.contains(&name), "names() listed unbuildable {name}");
219        }
220        #[cfg(feature = "channel-cli")]
221        assert!(reg.contains("cli"));
222        #[cfg(feature = "channel-slack")]
223        assert!(reg.contains("slack"), "slack must be reachable by name");
224    }
225
226    #[test]
227    fn unknown_channel_lists_the_alternatives() {
228        let reg = ChannelRegistry::with_builtins();
229        let err = match reg.build("nope", &ChannelSettings::default()) {
230            Err(e) => e.to_string(),
231            Ok(_) => panic!("built a channel that was never registered"),
232        };
233        assert!(err.contains("unknown channel `nope`"), "{err}");
234        assert!(err.contains("available:"), "{err}");
235    }
236
237    #[test]
238    fn missing_required_setting_names_both_sources() {
239        let err = ChannelSettings::default()
240            .require("channel_id")
241            .unwrap_err()
242            .to_string();
243        assert!(err.contains("[channel].settings.channel_id"), "{err}");
244        assert!(err.contains("APOLLO_CHANNEL_CHANNEL_ID"), "{err}");
245    }
246
247    #[test]
248    fn a_plugin_channel_can_replace_a_builtin() {
249        let mut reg = ChannelRegistry::with_builtins();
250        let before = reg.names().len();
251        reg.register("custom", |_| {
252            anyhow::bail!("constructed");
253        });
254        assert_eq!(reg.names().len(), before + 1);
255        assert!(reg.contains("custom"));
256    }
257}