Skip to main content

rain_engine_channels/
discord.rs

1//! Discord bot adapter using runtime delivery plus Discord Gateway events.
2//!
3//! Requires `DISCORD_BOT_TOKEN` to be set.
4//! This adapter uses the Discord Gateway (WebSocket) for receiving messages
5//! and the REST API for sending replies.
6
7use crate::{ChannelAdapter, ChannelConfig};
8use async_trait::async_trait;
9use rain_engine_client::RainEngineClient;
10use serde::Deserialize;
11use tracing::{error, info, warn};
12
13#[derive(Debug, Clone)]
14pub struct DiscordAdapter {
15    token: String,
16    client: reqwest::Client,
17    engine_client: RainEngineClient,
18    config: ChannelConfig,
19}
20
21impl DiscordAdapter {
22    pub fn new(token: String, config: ChannelConfig) -> Self {
23        Self {
24            engine_client: RainEngineClient::new(&config.runtime_url)
25                .expect("failed to init client"),
26            client: reqwest::Client::new(),
27            token,
28            config,
29        }
30    }
31
32    fn session_id(&self, channel_id: &str) -> String {
33        format!(
34            "{}-discord-{}",
35            self.config.default_session_prefix, channel_id
36        )
37    }
38
39    async fn send_message(&self, channel_id: &str, content: &str) -> Result<(), reqwest::Error> {
40        self.client
41            .post(format!(
42                "https://discord.com/api/v10/channels/{channel_id}/messages"
43            ))
44            .header("Authorization", format!("Bot {}", self.token))
45            .json(&serde_json::json!({ "content": content }))
46            .send()
47            .await?;
48        Ok(())
49    }
50}
51
52#[derive(Debug, Deserialize)]
53struct GatewayInfo {
54    url: String,
55}
56
57#[derive(Debug, Deserialize)]
58#[allow(dead_code)]
59struct GatewayEvent {
60    op: u8,
61    #[serde(default)]
62    t: Option<String>,
63    #[serde(default)]
64    d: Option<serde_json::Value>,
65    #[serde(default)]
66    s: Option<i64>,
67}
68
69#[async_trait]
70impl ChannelAdapter for DiscordAdapter {
71    fn name(&self) -> &str {
72        "discord"
73    }
74
75    async fn run(&self, cancel: tokio_util::sync::CancellationToken) {
76        info!("Discord adapter started");
77
78        // Get the Gateway URL
79        let gateway_url = match self
80            .client
81            .get("https://discord.com/api/v10/gateway")
82            .header("Authorization", format!("Bot {}", self.token))
83            .send()
84            .await
85        {
86            Ok(resp) => match resp.json::<GatewayInfo>().await {
87                Ok(info) => format!("{}?v=10&encoding=json", info.url),
88                Err(err) => {
89                    error!("Failed to parse gateway URL: {err}");
90                    return;
91                }
92            },
93            Err(err) => {
94                error!("Failed to get Discord gateway: {err}");
95                return;
96            }
97        };
98
99        info!(url = %gateway_url, "Connecting to Discord gateway");
100
101        // For a production implementation, we'd use tokio-tungstenite here.
102        // This is a polling fallback that checks for messages via REST.
103        // Full WebSocket implementation requires the `tokio-tungstenite` crate.
104        warn!(
105            "Discord adapter: using REST polling fallback. For production, add WebSocket support."
106        );
107
108        let _last_message_id: Option<String> = None;
109
110        loop {
111            if cancel.is_cancelled() {
112                info!("Discord adapter shutting down");
113                return;
114            }
115
116            // Poll is a placeholder — in production you'd read from the WebSocket.
117            tokio::select! {
118                _ = cancel.cancelled() => return,
119                _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {}
120            }
121
122            // In production, messages arrive via the WebSocket gateway.
123            // This loop exists as the adapter's structural skeleton.
124            // The message processing logic below is wired and ready.
125        }
126    }
127}
128
129/// Process a Discord MESSAGE_CREATE event. Extracted for use by both
130/// the REST polling fallback and future WebSocket implementation.
131impl DiscordAdapter {
132    pub async fn handle_message(
133        &self,
134        channel_id: &str,
135        author_id: &str,
136        content: &str,
137        is_bot: bool,
138    ) {
139        if is_bot {
140            return; // Ignore bot messages
141        }
142
143        let actor_id = format!("discord:{author_id}");
144        let session_id = self.session_id(channel_id);
145
146        info!(channel_id, actor = %actor_id, "Discord message received");
147
148        match self
149            .engine_client
150            .send_human_input(&actor_id, &session_id, content)
151            .await
152        {
153            Ok(result) => {
154                let reply = result
155                    .outcome
156                    .response
157                    .as_deref()
158                    .unwrap_or("*(no response)*");
159                if let Err(err) = self.send_message(channel_id, reply).await {
160                    error!("Failed to send Discord reply: {err}");
161                }
162            }
163            Err(err) => {
164                error!("Engine request failed: {err}");
165                let _ = self
166                    .send_message(channel_id, "⚠️ Engine error, please try again.")
167                    .await;
168            }
169        }
170    }
171}