apollo/channels/
registry.rs1use std::collections::HashMap;
11use std::sync::Arc;
12
13use super::traits::Channel;
14
15#[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 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 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 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
61pub type ChannelBuilder =
63 Arc<dyn Fn(&ChannelSettings) -> anyhow::Result<Box<dyn Channel>> + Send + Sync>;
64
65#[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 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 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 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 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 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 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 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}