974 lines
38 KiB
Rust
Executable File
974 lines
38 KiB
Rust
Executable File
//! 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 0–11)
|
||
//! - 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));
|
||
}
|
||
} |