Skip to content

Commit 23e56d2

Browse files
committed
feat: Add Telegram, Discord, and Slack messenger support
Implements tier 1 CLI-based messengers using HTTP APIs: - Telegram: Bot API (getUpdates, sendMessage) - Discord: REST API v10 (channels/messages) - Slack: Web API (chat.postMessage, conversations.history) All use reqwest for HTTP (already a dep), no external CLI tools needed. Feature flags: telegram-cli, discord-cli, slack-cli Part of #115 (messenger support)
1 parent d39657f commit 23e56d2

File tree

5 files changed

+733
-1
lines changed

5 files changed

+733
-1
lines changed

crates/rustyclaw-core/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ browser = ["dep:chromiumoxide"]
2222
mcp = ["dep:rmcp", "dep:schemars"]
2323
matrix = ["dep:matrix-sdk"]
2424
whatsapp = ["dep:wa-rs", "dep:wa-rs-sqlite-storage", "dep:wa-rs-tokio-transport", "dep:wa-rs-ureq-http"]
25+
# CLI-based messengers (tier 1) - no heavy deps, just HTTP
26+
signal-cli = []
27+
telegram-cli = []
28+
discord-cli = []
29+
slack-cli = []
2530
# NOTE: matrix excluded from all-messengers due to matrix-sdk 0.16 recursion limit bug.
2631
# Use `--features matrix` explicitly if needed.
27-
all-messengers = ["whatsapp"]
32+
all-messengers = ["whatsapp", "signal-cli", "telegram-cli", "discord-cli", "slack-cli"]
2833
full = ["web-tools", "browser", "mcp", "all-messengers"]
2934

3035
[dependencies]
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
//! Discord messenger using REST API.
2+
//!
3+
//! Uses Discord's REST API at https://discord.com/api/v10/
4+
//! For simplicity, this uses polling for receiving messages rather than
5+
//! WebSocket Gateway. Good for low-volume bot use cases.
6+
//!
7+
//! This requires the `discord-cli` feature to be enabled.
8+
9+
use super::{Message, Messenger, SendOptions};
10+
use anyhow::{Context, Result};
11+
use async_trait::async_trait;
12+
use reqwest::Client;
13+
use serde::{Deserialize, Serialize};
14+
use serde_json::Value;
15+
use std::sync::Arc;
16+
use tokio::sync::Mutex;
17+
18+
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
19+
20+
/// Discord User object
21+
#[derive(Debug, Deserialize)]
22+
struct DiscordUser {
23+
id: String,
24+
username: String,
25+
discriminator: String,
26+
}
27+
28+
/// Discord Message object
29+
#[derive(Debug, Deserialize)]
30+
struct DiscordMessage {
31+
id: String,
32+
channel_id: String,
33+
author: DiscordUser,
34+
content: String,
35+
timestamp: String,
36+
}
37+
38+
/// Discord messenger implementation
39+
pub struct DiscordCliMessenger {
40+
name: String,
41+
token: String,
42+
client: Client,
43+
connected: Arc<Mutex<bool>>,
44+
last_message_ids: Arc<Mutex<std::collections::HashMap<String, String>>>,
45+
watch_channels: Vec<String>,
46+
}
47+
48+
impl DiscordCliMessenger {
49+
/// Create a new Discord messenger with bot token
50+
pub fn new(name: String, token: String) -> Self {
51+
Self {
52+
name,
53+
token,
54+
client: Client::new(),
55+
connected: Arc::new(Mutex::new(false)),
56+
last_message_ids: Arc::new(Mutex::new(std::collections::HashMap::new())),
57+
watch_channels: Vec::new(),
58+
}
59+
}
60+
61+
/// Add a channel to watch for incoming messages
62+
pub fn watch_channel(mut self, channel_id: String) -> Self {
63+
self.watch_channels.push(channel_id);
64+
self
65+
}
66+
67+
/// Get authorization header
68+
fn auth_header(&self) -> String {
69+
format!("Bot {}", self.token)
70+
}
71+
72+
/// Get current user info to verify token
73+
async fn get_me(&self) -> Result<DiscordUser> {
74+
let url = format!("{}/users/@me", DISCORD_API_BASE);
75+
let response = self.client
76+
.get(&url)
77+
.header("Authorization", self.auth_header())
78+
.send()
79+
.await?;
80+
81+
let status = response.status();
82+
if !status.is_success() {
83+
let error_text = response.text().await.unwrap_or_default();
84+
anyhow::bail!("Discord API error: {} - {}", status, error_text);
85+
}
86+
87+
response.json().await.context("Failed to parse user info")
88+
}
89+
90+
/// Send a message to a channel
91+
async fn send_message_internal(&self, channel_id: &str, content: &str) -> Result<String> {
92+
let url = format!("{}/channels/{}/messages", DISCORD_API_BASE, channel_id);
93+
94+
let body = serde_json::json!({
95+
"content": content
96+
});
97+
98+
let response = self.client
99+
.post(&url)
100+
.header("Authorization", self.auth_header())
101+
.json(&body)
102+
.send()
103+
.await
104+
.context("Failed to send Discord message")?;
105+
106+
let status = response.status();
107+
if !status.is_success() {
108+
let error_text = response.text().await.unwrap_or_default();
109+
anyhow::bail!("Failed to send message: {} - {}", status, error_text);
110+
}
111+
112+
let msg: DiscordMessage = response.json().await?;
113+
Ok(msg.id)
114+
}
115+
116+
/// Get messages from a channel
117+
async fn get_channel_messages(&self, channel_id: &str, limit: u32) -> Result<Vec<DiscordMessage>> {
118+
let last_ids = self.last_message_ids.lock().await;
119+
let after_id = last_ids.get(channel_id).cloned();
120+
drop(last_ids);
121+
122+
let mut url = format!("{}/channels/{}/messages?limit={}", DISCORD_API_BASE, channel_id, limit);
123+
124+
if let Some(after) = after_id {
125+
url.push_str(&format!("&after={}", after));
126+
}
127+
128+
let response = self.client
129+
.get(&url)
130+
.header("Authorization", self.auth_header())
131+
.send()
132+
.await?;
133+
134+
let status = response.status();
135+
if !status.is_success() {
136+
let error_text = response.text().await.unwrap_or_default();
137+
anyhow::bail!("Failed to get messages: {} - {}", status, error_text);
138+
}
139+
140+
let messages: Vec<DiscordMessage> = response.json().await?;
141+
142+
// Update last seen message ID
143+
if let Some(latest) = messages.first() {
144+
self.last_message_ids.lock().await.insert(channel_id.to_string(), latest.id.clone());
145+
}
146+
147+
Ok(messages)
148+
}
149+
}
150+
151+
impl std::fmt::Debug for DiscordCliMessenger {
152+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153+
f.debug_struct("DiscordCliMessenger")
154+
.field("name", &self.name)
155+
.field("watch_channels", &self.watch_channels)
156+
.finish()
157+
}
158+
}
159+
160+
#[async_trait]
161+
impl Messenger for DiscordCliMessenger {
162+
fn name(&self) -> &str {
163+
&self.name
164+
}
165+
166+
fn messenger_type(&self) -> &str {
167+
"discord"
168+
}
169+
170+
async fn initialize(&mut self) -> Result<()> {
171+
let user = self.get_me().await.context("Failed to verify Discord bot token")?;
172+
*self.connected.lock().await = true;
173+
tracing::info!("Discord bot connected as {}#{}", user.username, user.discriminator);
174+
Ok(())
175+
}
176+
177+
async fn send_message(&self, recipient: &str, content: &str) -> Result<String> {
178+
self.send_message_internal(recipient, content).await
179+
}
180+
181+
async fn receive_messages(&self) -> Result<Vec<Message>> {
182+
let mut all_messages = Vec::new();
183+
184+
for channel in &self.watch_channels {
185+
match self.get_channel_messages(channel, 100).await {
186+
Ok(messages) => {
187+
for msg in messages {
188+
all_messages.push(Message {
189+
id: msg.id,
190+
sender: msg.author.username,
191+
content: msg.content,
192+
timestamp: 0, // Would need to parse ISO timestamp
193+
channel: Some(msg.channel_id),
194+
reply_to: None,
195+
media: None,
196+
});
197+
}
198+
}
199+
Err(e) => {
200+
tracing::warn!("Failed to get messages for channel {}: {}", channel, e);
201+
}
202+
}
203+
}
204+
205+
Ok(all_messages)
206+
}
207+
208+
fn is_connected(&self) -> bool {
209+
self.connected.try_lock().map(|g| *g).unwrap_or(false)
210+
}
211+
212+
async fn disconnect(&mut self) -> Result<()> {
213+
*self.connected.lock().await = false;
214+
Ok(())
215+
}
216+
}

crates/rustyclaw-core/src/messengers/mod.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,25 @@ pub use matrix::MatrixMessenger;
193193
mod whatsapp;
194194
#[cfg(feature = "whatsapp")]
195195
pub use whatsapp::WhatsAppMessenger;
196+
197+
// ── Tier 1: CLI-based messengers (lightweight HTTP/process wrappers) ────────
198+
199+
#[cfg(feature = "signal-cli")]
200+
mod signal_cli;
201+
#[cfg(feature = "signal-cli")]
202+
pub use signal_cli::SignalCliMessenger;
203+
204+
#[cfg(feature = "telegram-cli")]
205+
mod telegram_cli;
206+
#[cfg(feature = "telegram-cli")]
207+
pub use telegram_cli::TelegramCliMessenger;
208+
209+
#[cfg(feature = "discord-cli")]
210+
mod discord_cli;
211+
#[cfg(feature = "discord-cli")]
212+
pub use discord_cli::DiscordCliMessenger;
213+
214+
#[cfg(feature = "slack-cli")]
215+
mod slack_cli;
216+
#[cfg(feature = "slack-cli")]
217+
pub use slack_cli::SlackCliMessenger;

0 commit comments

Comments
 (0)