Skip to main content

chat_system/
config.rs

1//! Config-driven generic client and server types.
2//!
3//! [`MessengerConfig`] and [`ServerConfig`] are serde-tagged enums whose `protocol`
4//! field selects the backend.  They can be deserialized directly from TOML, JSON,
5//! or any other serde-compatible format, making them suitable for config files.
6//!
7//! # Client example (TOML)
8//!
9//! ```toml
10//! protocol = "irc"
11//! name     = "my-bot"
12//! server   = "irc.libera.chat"
13//! port     = 6697
14//! nick     = "my-bot"
15//! channels = ["#rust"]
16//! tls      = true
17//! ```
18//!
19//! ```rust,no_run
20//! use chat_system::config::{IrcConfig, MessengerConfig};
21//! use chat_system::{GenericMessenger, Messenger};
22//!
23//! #[tokio::main]
24//! async fn main() -> anyhow::Result<()> {
25//!     let config = MessengerConfig::Irc(IrcConfig {
26//!         name: "bot".into(),
27//!         server: "irc.libera.chat".into(),
28//!         port: 6697,
29//!         nick: "my-bot".into(),
30//!         channels: vec!["#rust".into()],
31//!         tls: true,
32//!     });
33//!     let mut client = GenericMessenger::new(config);
34//!     client.initialize().await?;
35//!     client.send_message("#rust", "Hello!").await?;
36//!     client.disconnect().await?;
37//!     Ok(())
38//! }
39//! ```
40
41use crate::message::{Message, SendOptions};
42use crate::messenger::{Messenger, PresenceStatus, SearchQuery};
43use crate::server::ChatServer;
44use anyhow::Result;
45use async_trait::async_trait;
46use serde::{Deserialize, Serialize};
47
48// ── per-protocol client config structs ────────────────────────────────────────
49
50/// Configuration for an IRC client connection.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct IrcConfig {
53    pub name: String,
54    pub server: String,
55    #[serde(default = "default_irc_port")]
56    pub port: u16,
57    pub nick: String,
58    #[serde(default)]
59    pub channels: Vec<String>,
60    #[serde(default)]
61    pub tls: bool,
62}
63fn default_irc_port() -> u16 {
64    6667
65}
66
67/// Configuration for a Discord bot.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct DiscordConfig {
70    pub name: String,
71    pub token: String,
72}
73
74/// Configuration for a Telegram bot.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct TelegramConfig {
77    pub name: String,
78    pub token: String,
79}
80
81/// Configuration for a Slack bot.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SlackConfig {
84    pub name: String,
85    pub token: String,
86}
87
88/// Configuration for Microsoft Teams.
89///
90/// Use `webhook_url` for legacy incoming-webhook mode, or `token` + `team_id`
91/// + `channel_id` for Microsoft Graph mode with inbound polling support.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct TeamsConfig {
94    pub name: String,
95    #[serde(default)]
96    pub webhook_url: Option<String>,
97    #[serde(default)]
98    pub token: Option<String>,
99    #[serde(default)]
100    pub team_id: Option<String>,
101    #[serde(default)]
102    pub channel_id: Option<String>,
103}
104
105/// Configuration for Google Chat.
106///
107/// Use `webhook_url` for incoming-webhook mode, or `token` + `space_id` for
108/// authenticated Google Chat API mode with inbound polling support.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct GoogleChatConfig {
111    pub name: String,
112    #[serde(default)]
113    pub webhook_url: Option<String>,
114    #[serde(default)]
115    pub token: Option<String>,
116    #[serde(default)]
117    pub space_id: Option<String>,
118}
119
120/// Configuration for the console (stdin/stdout) messenger.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ConsoleConfig {
123    pub name: String,
124}
125
126/// Configuration for an outbound HTTP webhook.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct WebhookConfig {
129    pub name: String,
130    pub url: String,
131}
132
133/// Configuration for the iMessage messenger (macOS only).
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct IMessageConfig {
136    pub name: String,
137}
138
139#[cfg(feature = "matrix")]
140/// Configuration for a Matrix client.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct MatrixConfig {
143    pub name: String,
144    pub homeserver: String,
145    pub username: String,
146    pub password: String,
147}
148
149#[cfg(feature = "signal-cli")]
150/// Configuration for a Signal CLI messenger.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct SignalCliConfig {
153    pub name: String,
154    pub phone_number: String,
155    #[serde(default = "default_signal_cli_path")]
156    pub cli_path: String,
157}
158#[cfg(feature = "signal-cli")]
159fn default_signal_cli_path() -> String {
160    "signal-cli".to_string()
161}
162
163#[cfg(feature = "whatsapp")]
164/// Configuration for a WhatsApp messenger.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct WhatsAppConfig {
167    pub name: String,
168    /// Path to the SQLite session database (e.g. `"whatsapp.db"`).
169    pub db_path: String,
170}
171
172// ── MessengerConfig ────────────────────────────────────────────────────────────
173
174/// Protocol-selecting messenger configuration.
175///
176/// The `protocol` field (the serde tag) identifies the backend.  Deserializing
177/// from a config file that contains `protocol = "irc"` will produce
178/// `MessengerConfig::Irc(IrcConfig { … })`.
179///
180/// Call [`MessengerConfig::build`] to obtain a concrete [`Messenger`], or wrap it
181/// in a [`GenericMessenger`] which itself implements [`Messenger`].
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(tag = "protocol", rename_all = "lowercase")]
184pub enum MessengerConfig {
185    Irc(IrcConfig),
186    Discord(DiscordConfig),
187    Telegram(TelegramConfig),
188    Slack(SlackConfig),
189    Teams(TeamsConfig),
190    #[serde(rename = "googlechat")]
191    GoogleChat(GoogleChatConfig),
192    Console(ConsoleConfig),
193    Webhook(WebhookConfig),
194    #[serde(rename = "imessage")]
195    IMessage(IMessageConfig),
196    #[cfg(feature = "matrix")]
197    Matrix(MatrixConfig),
198    #[cfg(feature = "signal-cli")]
199    #[serde(rename = "signal")]
200    SignalCli(SignalCliConfig),
201    #[cfg(feature = "whatsapp")]
202    WhatsApp(WhatsAppConfig),
203}
204
205impl MessengerConfig {
206    /// The human-readable name for this messenger instance.
207    pub fn name(&self) -> &str {
208        match self {
209            Self::Irc(c) => &c.name,
210            Self::Discord(c) => &c.name,
211            Self::Telegram(c) => &c.name,
212            Self::Slack(c) => &c.name,
213            Self::Teams(c) => &c.name,
214            Self::GoogleChat(c) => &c.name,
215            Self::Console(c) => &c.name,
216            Self::Webhook(c) => &c.name,
217            Self::IMessage(c) => &c.name,
218            #[cfg(feature = "matrix")]
219            Self::Matrix(c) => &c.name,
220            #[cfg(feature = "signal-cli")]
221            Self::SignalCli(c) => &c.name,
222            #[cfg(feature = "whatsapp")]
223            Self::WhatsApp(c) => &c.name,
224        }
225    }
226
227    /// The protocol identifier string (matches the serde tag value).
228    pub fn protocol_name(&self) -> &'static str {
229        match self {
230            Self::Irc(_) => "irc",
231            Self::Discord(_) => "discord",
232            Self::Telegram(_) => "telegram",
233            Self::Slack(_) => "slack",
234            Self::Teams(_) => "teams",
235            Self::GoogleChat(_) => "googlechat",
236            Self::Console(_) => "console",
237            Self::Webhook(_) => "webhook",
238            Self::IMessage(_) => "imessage",
239            #[cfg(feature = "matrix")]
240            Self::Matrix(_) => "matrix",
241            #[cfg(feature = "signal-cli")]
242            Self::SignalCli(_) => "signal",
243            #[cfg(feature = "whatsapp")]
244            Self::WhatsApp(_) => "whatsapp",
245        }
246    }
247
248    /// Construct a concrete [`Messenger`] from this config.
249    ///
250    /// The returned messenger has **not** been initialized; call
251    /// [`Messenger::initialize`] before use, or use [`GenericMessenger`] which
252    /// does this automatically.
253    pub fn build(&self) -> Result<Box<dyn Messenger>> {
254        use crate::messengers::*;
255        let m: Box<dyn Messenger> = match self {
256            Self::Irc(c) => Box::new(
257                IrcMessenger::new(&c.name, &c.server, c.port, &c.nick)
258                    .with_channels(c.channels.clone())
259                    .with_tls(c.tls),
260            ),
261            Self::Discord(c) => Box::new(DiscordMessenger::new(&c.name, &c.token)),
262            Self::Telegram(c) => Box::new(TelegramMessenger::new(&c.name, &c.token)),
263            Self::Slack(c) => Box::new(SlackMessenger::new(&c.name, &c.token)),
264            Self::Teams(c) => match (&c.webhook_url, &c.token, &c.team_id, &c.channel_id) {
265                (_, Some(token), Some(team_id), Some(channel_id)) => Box::new(
266                    TeamsMessenger::new_graph(&c.name, token, team_id, channel_id),
267                ),
268                (Some(webhook_url), _, _, _) => Box::new(TeamsMessenger::new(&c.name, webhook_url)),
269                _ => anyhow::bail!(
270                    "Teams config requires either webhook_url or token + team_id + channel_id"
271                ),
272            },
273            Self::GoogleChat(c) => match (&c.webhook_url, &c.token, &c.space_id) {
274                (_, Some(token), Some(space_id)) => {
275                    Box::new(GoogleChatMessenger::new_api(&c.name, token, space_id))
276                }
277                (Some(webhook_url), _, _) => Box::new(GoogleChatMessenger::new(&c.name, webhook_url)),
278                _ => anyhow::bail!(
279                    "Google Chat config requires either webhook_url or token + space_id"
280                ),
281            },
282            Self::Console(c) => Box::new(ConsoleMessenger::new(&c.name)),
283            Self::Webhook(c) => Box::new(WebhookMessenger::new(&c.name, &c.url)),
284            Self::IMessage(c) => Box::new(IMessageMessenger::new(&c.name)),
285            #[cfg(feature = "matrix")]
286            Self::Matrix(c) => Box::new(MatrixMessenger::new(
287                &c.name,
288                &c.homeserver,
289                &c.username,
290                &c.password,
291            )),
292            #[cfg(feature = "signal-cli")]
293            Self::SignalCli(c) => Box::new(
294                SignalCliMessenger::new(&c.name, &c.phone_number).with_cli_path(&c.cli_path),
295            ),
296            #[cfg(feature = "whatsapp")]
297            Self::WhatsApp(c) => Box::new(WhatsAppMessenger::new(&c.name, &c.db_path)),
298        };
299        Ok(m)
300    }
301}
302
303// ── per-protocol server config structs ────────────────────────────────────────
304
305/// Configuration for an IRC listener.
306///
307/// Each `IrcListenerConfig` represents a single TCP address the server will
308/// accept IRC connections on.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct IrcListenerConfig {
311    /// TCP address to bind (e.g. `"0.0.0.0:6667"`).
312    pub address: String,
313}
314
315#[typetag::serde(name = "irc")]
316impl ListenerConfig for IrcListenerConfig {
317    fn protocol(&self) -> &str {
318        "irc"
319    }
320
321    fn address(&self) -> &str {
322        &self.address
323    }
324
325    fn build(&self) -> Box<dyn crate::server::ChatListener> {
326        Box::new(crate::servers::IrcListener::new(&self.address))
327    }
328
329    fn clone_box(&self) -> Box<dyn ListenerConfig> {
330        Box::new(self.clone())
331    }
332
333    fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        std::fmt::Debug::fmt(self, f)
335    }
336}
337
338// ── ListenerConfig ────────────────────────────────────────────────────────────
339
340/// Per-listener configuration trait.
341///
342/// Each protocol provides its own config struct that implements this trait.
343/// The trait is serde-compatible via [`typetag`], so `Vec<Box<dyn
344/// ListenerConfig>>` can be serialized and deserialized from JSON, TOML, etc.
345///
346/// # Implementing a custom listener config
347///
348/// ```rust,ignore
349/// use chat_system::config::ListenerConfig;
350///
351/// #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
352/// pub struct MyListenerConfig { pub address: String }
353///
354/// #[typetag::serde(name = "my-protocol")]
355/// impl ListenerConfig for MyListenerConfig {
356///     fn protocol(&self) -> &str { "my-protocol" }
357///     fn address(&self) -> &str { &self.address }
358///     fn build(&self) -> Box<dyn chat_system::server::ChatListener> { todo!() }
359///     fn clone_box(&self) -> Box<dyn ListenerConfig> { Box::new(self.clone()) }
360///     fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361///         std::fmt::Debug::fmt(self, f)
362///     }
363/// }
364/// ```
365#[typetag::serde(tag = "protocol")]
366pub trait ListenerConfig: Send + Sync {
367    /// The wire protocol this listener speaks (e.g. `"irc"`).
368    fn protocol(&self) -> &str;
369
370    /// The address this listener will bind.
371    fn address(&self) -> &str;
372
373    /// Construct a concrete [`ChatListener`](crate::server::ChatListener) from
374    /// this config.
375    fn build(&self) -> Box<dyn crate::server::ChatListener>;
376
377    /// Clone this config into a new boxed trait object.
378    fn clone_box(&self) -> Box<dyn ListenerConfig>;
379
380    /// Format this config for debug output.
381    fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result;
382}
383
384impl Clone for Box<dyn ListenerConfig> {
385    fn clone(&self) -> Self {
386        self.clone_box()
387    }
388}
389
390impl std::fmt::Debug for dyn ListenerConfig {
391    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392        self.debug_fmt(f)
393    }
394}
395
396// ── ServerConfig ───────────────────────────────────────────────────────────────
397
398/// Server configuration.
399///
400/// A server has a `name` and a list of listener configs.  Each listener may use
401/// a different protocol; the server itself is protocol-agnostic.
402///
403/// # Example (JSON)
404///
405/// ```json
406/// {
407///   "name": "my-server",
408///   "listeners": [
409///     { "protocol": "irc", "address": "0.0.0.0:6667" },
410///     { "protocol": "irc", "address": "0.0.0.0:6697" }
411///   ]
412/// }
413/// ```
414#[derive(Clone, Debug, Serialize, Deserialize)]
415pub struct ServerConfig {
416    pub name: String,
417    pub listeners: Vec<Box<dyn ListenerConfig>>,
418}
419
420impl ServerConfig {
421    /// The human-readable name for this server instance.
422    pub fn name(&self) -> &str {
423        &self.name
424    }
425
426    /// The listener configurations.
427    pub fn listener_configs(&self) -> &[Box<dyn ListenerConfig>] {
428        &self.listeners
429    }
430}
431
432// ── GenericMessenger ───────────────────────────────────────────────────────────
433
434/// A [`Messenger`] whose protocol is determined at runtime by a [`MessengerConfig`].
435///
436/// Construct with a config, call [`Messenger::initialize`] to establish the
437/// connection (which also builds the inner backend), then use it like any other
438/// [`Messenger`].
439///
440/// Because [`GenericMessenger`] implements [`Messenger`] it is a drop-in
441/// replacement everywhere a `Box<dyn Messenger>` is accepted, including
442/// [`MessengerManager`](crate::MessengerManager).
443pub struct GenericMessenger {
444    config: MessengerConfig,
445    inner: Option<Box<dyn Messenger>>,
446}
447
448impl GenericMessenger {
449    /// Create a new uninitialized [`GenericMessenger`] from a config.
450    pub fn new(config: MessengerConfig) -> Self {
451        Self {
452            config,
453            inner: None,
454        }
455    }
456
457    /// Access the underlying config.
458    pub fn config(&self) -> &MessengerConfig {
459        &self.config
460    }
461}
462
463#[async_trait]
464impl Messenger for GenericMessenger {
465    fn name(&self) -> &str {
466        self.inner
467            .as_ref()
468            .map(|m| m.name())
469            .unwrap_or_else(|| self.config.name())
470    }
471
472    fn messenger_type(&self) -> &str {
473        self.inner
474            .as_ref()
475            .map(|m| m.messenger_type())
476            .unwrap_or_else(|| self.config.protocol_name())
477    }
478
479    async fn initialize(&mut self) -> Result<()> {
480        let mut built = self.config.build()?;
481        built.initialize().await?;
482        self.inner = Some(built);
483        Ok(())
484    }
485
486    async fn send_message(&self, recipient: &str, content: &str) -> Result<String> {
487        self.inner
488            .as_ref()
489            .ok_or_else(|| anyhow::anyhow!("GenericMessenger not initialized"))?
490            .send_message(recipient, content)
491            .await
492    }
493
494    async fn send_message_with_options(&self, opts: SendOptions<'_>) -> Result<String> {
495        self.inner
496            .as_ref()
497            .ok_or_else(|| anyhow::anyhow!("GenericMessenger not initialized"))?
498            .send_message_with_options(opts)
499            .await
500    }
501
502    async fn receive_messages(&self) -> Result<Vec<Message>> {
503        self.inner
504            .as_ref()
505            .ok_or_else(|| anyhow::anyhow!("GenericMessenger not initialized"))?
506            .receive_messages()
507            .await
508    }
509
510    fn is_connected(&self) -> bool {
511        self.inner
512            .as_ref()
513            .map(|m| m.is_connected())
514            .unwrap_or(false)
515    }
516
517    async fn disconnect(&mut self) -> Result<()> {
518        if let Some(inner) = &mut self.inner {
519            inner.disconnect().await?;
520        }
521        Ok(())
522    }
523
524    async fn set_typing(&self, channel: &str, typing: bool) -> Result<()> {
525        if let Some(inner) = &self.inner {
526            inner.set_typing(channel, typing).await
527        } else {
528            Ok(())
529        }
530    }
531
532    async fn set_status(&self, status: PresenceStatus) -> Result<()> {
533        if let Some(inner) = &self.inner {
534            inner.set_status(status).await
535        } else {
536            Ok(())
537        }
538    }
539
540    async fn add_reaction(&self, message_id: &str, channel: &str, emoji: &str) -> Result<()> {
541        if let Some(inner) = &self.inner {
542            inner.add_reaction(message_id, channel, emoji).await
543        } else {
544            Ok(())
545        }
546    }
547
548    async fn remove_reaction(&self, message_id: &str, channel: &str, emoji: &str) -> Result<()> {
549        if let Some(inner) = &self.inner {
550            inner.remove_reaction(message_id, channel, emoji).await
551        } else {
552            Ok(())
553        }
554    }
555
556    async fn get_profile_picture(&self, user_id: &str) -> Result<Option<String>> {
557        if let Some(inner) = &self.inner {
558            inner.get_profile_picture(user_id).await
559        } else {
560            Ok(None)
561        }
562    }
563
564    async fn set_profile_picture(&self, url: &str) -> Result<()> {
565        if let Some(inner) = &self.inner {
566            inner.set_profile_picture(url).await
567        } else {
568            Ok(())
569        }
570    }
571
572    async fn set_text_status(&self, text: &str) -> Result<()> {
573        if let Some(inner) = &self.inner {
574            inner.set_text_status(text).await
575        } else {
576            Ok(())
577        }
578    }
579
580    async fn search_messages(&self, query: SearchQuery) -> Result<Vec<Message>> {
581        if let Some(inner) = &self.inner {
582            inner.search_messages(query).await
583        } else {
584            Ok(Vec::new())
585        }
586    }
587}
588
589// ── GenericServer ──────────────────────────────────────────────────────────────
590
591/// A config-driven server.
592///
593/// Builds a [`Server`](crate::server::Server) from a [`ServerConfig`],
594/// constructing the appropriate listeners for each entry in the config's
595/// `listeners` list.
596pub struct GenericServer {
597    config: ServerConfig,
598    inner: Option<crate::server::Server>,
599}
600
601impl GenericServer {
602    /// Create a new [`GenericServer`] from a config.
603    pub fn new(config: ServerConfig) -> Self {
604        Self {
605            config,
606            inner: None,
607        }
608    }
609
610    /// Access the underlying config.
611    pub fn config(&self) -> &ServerConfig {
612        &self.config
613    }
614
615    fn build_inner(&self) -> crate::server::Server {
616        let mut server = crate::server::Server::new(&self.config.name);
617        for lc in &self.config.listeners {
618            server = server.add_boxed_listener(lc.build());
619        }
620        server
621    }
622}
623
624#[async_trait]
625impl ChatServer for GenericServer {
626    fn name(&self) -> &str {
627        match &self.inner {
628            Some(s) => s.name(),
629            None => &self.config.name,
630        }
631    }
632
633    fn listeners(&self) -> Vec<&dyn crate::server::ChatListener> {
634        match &self.inner {
635            Some(s) => s.listeners(),
636            None => Vec::new(),
637        }
638    }
639
640    async fn run<F, Fut>(&mut self, handler: F) -> Result<()>
641    where
642        F: Fn(Message) -> Fut + Send + Sync + 'static,
643        Fut: std::future::Future<Output = Result<Option<String>>> + Send + 'static,
644    {
645        if self.inner.is_none() {
646            self.inner = Some(self.build_inner());
647        }
648        self.inner.as_mut().unwrap().run(handler).await
649    }
650
651    async fn shutdown(&mut self) -> Result<()> {
652        if let Some(s) = &mut self.inner {
653            s.shutdown().await
654        } else {
655            Ok(())
656        }
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn messenger_config_roundtrip_json() {
666        let cfg = MessengerConfig::Irc(IrcConfig {
667            name: "bot".into(),
668            server: "irc.libera.chat".into(),
669            port: 6697,
670            nick: "bot".into(),
671            channels: vec!["#rust".into()],
672            tls: true,
673        });
674        let json = serde_json::to_string(&cfg).unwrap();
675        let decoded: MessengerConfig = serde_json::from_str(&json).unwrap();
676        assert_eq!(decoded.protocol_name(), "irc");
677        assert_eq!(decoded.name(), "bot");
678    }
679
680    #[test]
681    fn messenger_config_deserialize_protocol_tag() {
682        let json = r#"{"protocol":"discord","name":"d-bot","token":"tok123"}"#;
683        let cfg: MessengerConfig = serde_json::from_str(json).unwrap();
684        assert_eq!(cfg.protocol_name(), "discord");
685        assert_eq!(cfg.name(), "d-bot");
686    }
687
688    #[test]
689    fn server_config_roundtrip_json() {
690        let cfg = ServerConfig {
691            name: "srv".into(),
692            listeners: vec![Box::new(IrcListenerConfig {
693                address: "0.0.0.0:6667".into(),
694            })],
695        };
696        let json = serde_json::to_string(&cfg).unwrap();
697        let decoded: ServerConfig = serde_json::from_str(&json).unwrap();
698        assert_eq!(decoded.name(), "srv");
699        assert_eq!(decoded.listeners.len(), 1);
700        assert_eq!(decoded.listeners[0].address(), "0.0.0.0:6667");
701        assert_eq!(decoded.listeners[0].protocol(), "irc");
702    }
703
704    #[test]
705    fn server_config_multi_listener_roundtrip_json() {
706        let cfg = ServerConfig {
707            name: "srv".into(),
708            listeners: vec![
709                Box::new(IrcListenerConfig {
710                    address: "0.0.0.0:6667".into(),
711                }),
712                Box::new(IrcListenerConfig {
713                    address: "0.0.0.0:6697".into(),
714                }),
715            ],
716        };
717        let json = serde_json::to_string(&cfg).unwrap();
718        let decoded: ServerConfig = serde_json::from_str(&json).unwrap();
719        assert_eq!(decoded.listeners.len(), 2);
720        assert_eq!(decoded.listeners[0].address(), "0.0.0.0:6667");
721        assert_eq!(decoded.listeners[1].address(), "0.0.0.0:6697");
722    }
723
724    #[test]
725    fn generic_messenger_name_before_init() {
726        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
727        let gm = GenericMessenger::new(cfg);
728        assert_eq!(gm.name(), "con");
729        assert_eq!(gm.messenger_type(), "console");
730        assert!(!gm.is_connected());
731    }
732
733    #[test]
734    fn generic_server_name_before_run() {
735        let cfg = ServerConfig {
736            name: "srv".into(),
737            listeners: vec![Box::new(IrcListenerConfig {
738                address: "127.0.0.1:7777".into(),
739            })],
740        };
741        let gs = GenericServer::new(cfg);
742        assert_eq!(gs.name(), "srv");
743    }
744
745    #[tokio::test]
746    async fn generic_messenger_set_typing_before_init_is_ok() {
747        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
748        let gm = GenericMessenger::new(cfg);
749        // Should be a no-op (not initialized yet), not an error.
750        gm.set_typing("#general", true).await.unwrap();
751        gm.set_typing("#general", false).await.unwrap();
752    }
753
754    #[tokio::test]
755    async fn generic_messenger_set_typing_after_init_is_ok() {
756        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
757        let mut gm = GenericMessenger::new(cfg);
758        gm.initialize().await.unwrap();
759        gm.set_typing("#general", true).await.unwrap();
760        gm.set_typing("#general", false).await.unwrap();
761        gm.disconnect().await.unwrap();
762    }
763
764    #[tokio::test]
765    async fn generic_messenger_set_status_before_init_is_ok() {
766        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
767        let gm = GenericMessenger::new(cfg);
768        gm.set_status(PresenceStatus::Online).await.unwrap();
769        gm.set_status(PresenceStatus::Away).await.unwrap();
770        gm.set_status(PresenceStatus::Busy).await.unwrap();
771        gm.set_status(PresenceStatus::Invisible).await.unwrap();
772        gm.set_status(PresenceStatus::Offline).await.unwrap();
773    }
774
775    #[tokio::test]
776    async fn generic_messenger_set_status_after_init_is_ok() {
777        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
778        let mut gm = GenericMessenger::new(cfg);
779        gm.initialize().await.unwrap();
780        gm.set_status(PresenceStatus::Online).await.unwrap();
781        gm.set_status(PresenceStatus::Away).await.unwrap();
782        gm.disconnect().await.unwrap();
783    }
784
785    #[test]
786    fn presence_status_serde_roundtrip() {
787        for status in [
788            PresenceStatus::Online,
789            PresenceStatus::Away,
790            PresenceStatus::Busy,
791            PresenceStatus::Invisible,
792            PresenceStatus::Offline,
793        ] {
794            let json = serde_json::to_string(&status).unwrap();
795            let decoded: PresenceStatus = serde_json::from_str(&json).unwrap();
796            assert_eq!(decoded, status);
797        }
798    }
799
800    #[test]
801    fn presence_status_json_values() {
802        assert_eq!(
803            serde_json::to_string(&PresenceStatus::Online).unwrap(),
804            r#""online""#
805        );
806        assert_eq!(
807            serde_json::to_string(&PresenceStatus::Away).unwrap(),
808            r#""away""#
809        );
810        assert_eq!(
811            serde_json::to_string(&PresenceStatus::Busy).unwrap(),
812            r#""busy""#
813        );
814        assert_eq!(
815            serde_json::to_string(&PresenceStatus::Invisible).unwrap(),
816            r#""invisible""#
817        );
818        assert_eq!(
819            serde_json::to_string(&PresenceStatus::Offline).unwrap(),
820            r#""offline""#
821        );
822    }
823
824    #[tokio::test]
825    async fn generic_messenger_add_reaction_before_init_is_ok() {
826        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
827        let gm = GenericMessenger::new(cfg);
828        gm.add_reaction("msg-1", "#general", "👍").await.unwrap();
829    }
830
831    #[tokio::test]
832    async fn generic_messenger_add_reaction_after_init_is_ok() {
833        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
834        let mut gm = GenericMessenger::new(cfg);
835        gm.initialize().await.unwrap();
836        gm.add_reaction("msg-1", "#general", "👍").await.unwrap();
837        gm.disconnect().await.unwrap();
838    }
839
840    #[tokio::test]
841    async fn generic_messenger_remove_reaction_before_init_is_ok() {
842        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
843        let gm = GenericMessenger::new(cfg);
844        gm.remove_reaction("msg-1", "#general", "👍").await.unwrap();
845    }
846
847    #[tokio::test]
848    async fn generic_messenger_remove_reaction_after_init_is_ok() {
849        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
850        let mut gm = GenericMessenger::new(cfg);
851        gm.initialize().await.unwrap();
852        gm.remove_reaction("msg-1", "#general", "❤️").await.unwrap();
853        gm.disconnect().await.unwrap();
854    }
855
856    #[tokio::test]
857    async fn generic_messenger_get_profile_picture_before_init_returns_none() {
858        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
859        let gm = GenericMessenger::new(cfg);
860        let pic = gm.get_profile_picture("alice").await.unwrap();
861        assert!(pic.is_none());
862    }
863
864    #[tokio::test]
865    async fn generic_messenger_get_profile_picture_after_init_returns_none() {
866        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
867        let mut gm = GenericMessenger::new(cfg);
868        gm.initialize().await.unwrap();
869        let pic = gm.get_profile_picture("bob").await.unwrap();
870        assert!(pic.is_none());
871        gm.disconnect().await.unwrap();
872    }
873
874    #[tokio::test]
875    async fn generic_messenger_set_profile_picture_before_init_is_ok() {
876        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
877        let gm = GenericMessenger::new(cfg);
878        gm.set_profile_picture("https://example.com/avatar.png")
879            .await
880            .unwrap();
881    }
882
883    #[tokio::test]
884    async fn generic_messenger_set_profile_picture_after_init_is_ok() {
885        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
886        let mut gm = GenericMessenger::new(cfg);
887        gm.initialize().await.unwrap();
888        gm.set_profile_picture("https://example.com/avatar.png")
889            .await
890            .unwrap();
891        gm.disconnect().await.unwrap();
892    }
893
894    #[tokio::test]
895    async fn generic_messenger_set_text_status_before_init_is_ok() {
896        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
897        let gm = GenericMessenger::new(cfg);
898        gm.set_text_status("Working from home 🏠").await.unwrap();
899    }
900
901    #[tokio::test]
902    async fn generic_messenger_set_text_status_after_init_is_ok() {
903        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
904        let mut gm = GenericMessenger::new(cfg);
905        gm.initialize().await.unwrap();
906        gm.set_text_status("In a meeting").await.unwrap();
907        gm.disconnect().await.unwrap();
908    }
909
910    #[tokio::test]
911    async fn generic_messenger_search_messages_before_init_returns_empty() {
912        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
913        let gm = GenericMessenger::new(cfg);
914        let results = gm
915            .search_messages(SearchQuery {
916                text: "hello".into(),
917                ..Default::default()
918            })
919            .await
920            .unwrap();
921        assert!(results.is_empty());
922    }
923
924    #[tokio::test]
925    async fn generic_messenger_search_messages_after_init_returns_empty() {
926        let cfg = MessengerConfig::Console(ConsoleConfig { name: "con".into() });
927        let mut gm = GenericMessenger::new(cfg);
928        gm.initialize().await.unwrap();
929        let results = gm
930            .search_messages(SearchQuery {
931                text: "rust".into(),
932                channel: Some("#general".into()),
933                limit: Some(10),
934                ..Default::default()
935            })
936            .await
937            .unwrap();
938        assert!(results.is_empty());
939        gm.disconnect().await.unwrap();
940    }
941
942    #[test]
943    fn search_query_serde_roundtrip() {
944        let q = SearchQuery {
945            text: "hello world".into(),
946            channel: Some("#rust".into()),
947            from: Some("alice".into()),
948            limit: Some(50),
949            before_timestamp: Some(9_999_999),
950            after_timestamp: Some(1_000_000),
951        };
952        let json = serde_json::to_string(&q).unwrap();
953        let de: SearchQuery = serde_json::from_str(&json).unwrap();
954        assert_eq!(de.text, q.text);
955        assert_eq!(de.channel, q.channel);
956        assert_eq!(de.from, q.from);
957        assert_eq!(de.limit, q.limit);
958        assert_eq!(de.before_timestamp, q.before_timestamp);
959        assert_eq!(de.after_timestamp, q.after_timestamp);
960    }
961
962    #[test]
963    fn search_query_defaults() {
964        let q: SearchQuery = serde_json::from_str(r#"{"text":"hi"}"#).unwrap();
965        assert_eq!(q.text, "hi");
966        assert!(q.channel.is_none());
967        assert!(q.from.is_none());
968        assert!(q.limit.is_none());
969        assert!(q.before_timestamp.is_none());
970        assert!(q.after_timestamp.is_none());
971    }
972}