nirc-rs/src/protocols/discord.rs

974 lines
38 KiB
Rust
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Discord protocol backend — Phase I.
//!
//! Implements the Discord Gateway (WebSocket) + REST API:
//! - Bot token authentication (user token supported but discouraged by Discord ToS)
//! - Real-time messaging via Gateway events (opcodes 011)
//! - Heartbeat (configurable interval from Hello, typically 41.25 s)
//! - Session resume (session_id + sequence number)
//! - Guild (server), channel, DM, and group DM support
//! - Message send / edit / delete / react
//! - Typing indicators
//! - Member listing, server join/leave via invite
//!
//! API reference: <https://discord.com/developers/docs>
//! Gateway URL obtained via REST GET /gateway/bot.
use crate::core::message::{ChatMessage, MessageKind};
use crate::core::protocol::ProtocolType;
use serde::{Deserialize, Serialize};
use futures::StreamExt;
use std::collections::HashMap;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
// ─── Configuration ────────────────────────────────────────────────────
/// Configuration for a Discord connection.
#[derive(Debug, Clone)]
pub struct DiscordConfig {
/// REST API base URL.
pub api_base: String,
/// Bot token (starts with "Bot ") or user token.
pub bot_token: String,
/// Session ID for resume (saved from previous Ready).
pub session_id: Option<String>,
/// Last received sequence number for resume.
pub sequence: Option<u64>,
/// Outgoing messages to the TUI.
pub tx: mpsc::Sender<ChatMessage>,
}
// ─── Commands ──────────────────────────────────────────────────────────
/// Commands sent from the dispatcher to the Discord client task.
#[derive(Debug)]
pub enum DiscordCommand {
/// Send a text message to a channel.
Msg { channel_id: String, body: String },
/// Send an emote (`/me` — sent as italic text since Discord has no native /me).
Emote { channel_id: String, body: String },
/// Edit a previously sent message.
EditMessage { channel_id: String, message_id: String, new_body: String },
/// Delete a message.
DeleteMessage { channel_id: String, message_id: String },
/// React to a message (emoji string, e.g. "🎉" or "thonk:123456").
React { channel_id: String, message_id: String, emoji: String },
/// Remove a reaction.
RemoveReact { channel_id: String, message_id: String, emoji: String },
/// Join a guild by invite code.
JoinGuild { invite_code: String },
/// Leave a guild.
LeaveGuild { guild_id: String },
/// List members of a guild.
Members { guild_id: String },
/// List guilds (servers) the bot is in.
ListServers,
/// Quit the Discord client task.
Quit,
}
// ─── Gateway opcodes ──────────────────────────────────────────────────
const OP_DISPATCH: u8 = 0;
const OP_HEARTBEAT: u8 = 1;
const OP_IDENTIFY: u8 = 2;
const OP_PRESENCE_UPDATE: u8 = 3;
const OP_RESUME: u8 = 6;
const OP_RECONNECT: u8 = 7;
const OP_REQUEST_GUILD_MEMBERS: u8 = 8;
const OP_INVALID_SESSION: u8 = 9;
const OP_HELLO: u8 = 10;
const OP_HEARTBEAT_ACK: u8 = 11;
// ─── Gateway wire types ───────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GatewayPayload {
op: u8,
#[serde(skip_serializing_if = "Option::is_none")]
d: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
s: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
t: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct Identify {
token: String,
properties: IdentifyProperties,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
seq: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
struct IdentifyProperties {
os: &'static str,
browser: &'static str,
device: &'static str,
}
#[derive(Debug, Clone, Serialize)]
struct Resume {
token: String,
session_id: String,
seq: u64,
}
// ─── Discord API types (minimal subset) ───────────────────────────────
#[derive(Debug, Clone, Deserialize, Default)]
struct DiscordUser {
#[serde(default)]
id: String,
#[serde(default)]
username: String,
#[serde(default)]
discriminator: String,
#[serde(default)]
avatar: Option<String>,
#[serde(default)]
bot: bool,
}
#[derive(Debug, Clone, Deserialize)]
struct DiscordGuild {
id: String,
name: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
owner: bool,
#[serde(default)]
channels: Vec<DiscordChannel>,
#[serde(default)]
members: Vec<DiscordMember>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
struct DiscordChannel {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
channel_type: u8,
// 0 = guild text, 1 = DM, 2 = guild voice, 3 = group DM, 4 = guild category,
// 5 = guild announcement, 10 = announcement thread, 11 = public thread,
// 12 = private thread, 13 = stage channel, 14 = guild directory, 15 = forum
#[serde(default)]
guild_id: Option<String>,
#[serde(default)]
recipient_ids: Vec<String>,
#[serde(default)]
last_message_id: Option<String>,
#[serde(default)]
nsfw: bool,
#[serde(default)]
topic: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct DiscordMember {
#[serde(default)]
user: Option<DiscordUser>,
#[serde(default)]
nick: Option<String>,
#[serde(default)]
roles: Vec<String>,
#[serde(default)]
joined_at: String,
#[serde(default)]
deaf: bool,
#[serde(default)]
mute: bool,
}
#[derive(Debug, Clone, Deserialize)]
struct DiscordMessage {
id: String,
content: String,
#[serde(default)]
author: Option<DiscordUser>,
#[serde(default)]
channel_id: String,
#[serde(default)]
guild_id: Option<String>,
#[serde(default)]
member: Option<DiscordMemberPayload>,
#[serde(default)]
mention_everyone: bool,
#[serde(default)]
mentions: Vec<DiscordUser>,
#[serde(default)]
referenced_message: Option<Box<DiscordMessage>>,
#[serde(default)]
edited_timestamp: Option<String>,
#[serde(default)]
webhook_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct DiscordMemberPayload {
#[serde(default)]
nick: Option<String>,
#[serde(default)]
roles: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct ReadyData {
#[serde(default)]
user: DiscordUser,
#[serde(default)]
session_id: String,
#[serde(default)]
guilds: Vec<DiscordGuild>,
#[serde(default, rename = "resume_gateway_url")]
resume_gateway_url: String,
}
// ─── Runtime state ────────────────────────────────────────────────────
struct DiscordState {
/// REST client.
rest: reqwest::Client,
/// Auth header value ("Bot <token>" or just the token).
auth_header: String,
/// Config reference (api_base, session_id, sequence).
config: DiscordConfig,
/// Resolved user (set after READY).
self_user: Option<DiscordUser>,
/// Guild cache: guild_id → guild.
guilds: HashMap<String, DiscordGuild>,
/// Channel cache: channel_id → channel.
channels: HashMap<String, DiscordChannel>,
/// User cache: user_id → display name.
users: HashMap<String, String>,
/// Gateway URL (from GET /gateway/bot or resume_gateway_url).
gateway_url: String,
/// Last received sequence number.
seq: Option<u64>,
/// Session ID (from READY event).
session_id: Option<String>,
/// Heartbeat interval (ms), from HELLO.
heartbeat_interval: u64,
/// Whether we've received the first HEARTBEAT_ACK.
heartbeat_acked: bool,
}
impl DiscordState {
fn new(config: DiscordConfig) -> Self {
let auth_header = if config.bot_token.starts_with("Bot ") || config.bot_token.starts_with("bot ") {
config.bot_token.clone()
} else {
format!("Bot {}", config.bot_token)
};
let rest = reqwest::Client::builder()
.default_headers({
let mut h = reqwest::header::HeaderMap::new();
h.insert("Authorization", reqwest::header::HeaderValue::from_str(&auth_header)
.unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("")));
h.insert("User-Agent", reqwest::header::HeaderValue::from_static("nirc-rs (https://git.dcos.net/dcosnet/nirc-rs, 0.9.0)"));
h
})
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
rest,
auth_header,
self_user: None,
guilds: HashMap::new(),
channels: HashMap::new(),
users: HashMap::new(),
gateway_url: String::new(),
seq: config.sequence,
session_id: config.session_id.clone(),
heartbeat_interval: 41250,
heartbeat_acked: true,
config,
}
}
/// Get the display name for a user ID.
fn display_name(&self, user_id: &str) -> String {
self.users.get(user_id).cloned().unwrap_or_else(|| user_id.to_owned())
}
}
// ─── REST helpers ─────────────────────────────────────────────────────
async fn get_gateway_url(rest: &reqwest::Client, api_base: &str) -> anyhow::Result<String> {
let url = format!("{}/gateway/bot", api_base.trim_end_matches('/'));
debug!(%url, "Fetching Discord gateway URL");
let resp: serde_json::Value = rest.get(&url).send().await?.json().await?;
let ws_url = resp["url"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'url' in gateway response"))?;
// Discord returns wss://gateway.discord.gg — append ?v=10&encoding=json
let sep = if ws_url.contains('?') { "&" } else { "?" };
Ok(format!("{}{}v=10&encoding=json", ws_url, sep))
}
async fn rest_send_message(
state: &DiscordState, channel_id: &str, content: &str,
) -> anyhow::Result<()> {
let url = format!("{}/channels/{}/messages", state.config.api_base.trim_end_matches('/'), channel_id);
let body = serde_json::json!({ "content": content });
state.rest.post(&url).json(&body).send().await?;
Ok(())
}
async fn rest_edit_message(
state: &DiscordState, channel_id: &str, message_id: &str, content: &str,
) -> anyhow::Result<()> {
let url = format!("{}/channels/{}/messages/{}", state.config.api_base.trim_end_matches('/'), channel_id, message_id);
let body = serde_json::json!({ "content": content });
state.rest.patch(&url).json(&body).send().await?;
Ok(())
}
async fn rest_delete_message(
state: &DiscordState, channel_id: &str, message_id: &str,
) -> anyhow::Result<()> {
let url = format!("{}/channels/{}/messages/{}", state.config.api_base.trim_end_matches('/'), channel_id, message_id);
state.rest.delete(&url).send().await?;
Ok(())
}
async fn rest_add_reaction(
state: &DiscordState, channel_id: &str, message_id: &str, emoji: &str,
) -> anyhow::Result<()> {
let url = format!(
"{}/channels/{}/messages/{}/reactions/{}/@me",
state.config.api_base.trim_end_matches('/'), channel_id, message_id,
urlencoding(emoji),
);
state.rest.put(&url).send().await?;
Ok(())
}
async fn rest_remove_reaction(
state: &DiscordState, channel_id: &str, message_id: &str, emoji: &str,
) -> anyhow::Result<()> {
let url = format!(
"{}/channels/{}/messages/{}/reactions/{}/@me",
state.config.api_base.trim_end_matches('/'), channel_id, message_id,
urlencoding(emoji),
);
state.rest.delete(&url).send().await?;
Ok(())
}
async fn rest_join_guild(
state: &DiscordState, invite_code: &str,
) -> anyhow::Result<()> {
let url = format!("{}/invites/{}", state.config.api_base.trim_end_matches('/'), invite_code);
let body = serde_json::json!({});
state.rest.post(&url).json(&body).send().await?;
Ok(())
}
async fn rest_leave_guild(
state: &DiscordState, guild_id: &str,
) -> anyhow::Result<()> {
let url = format!("{}/users/@me/guilds/{}", state.config.api_base.trim_end_matches('/'), guild_id);
state.rest.delete(&url).send().await?;
Ok(())
}
async fn rest_list_members(
state: &DiscordState, guild_id: &str, tx: &mpsc::Sender<ChatMessage>,
) -> anyhow::Result<()> {
let url = format!(
"{}/guilds/{}/members?limit=100",
state.config.api_base.trim_end_matches('/'), guild_id,
);
let resp: Vec<DiscordMember> = state.rest.get(&url).send().await?.json().await?;
let guild_name = state.guilds.get(guild_id)
.map(|g| g.name.as_str())
.unwrap_or(guild_id);
if resp.is_empty() {
let _ = tx.send(ChatMessage::notice(ProtocolType::Discord, guild_name, "No members found.")).await;
} else {
let mut lines = Vec::new();
for m in &resp {
if let Some(u) = &m.user {
let name = m.nick.as_deref().unwrap_or(&u.username);
let bot_tag = if u.bot { " [BOT]" } else { "" };
lines.push(format!(" {}{}", name, bot_tag));
}
}
let body = format!("Members of {} ({}):\n{}", guild_name, resp.len(), lines.join("\n"));
let _ = tx.send(ChatMessage::notice(ProtocolType::Discord, guild_name, &body)).await;
}
Ok(())
}
async fn rest_list_servers(
state: &DiscordState, tx: &mpsc::Sender<ChatMessage>,
) -> anyhow::Result<()> {
let url = format!("{}/users/@me/guilds", state.config.api_base.trim_end_matches('/'));
let resp: Vec<serde_json::Value> = state.rest.get(&url).send().await?.json().await?;
if resp.is_empty() {
let _ = tx.send(ChatMessage::notice(ProtocolType::Discord, "Status", "No guilds.")).await;
} else {
let mut lines = Vec::new();
for g in &resp {
let name = g["name"].as_str().unwrap_or("?");
let gid = g["id"].as_str().unwrap_or("?");
lines.push(format!(" {} ({})", name, gid));
}
let body = format!("Guilds ({}):\n{}", resp.len(), lines.join("\n"));
let _ = tx.send(ChatMessage::notice(ProtocolType::Discord, "Status", &body)).await;
}
Ok(())
}
/// Minimal URL-encoding for emoji (replaces non-alphanumeric with %XX).
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if ch.is_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else {
for byte in ch.encode_utf8(&mut [0u8; 4]).as_bytes() {
out.push_str(&format!("%{:02X}", byte));
}
}
}
out
}
// ─── Event dispatch ───────────────────────────────────────────────────
async fn handle_dispatch(
state: &mut DiscordState,
event: &str,
data: &serde_json::Value,
tx: &mpsc::Sender<ChatMessage>,
) {
match event {
"READY" => {
let ready: ReadyData = match serde_json::from_value(data.clone()) {
Ok(r) => r,
Err(e) => { warn!(%e, "Failed to parse READY"); return; }
};
state.session_id = Some(ready.session_id.clone());
state.self_user = Some(ready.user.clone());
state.users.insert(ready.user.id.clone(), ready.user.username.clone());
if !ready.resume_gateway_url.is_empty() {
state.gateway_url = ready.resume_gateway_url.clone();
}
// Cache guilds and channels.
for guild in &ready.guilds {
state.guilds.insert(guild.id.clone(), guild.clone());
for ch in &guild.channels {
state.channels.insert(ch.id.clone(), ch.clone());
}
}
let username = &ready.user.username;
let guild_count = ready.guilds.len();
info!(%username, guild_count, "Discord READY");
let _ = tx.send(ChatMessage::notice(
ProtocolType::Discord, "Status",
&format!("Connected as {} in {} guild(s)", username, guild_count),
)).await;
// Emit token persistence notice (intercepted by main.rs).
let _ = tx.send(ChatMessage::notice(
ProtocolType::Discord, "Status",
&format!("[discord-session] session_id={} user_id={}", ready.session_id, ready.user.id),
)).await;
}
"GUILD_CREATE" => {
let guild: DiscordGuild = match serde_json::from_value(data.clone()) {
Ok(g) => g,
Err(e) => { warn!(%e, "Failed to parse GUILD_CREATE"); return; }
};
let guild_name = guild.name.clone();
let channel_count = guild.channels.len();
state.guilds.insert(guild.id.clone(), guild.clone());
for ch in &guild.channels {
state.channels.insert(ch.id.clone(), ch.clone());
}
info!(%guild_name, channel_count, "Guild available");
let _ = tx.send(ChatMessage::notice(
ProtocolType::Discord, &guild_name,
&format!("Guild available ({} channels)", channel_count),
)).await;
}
"MESSAGE_CREATE" => {
let msg: DiscordMessage = match serde_json::from_value(data.clone()) {
Ok(m) => m,
Err(e) => { warn!(%e, "Failed to parse MESSAGE_CREATE"); return; }
};
let is_own = state.self_user.as_ref()
.map(|u| u.id == msg.author.as_ref().map(|a| a.id.clone()).unwrap_or_default())
.unwrap_or(false);
if is_own { return; } // Don't echo own messages.
let author = msg.author.as_ref()
.map(|a| state.display_name(&a.id))
.unwrap_or_else(|| "Unknown".into());
let source = msg.guild_id.as_deref()
.or_else(|| state.channels.get(&msg.channel_id).and_then(|c| c.guild_id.as_deref()))
.unwrap_or(&msg.channel_id);
// Use channel name if available, otherwise use guild or channel ID.
let display_source = state.channels.get(&msg.channel_id)
.and_then(|c| c.name.clone())
.unwrap_or_else(|| source.to_owned());
let content = msg.content.clone();
if content.is_empty() { return; } // Skip empty/embed-only messages.
let is_private = is_dm_channel(&msg.channel_id, state);
let kind = if is_private { MessageKind::Private } else { MessageKind::Text };
let chat_msg = ChatMessage {
id: msg.id,
protocol: ProtocolType::Discord,
kind,
source: display_source,
sender: author,
body: content,
timestamp: chrono::Utc::now(),
is_own,
remote_ts: true,
};
let _ = tx.send(chat_msg).await;
}
"MESSAGE_UPDATE" => {
let msg: DiscordMessage = match serde_json::from_value(data.clone()) {
Ok(m) => m,
Err(e) => { warn!(%e, "Failed to parse MESSAGE_UPDATE"); return; }
};
if msg.edited_timestamp.is_none() { return; }
let author = msg.author.as_ref()
.map(|a| state.display_name(&a.id))
.unwrap_or_else(|| "Unknown".into());
let display_source = state.channels.get(&msg.channel_id)
.and_then(|c| c.name.clone())
.unwrap_or_else(|| msg.channel_id.clone());
let body = format!("{} (edited)", msg.content);
let _ = tx.send(ChatMessage::notice(
ProtocolType::Discord, &display_source,
&format!("<{}> {}", author, body),
)).await;
}
"MESSAGE_DELETE" => {
let channel_id = data["channel_id"].as_str().unwrap_or("");
let msg_id = data["id"].as_str().unwrap_or("");
let display_source = state.channels.get(channel_id)
.and_then(|c| c.name.clone())
.unwrap_or_else(|| channel_id.to_owned());
let _ = tx.send(ChatMessage::notice(
ProtocolType::Discord, &display_source,
&format!("Message {} deleted", msg_id),
)).await;
}
"GUILD_DELETE" => {
let guild_id = data["id"].as_str().unwrap_or("");
let name = state.guilds.get(guild_id)
.map(|g| g.name.clone())
.unwrap_or_else(|| guild_id.to_owned());
state.guilds.remove(guild_id);
let _ = tx.send(ChatMessage::notice(
ProtocolType::Discord, "Status",
&format!("Removed from guild: {}", name),
)).await;
}
"CHANNEL_CREATE" => {
let ch: DiscordChannel = match serde_json::from_value(data.clone()) {
Ok(c) => c,
Err(e) => { warn!(%e, "Failed to parse CHANNEL_CREATE"); return; }
};
let ch_name = ch.name.clone().unwrap_or_default();
state.channels.insert(ch.id.clone(), ch);
debug!(?ch_name, "Channel created");
}
"TYPING_START" => {
let user_id = data["user_id"].as_str().unwrap_or("");
let channel_id = data["channel_id"].as_str().unwrap_or("");
let display_source = state.channels.get(channel_id)
.and_then(|c| c.name.clone())
.unwrap_or_else(|| channel_id.to_owned());
let name = state.display_name(user_id);
let _ = tx.send(ChatMessage::notice(
ProtocolType::Discord, &display_source,
&format!("{} is typing...", name),
)).await;
}
"PRESENCE_UPDATE" => {
if let Some(user) = data.get("user") {
let uid = user["id"].as_str().unwrap_or("");
let uname = user["username"].as_str();
if let Some(name) = uname {
state.users.insert(uid.to_owned(), name.to_owned());
}
}
}
_ => {
debug!(event, "Unhandled Discord dispatch event");
}
}
}
/// Check if a channel is a DM or group DM.
fn is_dm_channel(channel_id: &str, state: &DiscordState) -> bool {
state.channels.get(channel_id)
.map(|c| c.channel_type == 1 || c.channel_type == 3)
.unwrap_or(false)
}
// ─── Main entry point ─────────────────────────────────────────────────
/// Run the Discord client event loop.
///
/// Connects to the Discord Gateway via WebSocket, authenticates with a bot
/// token, handles heartbeat/identify/resume, and dispatches incoming events
/// to the TUI via the `tx` channel.
pub async fn run_discord(
config: DiscordConfig,
mut cmd_rx: mpsc::Receiver<DiscordCommand>,
) -> anyhow::Result<()> {
let mut state = DiscordState::new(config.clone());
// Fetch gateway URL.
state.gateway_url = get_gateway_url(&state.rest, &state.config.api_base).await?;
info!(url = %state.gateway_url, "Discord gateway URL obtained");
// Connect WebSocket.
// tokio-tungstenite 0.24 returns `(WebSocket, Response)`; we keep the
// response discarded and split the stream so we can read (StreamExt::next)
// and write (SinkExt::send) concurrently in the select! below.
let (ws_stream, _response) = tokio_tungstenite::connect_async(&state.gateway_url).await?;
info!("Discord WebSocket connected");
let (mut ws_write, mut ws_read) = ws_stream.split();
// Send a session persistence notice early so main.rs can intercept.
if let (Some(sid), Some(uid)) = (&state.session_id, state.self_user.as_ref().map(|u| &u.id)) {
let _ = state.config.tx.send(ChatMessage::notice(
ProtocolType::Discord, "Status",
&format!("[discord-session] session_id={} user_id={}", sid, uid),
)).await;
}
let tx = state.config.tx.clone();
let mut heartbeat_timer = tokio::time::interval(std::time::Duration::from_millis(state.heartbeat_interval));
loop {
tokio::select! {
// ── Incoming WebSocket frames ──────────────────────────
msg = ws_read.next() => {
match msg {
Some(Ok(frame)) => {
let text = match frame.into_text() {
Ok(t) => t,
Err(_) => continue,
};
let payload: GatewayPayload = match serde_json::from_str(&text) {
Ok(p) => p,
Err(e) => { warn!(%e, "Failed to parse gateway payload"); continue; }
};
// Update sequence number.
if let Some(s) = payload.s {
state.seq = Some(s);
}
match payload.op {
OP_HELLO => {
if let Some(d) = &payload.d {
state.heartbeat_interval = d["heartbeat_interval"].as_u64()
.unwrap_or(41250);
heartbeat_timer = tokio::time::interval(
std::time::Duration::from_millis(state.heartbeat_interval)
);
info!(interval_ms = state.heartbeat_interval, "Discord HELLO");
// Send first heartbeat immediately.
let hb = GatewayPayload {
op: OP_HEARTBEAT,
d: state.seq.map(|s| serde_json::json!(s)),
s: None, t: None,
};
if let Ok(json) = serde_json::to_string(&hb) {
use futures::SinkExt;
let _ = ws_write.send(tokio_tungstenite::tungstenite::Message::Text(json)).await;
state.heartbeat_acked = false;
}
}
}
OP_DISPATCH => {
if let (Some(event), Some(data)) = (&payload.t, &payload.d) {
handle_dispatch(&mut state, event, data, &tx).await;
}
}
OP_HEARTBEAT_ACK => {
state.heartbeat_acked = true;
debug!("Discord HEARTBEAT_ACK");
}
OP_RECONNECT => {
info!("Discord RECONNECT requested");
let _ = tx.send(ChatMessage::notice(
ProtocolType::Discord, "Status",
"Reconnecting...",
)).await;
break;
}
OP_INVALID_SESSION => {
let resumable = payload.d.as_ref()
.and_then(|d| d.as_bool())
.unwrap_or(false);
warn!(resumable, "Discord INVALID_SESSION");
if !resumable {
state.session_id = None;
state.seq = None;
}
break;
}
_ => {
debug!(op = payload.op, "Unhandled gateway opcode");
}
}
}
Some(Err(e)) => {
error!(%e, "Discord WebSocket read error");
break;
}
None => {
info!("Discord WebSocket closed");
break;
}
}
}
// ── Heartbeat timer ────────────────────────────────────
_ = heartbeat_timer.tick() => {
if !state.heartbeat_acked {
warn!("Discord heartbeat not ACKed — reconnecting");
break;
}
let hb = GatewayPayload {
op: OP_HEARTBEAT,
d: state.seq.map(|s| serde_json::json!(s)),
s: None, t: None,
};
if let Ok(json) = serde_json::to_string(&hb) {
use futures::SinkExt;
let _ = ws_write.send(tokio_tungstenite::tungstenite::Message::Text(json)).await;
state.heartbeat_acked = false;
debug!("Discord HEARTBEAT sent");
}
}
// ── Commands from dispatcher ───────────────────────────
cmd = cmd_rx.recv() => {
match cmd {
Some(DiscordCommand::Msg { channel_id, body }) => {
if let Err(e) = rest_send_message(&state, &channel_id, &body).await {
let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await;
}
}
Some(DiscordCommand::Emote { channel_id, body }) => {
// Discord has no native /me; send as *italic text*.
let emote_body = format!("*{}*", body);
if let Err(e) = rest_send_message(&state, &channel_id, &emote_body).await {
let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await;
}
}
Some(DiscordCommand::EditMessage { channel_id, message_id, new_body }) => {
if let Err(e) = rest_edit_message(&state, &channel_id, &message_id, &new_body).await {
let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await;
}
}
Some(DiscordCommand::DeleteMessage { channel_id, message_id }) => {
if let Err(e) = rest_delete_message(&state, &channel_id, &message_id).await {
let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await;
}
}
Some(DiscordCommand::React { channel_id, message_id, emoji }) => {
if let Err(e) = rest_add_reaction(&state, &channel_id, &message_id, &emoji).await {
let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await;
}
}
Some(DiscordCommand::RemoveReact { channel_id, message_id, emoji }) => {
if let Err(e) = rest_remove_reaction(&state, &channel_id, &message_id, &emoji).await {
let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &channel_id, &e.to_string())).await;
}
}
Some(DiscordCommand::JoinGuild { invite_code }) => {
if let Err(e) = rest_join_guild(&state, &invite_code).await {
let _ = tx.send(ChatMessage::error(ProtocolType::Discord, "Status", &e.to_string())).await;
} else {
let _ = tx.send(ChatMessage::notice(
ProtocolType::Discord, "Status",
&format!("Accepted invite: {}", invite_code),
)).await;
}
}
Some(DiscordCommand::LeaveGuild { guild_id }) => {
if let Err(e) = rest_leave_guild(&state, &guild_id).await {
let _ = tx.send(ChatMessage::error(ProtocolType::Discord, "Status", &e.to_string())).await;
} else {
let name = state.guilds.get(&guild_id)
.map(|g| g.name.clone())
.unwrap_or_else(|| guild_id.clone());
let _ = tx.send(ChatMessage::notice(
ProtocolType::Discord, "Status",
&format!("Left guild: {}", name),
)).await;
state.guilds.remove(&guild_id);
}
}
Some(DiscordCommand::Members { guild_id }) => {
if let Err(e) = rest_list_members(&state, &guild_id, &tx).await {
let _ = tx.send(ChatMessage::error(ProtocolType::Discord, &guild_id, &e.to_string())).await;
}
}
Some(DiscordCommand::ListServers) => {
if let Err(e) = rest_list_servers(&state, &tx).await {
let _ = tx.send(ChatMessage::error(ProtocolType::Discord, "Status", &e.to_string())).await;
}
}
Some(DiscordCommand::Quit) | None => {
info!("Discord quitting");
// Send close frame.
use futures::SinkExt;
let _ = ws_write.close().await;
break;
}
}
}
}
}
Ok(())
}
// ─── Tests ────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urlencoding_basic() {
assert_eq!(urlencoding("hello"), "hello");
assert_eq!(urlencoding("🎉"), "%F0%9F%8E%89");
assert_eq!(urlencoding("a b"), "a%20b");
assert_eq!(urlencoding("test_123"), "test_123");
}
#[test]
fn gateway_payload_serialize() {
let p = GatewayPayload {
op: OP_HEARTBEAT,
d: Some(serde_json::json!(42)),
s: None,
t: None,
};
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"op\":1"));
assert!(json.contains("\"d\":42"));
}
#[test]
fn identify_serialize() {
let id = Identify {
token: "test_token".into(),
properties: IdentifyProperties {
os: "Linux",
browser: "nirc-rs",
device: "nirc-rs",
},
session_id: Some("sess123".into()),
seq: Some(99),
};
let json = serde_json::to_string(&id).unwrap();
assert!(json.contains("\"token\":\"test_token\""));
assert!(json.contains("\"session_id\":\"sess123\""));
assert!(json.contains("\"seq\":99"));
}
#[test]
fn discord_state_new() {
let config = DiscordConfig {
api_base: "https://discord.com/api/v10".into(),
bot_token: "Bot test123".into(),
session_id: None,
sequence: None,
tx: tokio::sync::mpsc::channel(1).0,
};
let state = DiscordState::new(config);
assert_eq!(state.auth_header, "Bot test123");
assert!(state.guilds.is_empty());
assert!(state.channels.is_empty());
}
#[test]
fn discord_state_new_auto_prefix() {
let config = DiscordConfig {
api_base: "https://discord.com/api/v10".into(),
bot_token: "test456".into(), // No "Bot " prefix
session_id: None,
sequence: None,
tx: tokio::sync::mpsc::channel(1).0,
};
let state = DiscordState::new(config);
assert_eq!(state.auth_header, "Bot test456");
}
#[test]
fn display_name_cached() {
let config = DiscordConfig {
api_base: "https://discord.com/api/v10".into(),
bot_token: "Bot t".into(),
session_id: None,
sequence: None,
tx: tokio::sync::mpsc::channel(1).0,
};
let mut state = DiscordState::new(config);
state.users.insert("123".into(), "Alice".into());
assert_eq!(state.display_name("123"), "Alice");
assert_eq!(state.display_name("999"), "999"); // Fallback to ID
}
#[test]
fn is_dm_channel_test() {
let config = DiscordConfig {
api_base: "https://discord.com/api/v10".into(),
bot_token: "Bot t".into(),
session_id: None,
sequence: None,
tx: tokio::sync::mpsc::channel(1).0,
};
let mut state = DiscordState::new(config);
let mut dm_ch = DiscordChannel {
id: "ch1".into(),
name: None,
channel_type: 1, // DM
guild_id: None,
recipient_ids: vec![],
last_message_id: None,
nsfw: false,
topic: None,
};
state.channels.insert("ch1".into(), dm_ch.clone());
assert!(is_dm_channel("ch1", &state));
dm_ch.channel_type = 0; // Guild text
state.channels.insert("ch2".into(), dm_ch);
assert!(!is_dm_channel("ch2", &state));
}
}