use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::{mpsc, Mutex};
use super::traits::*;
#[derive(Debug, Deserialize)]
struct ServiceAccount {
client_email: String,
private_key: String,
#[serde(default = "default_token_uri")]
token_uri: String,
}
fn default_token_uri() -> String {
"https://oauth2.googleapis.com/token".to_string()
}
#[derive(Debug, Serialize)]
struct Assertion<'a> {
iss: &'a str,
scope: &'a str,
aud: &'a str,
iat: i64,
exp: i64,
}
struct CachedToken {
value: String,
expires_at: chrono::DateTime<chrono::Utc>,
}
pub struct GoogleChatChannel {
service_account_key: String,
space_id: Option<String>,
api_base: String,
bind_addr: std::net::SocketAddr,
token: Mutex<Option<CachedToken>>,
}
impl GoogleChatChannel {
pub fn new(service_account_key: impl Into<String>) -> Self {
Self {
service_account_key: service_account_key.into(),
space_id: None,
api_base: "https://chat.googleapis.com/v1".to_string(),
bind_addr: ([0, 0, 0, 0], 3001).into(),
token: Mutex::new(None),
}
}
async fn access_token(&self) -> anyhow::Result<String> {
let mut cached = self.token.lock().await;
if let Some(token) = cached.as_ref() {
if token.expires_at > chrono::Utc::now() {
return Ok(token.value.clone());
}
}
let account: ServiceAccount =
serde_json::from_str(&self.service_account_key).map_err(|e| {
anyhow::anyhow!("Google Chat service-account key is not valid JSON: {e}")
})?;
let now = chrono::Utc::now().timestamp();
let assertion = Assertion {
iss: &account.client_email,
scope: "https://www.googleapis.com/auth/chat.bot",
aud: &account.token_uri,
iat: now,
exp: now + 3600,
};
let key = jsonwebtoken::EncodingKey::from_rsa_pem(account.private_key.as_bytes()).map_err(
|e| anyhow::anyhow!("Google Chat service-account private key is unusable: {e}"),
)?;
let jwt = jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
&assertion,
&key,
)?;
let resp = crate::http::shared()
.post(&account.token_uri)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
("assertion", &jwt),
])
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("Google Chat token exchange failed: {}", resp.status());
}
let body = resp.json::<Value>().await?;
let value = body["access_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Google Chat token response carried no access_token"))?
.to_string();
let ttl = body["expires_in"].as_i64().unwrap_or(3600);
*cached = Some(CachedToken {
value: value.clone(),
expires_at: chrono::Utc::now() + chrono::Duration::seconds((ttl - 60).max(0)),
});
Ok(value)
}
pub fn with_api_base(mut self, base: impl Into<String>) -> Self {
self.api_base = base.into().trim_end_matches('/').to_string();
self
}
pub fn with_bind_addr(mut self, addr: std::net::SocketAddr) -> Self {
self.bind_addr = addr;
self
}
pub fn with_space(mut self, space_id: impl Into<String>) -> Self {
self.space_id = Some(space_id.into());
self
}
}
fn parse_webhook_message(body: &Value) -> Vec<IncomingMessage> {
if body["type"].as_str() == Some("MESSAGE") {
let msg = &body["message"];
let text = msg["text"].as_str().unwrap_or("").to_string();
if !text.is_empty() {
return vec![IncomingMessage {
id: msg["name"].as_str().unwrap_or("").to_string(),
sender_id: body["user"]["name"].as_str().unwrap_or("").to_string(),
sender_name: body["user"]["displayName"].as_str().map(|s| s.to_string()),
chat_id: body["space"]["name"].as_str().unwrap_or("").to_string(),
text,
is_group: body["space"]["type"].as_str() == Some("ROOM"),
reply_to: None,
timestamp: chrono::Utc::now(),
}];
}
}
Vec::new()
}
#[async_trait]
impl Channel for GoogleChatChannel {
fn name(&self) -> &str {
"googlechat"
}
async fn start(&mut self) -> anyhow::Result<mpsc::Receiver<IncomingMessage>> {
let (tx, rx) = mpsc::channel(32);
super::webhook::serve_json(
self.bind_addr,
"/googlechat/webhook",
parse_webhook_message,
tx,
)
.await?;
Ok(rx)
}
async fn send(&self, message: OutgoingMessage) -> anyhow::Result<Option<String>> {
let client = crate::http::shared();
let body = serde_json::json!({
"text": &message.text,
});
client
.post(format!("{}/{}/messages", self.api_base, message.chat_id))
.header(
"Authorization",
format!("Bearer {}", self.access_token().await?),
)
.json(&body)
.send()
.await?;
Ok(None)
}
async fn stop(&mut self) -> anyhow::Result<()> {
Ok(())
}
}