diff --git a/src/channels/mochat.rs b/src/channels/mochat.rs new file mode 100644 index 000000000..c0a058524 --- /dev/null +++ b/src/channels/mochat.rs @@ -0,0 +1,326 @@ +use super::traits::{Channel, ChannelMessage, SendMessage}; +use async_trait::async_trait; +use serde_json::json; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +/// Deduplication set capacity — evict half of entries when full. +const DEDUP_CAPACITY: usize = 10_000; + +/// Mochat customer service channel. +/// +/// Integrates with the Mochat open-source customer service platform API +/// for receiving and sending messages through its HTTP endpoints. +pub struct MochatChannel { + api_url: String, + api_token: String, + allowed_users: Vec, + poll_interval_secs: u64, + /// Message deduplication set. + dedup: Arc>>, +} + +impl MochatChannel { + pub fn new( + api_url: String, + api_token: String, + allowed_users: Vec, + poll_interval_secs: u64, + ) -> Self { + Self { + api_url: api_url.trim_end_matches('/').to_string(), + api_token, + allowed_users, + poll_interval_secs, + dedup: Arc::new(RwLock::new(HashSet::new())), + } + } + + fn http_client(&self) -> reqwest::Client { + crate::config::build_runtime_proxy_client("channel.mochat") + } + + fn is_user_allowed(&self, user_id: &str) -> bool { + self.allowed_users.iter().any(|u| u == "*" || u == user_id) + } + + /// Check and insert message ID for deduplication. + async fn is_duplicate(&self, msg_id: &str) -> bool { + if msg_id.is_empty() { + return false; + } + + let mut dedup = self.dedup.write().await; + + if dedup.contains(msg_id) { + return true; + } + + if dedup.len() >= DEDUP_CAPACITY { + let to_remove: Vec = dedup.iter().take(DEDUP_CAPACITY / 2).cloned().collect(); + for key in to_remove { + dedup.remove(&key); + } + } + + dedup.insert(msg_id.to_string()); + false + } +} + +#[async_trait] +impl Channel for MochatChannel { + fn name(&self) -> &str { + "mochat" + } + + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + let body = json!({ + "toUserId": message.recipient, + "msgType": "text", + "content": { + "text": message.content, + } + }); + + let resp = self + .http_client() + .post(format!("{}/api/message/send", self.api_url)) + .header("Authorization", format!("Bearer {}", self.api_token)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("Mochat send message failed ({status}): {err}"); + } + + let result: serde_json::Value = resp.json().await?; + let code = result.get("code").and_then(|v| v.as_i64()).unwrap_or(-1); + if code != 0 && code != 200 { + let msg = result + .get("msg") + .or_else(|| result.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("Mochat API error (code={code}): {msg}"); + } + + Ok(()) + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + tracing::info!("Mochat: starting message poller"); + + let poll_interval = std::time::Duration::from_secs(self.poll_interval_secs); + let mut last_message_id: Option = None; + + loop { + let mut url = format!("{}/api/message/receive", self.api_url); + if let Some(ref id) = last_message_id { + use std::fmt::Write; + let _ = write!(url, "?since_id={id}"); + } + + match self + .http_client() + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_token)) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + let data: serde_json::Value = match resp.json().await { + Ok(d) => d, + Err(e) => { + tracing::warn!("Mochat: failed to parse response: {e}"); + tokio::time::sleep(poll_interval).await; + continue; + } + }; + + let messages = data + .get("data") + .or_else(|| data.get("messages")) + .and_then(|d| d.as_array()); + + if let Some(messages) = messages { + for msg in messages { + let msg_id = msg + .get("messageId") + .or_else(|| msg.get("id")) + .and_then(|i| i.as_str()) + .unwrap_or(""); + + if self.is_duplicate(msg_id).await { + continue; + } + + let sender = msg + .get("fromUserId") + .or_else(|| msg.get("sender")) + .and_then(|s| s.as_str()) + .unwrap_or("unknown"); + + if !self.is_user_allowed(sender) { + tracing::debug!( + "Mochat: ignoring message from unauthorized user: {sender}" + ); + continue; + } + + let content = msg + .get("content") + .and_then(|c| { + c.get("text") + .and_then(|t| t.as_str()) + .or_else(|| c.as_str()) + }) + .unwrap_or("") + .trim(); + + if content.is_empty() { + continue; + } + + let channel_msg = ChannelMessage { + id: Uuid::new_v4().to_string(), + sender: sender.to_string(), + reply_target: sender.to_string(), + content: content.to_string(), + channel: "mochat".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + thread_ts: None, + }; + + if tx.send(channel_msg).await.is_err() { + tracing::warn!("Mochat: message channel closed"); + return Ok(()); + } + + if !msg_id.is_empty() { + last_message_id = Some(msg_id.to_string()); + } + } + } + } + Ok(resp) => { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + tracing::warn!("Mochat: poll request failed ({status}): {err}"); + } + Err(e) => { + tracing::warn!("Mochat: poll request error: {e}"); + } + } + + tokio::time::sleep(poll_interval).await; + } + } + + async fn health_check(&self) -> bool { + let resp = self + .http_client() + .get(format!("{}/api/health", self.api_url)) + .header("Authorization", format!("Bearer {}", self.api_token)) + .send() + .await; + + match resp { + Ok(r) => r.status().is_success(), + Err(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_name() { + let ch = MochatChannel::new("https://mochat.example.com".into(), "tok".into(), vec![], 5); + assert_eq!(ch.name(), "mochat"); + } + + #[test] + fn test_api_url_trailing_slash_stripped() { + let ch = MochatChannel::new( + "https://mochat.example.com/".into(), + "tok".into(), + vec![], + 5, + ); + assert_eq!(ch.api_url, "https://mochat.example.com"); + } + + #[test] + fn test_user_allowed_wildcard() { + let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec!["*".into()], 5); + assert!(ch.is_user_allowed("anyone")); + } + + #[test] + fn test_user_allowed_specific() { + let ch = MochatChannel::new( + "https://m.test".into(), + "tok".into(), + vec!["user123".into()], + 5, + ); + assert!(ch.is_user_allowed("user123")); + assert!(!ch.is_user_allowed("other")); + } + + #[test] + fn test_user_denied_empty() { + let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec![], 5); + assert!(!ch.is_user_allowed("anyone")); + } + + #[tokio::test] + async fn test_dedup() { + let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec![], 5); + assert!(!ch.is_duplicate("msg1").await); + assert!(ch.is_duplicate("msg1").await); + assert!(!ch.is_duplicate("msg2").await); + } + + #[tokio::test] + async fn test_dedup_empty_id() { + let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec![], 5); + assert!(!ch.is_duplicate("").await); + assert!(!ch.is_duplicate("").await); + } + + #[test] + fn test_config_serde() { + let toml_str = r#" +api_url = "https://mochat.example.com" +api_token = "secret" +allowed_users = ["user1"] +"#; + let config: crate::config::schema::MochatConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.api_url, "https://mochat.example.com"); + assert_eq!(config.api_token, "secret"); + assert_eq!(config.allowed_users, vec!["user1"]); + } + + #[test] + fn test_config_serde_defaults() { + let toml_str = r#" +api_url = "https://mochat.example.com" +api_token = "secret" +"#; + let config: crate::config::schema::MochatConfig = toml::from_str(toml_str).unwrap(); + assert!(config.allowed_users.is_empty()); + assert_eq!(config.poll_interval_secs, 5); + } +} diff --git a/src/channels/mod.rs b/src/channels/mod.rs index d0d1ada19..c2c5db4c9 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -27,6 +27,7 @@ pub mod linq; #[cfg(feature = "channel-matrix")] pub mod matrix; pub mod mattermost; +pub mod mochat; pub mod nextcloud_talk; #[cfg(feature = "channel-nostr")] pub mod nostr; @@ -41,6 +42,7 @@ pub mod telegram; pub mod traits; pub mod transcription; pub mod tts; +pub mod twitter; pub mod wati; pub mod wecom; pub mod whatsapp; @@ -62,6 +64,7 @@ pub use linq::LinqChannel; #[cfg(feature = "channel-matrix")] pub use matrix::MatrixChannel; pub use mattermost::MattermostChannel; +pub use mochat::MochatChannel; pub use nextcloud_talk::NextcloudTalkChannel; #[cfg(feature = "channel-nostr")] pub use nostr::NostrChannel; @@ -73,6 +76,7 @@ pub use telegram::TelegramChannel; pub use traits::{Channel, SendMessage}; #[allow(unused_imports)] pub use tts::{TtsManager, TtsProvider}; +pub use twitter::TwitterChannel; pub use wati::WatiChannel; pub use wecom::WeComChannel; pub use whatsapp::WhatsAppChannel; @@ -3438,6 +3442,28 @@ fn collect_configured_channels( }); } + if let Some(ref tw) = config.channels_config.twitter { + channels.push(ConfiguredChannel { + display_name: "X/Twitter", + channel: Arc::new(TwitterChannel::new( + tw.bearer_token.clone(), + tw.allowed_users.clone(), + )), + }); + } + + if let Some(ref mc) = config.channels_config.mochat { + channels.push(ConfiguredChannel { + display_name: "Mochat", + channel: Arc::new(MochatChannel::new( + mc.api_url.clone(), + mc.api_token.clone(), + mc.allowed_users.clone(), + mc.poll_interval_secs, + )), + }); + } + if let Some(ref wc) = config.channels_config.wecom { channels.push(ConfiguredChannel { display_name: "WeCom", diff --git a/src/channels/twitter.rs b/src/channels/twitter.rs new file mode 100644 index 000000000..8dabf08cc --- /dev/null +++ b/src/channels/twitter.rs @@ -0,0 +1,485 @@ +use super::traits::{Channel, ChannelMessage, SendMessage}; +use async_trait::async_trait; +use serde_json::json; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +const TWITTER_API_BASE: &str = "https://api.x.com/2"; + +/// X/Twitter channel — uses the Twitter API v2 with OAuth 2.0 Bearer Token +/// for sending tweets/DMs and filtered stream for receiving mentions. +pub struct TwitterChannel { + bearer_token: String, + allowed_users: Vec, + /// Message deduplication set. + dedup: Arc>>, +} + +/// Deduplication set capacity — evict half of entries when full. +const DEDUP_CAPACITY: usize = 10_000; + +impl TwitterChannel { + pub fn new(bearer_token: String, allowed_users: Vec) -> Self { + Self { + bearer_token, + allowed_users, + dedup: Arc::new(RwLock::new(HashSet::new())), + } + } + + fn http_client(&self) -> reqwest::Client { + crate::config::build_runtime_proxy_client("channel.twitter") + } + + fn is_user_allowed(&self, user_id: &str) -> bool { + self.allowed_users.iter().any(|u| u == "*" || u == user_id) + } + + /// Check and insert tweet ID for deduplication. + async fn is_duplicate(&self, tweet_id: &str) -> bool { + if tweet_id.is_empty() { + return false; + } + + let mut dedup = self.dedup.write().await; + + if dedup.contains(tweet_id) { + return true; + } + + if dedup.len() >= DEDUP_CAPACITY { + let to_remove: Vec = dedup.iter().take(DEDUP_CAPACITY / 2).cloned().collect(); + for key in to_remove { + dedup.remove(&key); + } + } + + dedup.insert(tweet_id.to_string()); + false + } + + /// Get the authenticated user's ID for filtered stream rules. + async fn get_authenticated_user_id(&self) -> anyhow::Result { + let resp = self + .http_client() + .get(format!("{TWITTER_API_BASE}/users/me")) + .bearer_auth(&self.bearer_token) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("Twitter users/me failed ({status}): {err}"); + } + + let data: serde_json::Value = resp.json().await?; + let user_id = data + .get("data") + .and_then(|d| d.get("id")) + .and_then(|id| id.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing user id in Twitter response"))? + .to_string(); + + Ok(user_id) + } + + /// Send a reply tweet. + async fn create_tweet( + &self, + text: &str, + reply_tweet_id: Option<&str>, + ) -> anyhow::Result { + let mut body = json!({ "text": text }); + + if let Some(reply_id) = reply_tweet_id { + body["reply"] = json!({ "in_reply_to_tweet_id": reply_id }); + } + + let resp = self + .http_client() + .post(format!("{TWITTER_API_BASE}/tweets")) + .bearer_auth(&self.bearer_token) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("Twitter create tweet failed ({status}): {err}"); + } + + let data: serde_json::Value = resp.json().await?; + let tweet_id = data + .get("data") + .and_then(|d| d.get("id")) + .and_then(|id| id.as_str()) + .unwrap_or("") + .to_string(); + + Ok(tweet_id) + } + + /// Send a DM to a user. + async fn send_dm(&self, recipient_id: &str, text: &str) -> anyhow::Result<()> { + let body = json!({ + "text": text, + }); + + let resp = self + .http_client() + .post(format!( + "{TWITTER_API_BASE}/dm_conversations/with/{recipient_id}/messages" + )) + .bearer_auth(&self.bearer_token) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("Twitter DM send failed ({status}): {err}"); + } + + Ok(()) + } +} + +#[async_trait] +impl Channel for TwitterChannel { + fn name(&self) -> &str { + "twitter" + } + + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + // recipient format: "dm:{user_id}" for DMs, "tweet:{tweet_id}" for replies + if let Some(user_id) = message.recipient.strip_prefix("dm:") { + // Twitter API enforces a 280 char limit on tweets but DMs can be up to 10000. + self.send_dm(user_id, &message.content).await + } else if let Some(tweet_id) = message.recipient.strip_prefix("tweet:") { + // Split long replies into tweet threads (280 char limit). + let chunks = split_tweet_text(&message.content, 280); + let mut reply_to = tweet_id.to_string(); + for chunk in chunks { + reply_to = self.create_tweet(&chunk, Some(&reply_to)).await?; + } + Ok(()) + } else { + // Default: treat as tweet reply + let chunks = split_tweet_text(&message.content, 280); + let mut reply_to = message.recipient.clone(); + for chunk in chunks { + reply_to = self.create_tweet(&chunk, Some(&reply_to)).await?; + } + Ok(()) + } + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + tracing::info!("Twitter: authenticating..."); + let bot_user_id = self.get_authenticated_user_id().await?; + tracing::info!("Twitter: authenticated as user {bot_user_id}"); + + // Poll mentions timeline (filtered stream requires elevated access). + // Using mentions timeline polling as a more accessible approach. + let mut since_id: Option = None; + let poll_interval = std::time::Duration::from_secs(15); + + loop { + let mut url = format!( + "{TWITTER_API_BASE}/users/{bot_user_id}/mentions?tweet.fields=author_id,conversation_id,created_at&expansions=author_id&max_results=20" + ); + + if let Some(ref id) = since_id { + use std::fmt::Write; + let _ = write!(url, "&since_id={id}"); + } + + match self + .http_client() + .get(&url) + .bearer_auth(&self.bearer_token) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + let data: serde_json::Value = match resp.json().await { + Ok(d) => d, + Err(e) => { + tracing::warn!("Twitter: failed to parse mentions response: {e}"); + tokio::time::sleep(poll_interval).await; + continue; + } + }; + + if let Some(tweets) = data.get("data").and_then(|d| d.as_array()) { + // Build user lookup map from includes + let user_map: std::collections::HashMap = data + .get("includes") + .and_then(|i| i.get("users")) + .and_then(|u| u.as_array()) + .map(|users| { + users + .iter() + .filter_map(|u| { + let id = u.get("id")?.as_str()?.to_string(); + let username = u.get("username")?.as_str()?.to_string(); + Some((id, username)) + }) + .collect() + }) + .unwrap_or_default(); + + // Process tweets in chronological order (oldest first) + for tweet in tweets.iter().rev() { + let tweet_id = tweet.get("id").and_then(|i| i.as_str()).unwrap_or(""); + let author_id = tweet + .get("author_id") + .and_then(|a| a.as_str()) + .unwrap_or(""); + let text = tweet.get("text").and_then(|t| t.as_str()).unwrap_or(""); + + // Skip own tweets + if author_id == bot_user_id { + continue; + } + + if self.is_duplicate(tweet_id).await { + continue; + } + + let username = user_map + .get(author_id) + .cloned() + .unwrap_or_else(|| author_id.to_string()); + + if !self.is_user_allowed(&username) && !self.is_user_allowed(author_id) + { + tracing::debug!( + "Twitter: ignoring mention from unauthorized user: {username}" + ); + continue; + } + + // Strip the @mention from the text + let clean_text = strip_at_mention(text, &bot_user_id); + + if clean_text.trim().is_empty() { + continue; + } + + let reply_target = format!("tweet:{tweet_id}"); + + let channel_msg = ChannelMessage { + id: Uuid::new_v4().to_string(), + sender: username, + reply_target, + content: clean_text, + channel: "twitter".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + thread_ts: tweet + .get("conversation_id") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()), + }; + + if tx.send(channel_msg).await.is_err() { + tracing::warn!("Twitter: message channel closed"); + return Ok(()); + } + + // Track newest ID for pagination + if since_id.as_deref().map_or(true, |s| tweet_id > s) { + since_id = Some(tweet_id.to_string()); + } + } + } + + // Update newest_id from meta + if let Some(newest) = data + .get("meta") + .and_then(|m| m.get("newest_id")) + .and_then(|n| n.as_str()) + { + since_id = Some(newest.to_string()); + } + } + Ok(resp) => { + let status = resp.status(); + if status.as_u16() == 429 { + // Rate limited — back off + tracing::warn!("Twitter: rate limited, backing off 60s"); + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + continue; + } + let err = resp.text().await.unwrap_or_default(); + tracing::warn!("Twitter: mentions request failed ({status}): {err}"); + } + Err(e) => { + tracing::warn!("Twitter: mentions request error: {e}"); + } + } + + tokio::time::sleep(poll_interval).await; + } + } + + async fn health_check(&self) -> bool { + self.get_authenticated_user_id().await.is_ok() + } +} + +/// Strip @mention from the beginning of a tweet text. +fn strip_at_mention(text: &str, _bot_user_id: &str) -> String { + // Remove all leading @mentions (Twitter includes @bot_name at start of replies) + let mut result = text; + while let Some(rest) = result.strip_prefix('@') { + // Skip past the username (until whitespace or end) + match rest.find(char::is_whitespace) { + Some(idx) => result = rest[idx..].trim_start(), + None => return String::new(), + } + } + result.to_string() +} + +/// Split text into tweet-sized chunks, breaking at word boundaries. +fn split_tweet_text(text: &str, max_len: usize) -> Vec { + if text.len() <= max_len { + return vec![text.to_string()]; + } + + let mut chunks = Vec::new(); + let mut remaining = text; + + while !remaining.is_empty() { + if remaining.len() <= max_len { + chunks.push(remaining.to_string()); + break; + } + + // Find last space within limit + let split_at = remaining[..max_len].rfind(' ').unwrap_or(max_len); + + chunks.push(remaining[..split_at].to_string()); + remaining = remaining[split_at..].trim_start(); + } + + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_name() { + let ch = TwitterChannel::new("token".into(), vec![]); + assert_eq!(ch.name(), "twitter"); + } + + #[test] + fn test_user_allowed_wildcard() { + let ch = TwitterChannel::new("token".into(), vec!["*".into()]); + assert!(ch.is_user_allowed("anyone")); + } + + #[test] + fn test_user_allowed_specific() { + let ch = TwitterChannel::new("token".into(), vec!["user123".into()]); + assert!(ch.is_user_allowed("user123")); + assert!(!ch.is_user_allowed("other")); + } + + #[test] + fn test_user_denied_empty() { + let ch = TwitterChannel::new("token".into(), vec![]); + assert!(!ch.is_user_allowed("anyone")); + } + + #[tokio::test] + async fn test_dedup() { + let ch = TwitterChannel::new("token".into(), vec![]); + assert!(!ch.is_duplicate("tweet1").await); + assert!(ch.is_duplicate("tweet1").await); + assert!(!ch.is_duplicate("tweet2").await); + } + + #[tokio::test] + async fn test_dedup_empty_id() { + let ch = TwitterChannel::new("token".into(), vec![]); + assert!(!ch.is_duplicate("").await); + assert!(!ch.is_duplicate("").await); + } + + #[test] + fn test_strip_at_mention_single() { + assert_eq!(strip_at_mention("@bot hello world", "123"), "hello world"); + } + + #[test] + fn test_strip_at_mention_multiple() { + assert_eq!(strip_at_mention("@bot @other hello", "123"), "hello"); + } + + #[test] + fn test_strip_at_mention_only() { + assert_eq!(strip_at_mention("@bot", "123"), ""); + } + + #[test] + fn test_strip_at_mention_no_mention() { + assert_eq!(strip_at_mention("hello world", "123"), "hello world"); + } + + #[test] + fn test_split_tweet_text_short() { + let chunks = split_tweet_text("hello", 280); + assert_eq!(chunks, vec!["hello"]); + } + + #[test] + fn test_split_tweet_text_long() { + let text = "a ".repeat(200); + let chunks = split_tweet_text(text.trim(), 280); + assert!(chunks.len() > 1); + for chunk in &chunks { + assert!(chunk.len() <= 280); + } + } + + #[test] + fn test_split_tweet_text_no_spaces() { + let text = "a".repeat(300); + let chunks = split_tweet_text(&text, 280); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), 280); + } + + #[test] + fn test_config_serde() { + let toml_str = r#" +bearer_token = "AAAA" +allowed_users = ["user1"] +"#; + let config: crate::config::schema::TwitterConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.bearer_token, "AAAA"); + assert_eq!(config.allowed_users, vec!["user1"]); + } + + #[test] + fn test_config_serde_defaults() { + let toml_str = r#" +bearer_token = "tok" +"#; + let config: crate::config::schema::TwitterConfig = toml::from_str(toml_str).unwrap(); + assert!(config.allowed_users.is_empty()); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs index 06cf1ee70..5b65e2d46 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -3621,6 +3621,10 @@ pub struct ChannelsConfig { pub wecom: Option, /// QQ Official Bot channel configuration. pub qq: Option, + /// X/Twitter channel configuration. + pub twitter: Option, + /// Mochat customer service channel configuration. + pub mochat: Option, #[cfg(feature = "channel-nostr")] pub nostr: Option, /// ClawdTalk voice channel configuration. @@ -3784,6 +3788,8 @@ impl Default for ChannelsConfig { dingtalk: None, wecom: None, qq: None, + twitter: None, + mochat: None, #[cfg(feature = "channel-nostr")] nostr: None, clawdtalk: None, @@ -4804,6 +4810,53 @@ impl ChannelConfig for QQConfig { } } +/// X/Twitter channel configuration (Twitter API v2) +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TwitterConfig { + /// Twitter API v2 Bearer Token (OAuth 2.0) + pub bearer_token: String, + /// Allowed usernames or user IDs. Empty = deny all, "*" = allow all + #[serde(default)] + pub allowed_users: Vec, +} + +impl ChannelConfig for TwitterConfig { + fn name() -> &'static str { + "X/Twitter" + } + fn desc() -> &'static str { + "X/Twitter Bot via API v2" + } +} + +/// Mochat channel configuration (Mochat customer service API) +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MochatConfig { + /// Mochat API base URL + pub api_url: String, + /// Mochat API token + pub api_token: String, + /// Allowed user IDs. Empty = deny all, "*" = allow all + #[serde(default)] + pub allowed_users: Vec, + /// Poll interval in seconds for new messages. Default: 5 + #[serde(default = "default_mochat_poll_interval")] + pub poll_interval_secs: u64, +} + +fn default_mochat_poll_interval() -> u64 { + 5 +} + +impl ChannelConfig for MochatConfig { + fn name() -> &'static str { + "Mochat" + } + fn desc() -> &'static str { + "Mochat Customer Service" + } +} + /// Nostr channel configuration (NIP-04 + NIP-17 private messages) #[cfg(feature = "channel-nostr")] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -7460,6 +7513,8 @@ default_temperature = 0.7 dingtalk: None, wecom: None, qq: None, + twitter: None, + mochat: None, #[cfg(feature = "channel-nostr")] nostr: None, clawdtalk: None, @@ -8195,6 +8250,8 @@ allowed_users = ["@ops:matrix.org"] dingtalk: None, wecom: None, qq: None, + twitter: None, + mochat: None, nostr: None, clawdtalk: None, message_timeout_secs: 300, @@ -8425,6 +8482,8 @@ channel_id = "C123" dingtalk: None, wecom: None, qq: None, + twitter: None, + mochat: None, nostr: None, clawdtalk: None, message_timeout_secs: 300,