Skip to main content

rustyclaw_core/messengers/
google_chat.rs

1//! Google Chat messenger using webhook URLs.
2//!
3//! Uses Google Chat incoming webhooks for sending messages.
4//! For receiving messages, requires a Google Cloud Pub/Sub subscription
5//! or the Google Chat API with a service account.
6
7use super::{Message, Messenger, SendOptions};
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10
11/// Google Chat messenger using webhooks and/or Chat API.
12pub struct GoogleChatMessenger {
13    name: String,
14    /// Incoming webhook URL for sending messages.
15    webhook_url: Option<String>,
16    /// Service account credentials JSON path (for Chat API).
17    credentials_path: Option<String>,
18    /// Space name(s) to listen on (e.g. "spaces/AAAA").
19    spaces: Vec<String>,
20    connected: bool,
21    http: reqwest::Client,
22}
23
24impl GoogleChatMessenger {
25    pub fn new(name: String) -> Self {
26        Self {
27            name,
28            webhook_url: None,
29            credentials_path: None,
30            spaces: Vec::new(),
31            connected: false,
32            http: reqwest::Client::new(),
33        }
34    }
35
36    /// Set the incoming webhook URL.
37    pub fn with_webhook_url(mut self, url: String) -> Self {
38        self.webhook_url = Some(url);
39        self
40    }
41
42    /// Set the service account credentials path.
43    pub fn with_credentials(mut self, path: String) -> Self {
44        self.credentials_path = Some(path);
45        self
46    }
47
48    /// Set spaces to listen on.
49    pub fn with_spaces(mut self, spaces: Vec<String>) -> Self {
50        self.spaces = spaces;
51        self
52    }
53}
54
55#[async_trait]
56impl Messenger for GoogleChatMessenger {
57    fn name(&self) -> &str {
58        &self.name
59    }
60
61    fn messenger_type(&self) -> &str {
62        "google_chat"
63    }
64
65    async fn initialize(&mut self) -> Result<()> {
66        // Validate that we have at least one way to send messages
67        if self.webhook_url.is_none() && self.credentials_path.is_none() {
68            anyhow::bail!(
69                "Google Chat requires either 'webhook_url' or 'credentials_path' \
70                 (service account JSON)"
71            );
72        }
73
74        self.connected = true;
75        tracing::info!(
76            has_webhook = self.webhook_url.is_some(),
77            has_credentials = self.credentials_path.is_some(),
78            spaces = ?self.spaces,
79            "Google Chat initialized"
80        );
81
82        Ok(())
83    }
84
85    async fn send_message(&self, space: &str, content: &str) -> Result<String> {
86        // Prefer webhook for simplicity
87        if let Some(ref webhook_url) = self.webhook_url {
88            let payload = serde_json::json!({
89                "text": content
90            });
91
92            let resp = self
93                .http
94                .post(webhook_url)
95                .json(&payload)
96                .send()
97                .await
98                .context("Google Chat webhook POST failed")?;
99
100            if resp.status().is_success() {
101                let data: serde_json::Value = resp.json().await?;
102                return Ok(data["name"].as_str().unwrap_or("sent").to_string());
103            }
104            anyhow::bail!("Google Chat webhook returned {}", resp.status());
105        }
106
107        // Fall back to Chat API with service account
108        if self.credentials_path.is_some() {
109            // Chat API requires OAuth2 — for now, return an error directing
110            // the user to use webhook mode or the claw-me-maybe skill.
111            anyhow::bail!(
112                "Google Chat API (service account) send not yet implemented for space '{}'. \
113                 Use webhook_url or the claw-me-maybe skill.",
114                space
115            );
116        }
117
118        anyhow::bail!("No Google Chat send method configured")
119    }
120
121    async fn send_message_with_options(&self, opts: SendOptions<'_>) -> Result<String> {
122        // Google Chat webhooks don't support threading via webhook URL,
123        // so we just send the message.
124        self.send_message(opts.recipient, opts.content).await
125    }
126
127    async fn receive_messages(&self) -> Result<Vec<Message>> {
128        // Google Chat requires either:
129        // 1. A Pub/Sub subscription (push or pull)
130        // 2. The Chat API with events
131        // For now, return empty — receiving requires more complex setup.
132        Ok(Vec::new())
133    }
134
135    fn is_connected(&self) -> bool {
136        self.connected
137    }
138
139    async fn disconnect(&mut self) -> Result<()> {
140        self.connected = false;
141        Ok(())
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_google_chat_creation() {
151        let m = GoogleChatMessenger::new("test".to_string())
152            .with_webhook_url("https://chat.googleapis.com/v1/spaces/XXX/messages?key=YYY".to_string());
153        assert_eq!(m.name(), "test");
154        assert_eq!(m.messenger_type(), "google_chat");
155        assert!(!m.is_connected());
156        assert!(m.webhook_url.is_some());
157    }
158
159    #[test]
160    fn test_with_credentials() {
161        let m = GoogleChatMessenger::new("test".to_string())
162            .with_credentials("/path/to/sa.json".to_string())
163            .with_spaces(vec!["spaces/AAAA".to_string()]);
164        assert_eq!(m.credentials_path, Some("/path/to/sa.json".to_string()));
165        assert_eq!(m.spaces, vec!["spaces/AAAA"]);
166    }
167}