Files
optimclaw/channels-src/discord/src/lib.rs
T

2255 lines
72 KiB
Rust

//! Discord Gateway/Webhook channel for OptimClaw.
//!
//! This WASM component implements the channel interface for handling Discord
//! interactions via webhooks and sending messages back to Discord.
//!
//! # Features
//!
//! - URL verification for Discord interactions
//! - Slash command handling
//! - Message event parsing (@mentions, DMs)
//! - Thread support for conversations
//! - Response posting via Discord Web API
//! - Markdown attachment fallback for oversized replies
//!
//! # Security
//!
//! - Signature validation is handled by the host (webhook secrets)
//! - Bot token is injected by host during HTTP requests
//! - WASM never sees raw credentials
wit_bindgen::generate!({
world: "sandboxed-channel",
path: "../../wit/channel.wit",
});
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, PollConfig, StatusType, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage};
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
/// Discord interaction wrapper.
#[derive(Debug, Deserialize)]
struct DiscordInteraction {
/// Interaction type (1=Ping, 2=ApplicationCommand, 3=MessageComponent)
#[serde(rename = "type")]
interaction_type: u8,
/// Interaction ID
id: String,
/// Application ID
application_id: String,
/// Guild ID (if in server)
#[allow(dead_code)] // Part of API payload, currently unused
guild_id: Option<String>,
/// Channel ID
channel_id: Option<String>,
/// Member info (if in server)
member: Option<DiscordMember>,
/// User info (if DM)
user: Option<DiscordUser>,
/// Command data (for slash commands)
data: Option<DiscordCommandData>,
/// Message (for component interactions)
message: Option<DiscordMessage>,
/// Token for responding
token: String,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordMember {
user: DiscordUser,
#[allow(dead_code)] // Part of API payload, currently unused
nick: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordUser {
id: String,
username: String,
global_name: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordCommandData {
#[allow(dead_code)] // Part of API payload, currently unused
id: String,
name: String,
options: Option<Vec<DiscordCommandOption>>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordCommandOption {
name: String,
value: serde_json::Value,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordMessage {
#[allow(dead_code)] // Part of API payload, currently unused
id: String,
content: String,
channel_id: String,
#[allow(dead_code)] // Part of API payload, currently unused
author: DiscordUser,
}
/// Deserialize a String that may be null or missing (backward compat with old Option<String> fields).
fn deserialize_nullable_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
}
/// Metadata stored with emitted messages for response routing.
#[derive(Debug, Serialize, Deserialize)]
struct DiscordMessageMetadata {
/// Discord channel ID
channel_id: String,
/// Interaction ID for followups
#[serde(default, deserialize_with = "deserialize_nullable_string")]
interaction_id: String,
/// Interaction token for responding
#[serde(default, deserialize_with = "deserialize_nullable_string")]
token: String,
/// Application ID
#[serde(default, deserialize_with = "deserialize_nullable_string")]
application_id: String,
/// Source message ID when handling mention-poll events.
#[serde(default)]
source_message_id: Option<String>,
/// Thread ID (for forum threads)
thread_id: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
enum DiscordResponseRoute {
InteractionWebhook(String),
ChannelMessage(String),
}
fn response_route_for_metadata(metadata: &DiscordMessageMetadata) -> DiscordResponseRoute {
if !metadata.application_id.is_empty() && !metadata.token.is_empty() {
DiscordResponseRoute::InteractionWebhook(format!(
"{DISCORD_API_BASE}/webhooks/{}/{}/messages/@original",
metadata.application_id, metadata.token
))
} else {
DiscordResponseRoute::ChannelMessage(format!(
"{DISCORD_API_BASE}/channels/{}/messages",
metadata.channel_id
))
}
}
fn typing_request_url_for_update(update: &StatusUpdate) -> Option<String> {
if update.status != StatusType::Thinking {
return None;
}
let metadata: DiscordMessageMetadata = serde_json::from_str(&update.metadata_json).ok()?;
if metadata.channel_id.is_empty() {
return None;
}
Some(format!(
"{DISCORD_API_BASE}/channels/{}/typing",
metadata.channel_id
))
}
const DISCORD_MESSAGE_CHAR_LIMIT: usize = 2000;
const DISCORD_MULTIPART_BOUNDARY: &str = "ironclaw-discord-response-boundary";
const DISCORD_ATTACHMENT_FILENAME: &str = "response.md";
const DISCORD_ATTACHMENT_NOTICE: &str = "Response too long for Discord; attached as response.md.";
static MULTIPART_BOUNDARY_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, PartialEq, Eq)]
struct DiscordHttpRequest {
headers_json: String,
body: Vec<u8>,
}
#[derive(Debug, PartialEq, Eq)]
enum DiscordReplyPlan {
Inline(DiscordHttpRequest),
Attachment {
upload: DiscordHttpRequest,
fallback: DiscordHttpRequest,
},
}
fn embeds_from_metadata_json(metadata_json: &str) -> Option<serde_json::Value> {
serde_json::from_str::<serde_json::Value>(metadata_json)
.ok()?
.get("embeds")
.cloned()
}
fn build_discord_json_request(
content: &str,
embeds: Option<&serde_json::Value>,
) -> Result<DiscordHttpRequest, String> {
let mut payload = serde_json::json!({
"content": content,
});
if let Some(embeds) = embeds {
payload["embeds"] = embeds.clone();
}
Ok(DiscordHttpRequest {
headers_json: serde_json::json!({
"Content-Type": "application/json"
})
.to_string(),
body: serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?,
})
}
fn build_discord_attachment_request(
content: &str,
embeds: Option<&serde_json::Value>,
) -> Result<DiscordHttpRequest, String> {
let boundary = next_multipart_boundary();
let mut payload = serde_json::json!({
"content": DISCORD_ATTACHMENT_NOTICE,
});
if let Some(embeds) = embeds {
payload["embeds"] = embeds.clone();
}
let payload_json =
serde_json::to_string(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
let mut body = Vec::new();
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"payload_json\"\r\nContent-Type: application/json\r\n\r\n{payload_json}\r\n",
boundary = boundary,
)
.as_bytes(),
);
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"files[0]\"; filename=\"{filename}\"\r\nContent-Type: text/markdown\r\n\r\n",
boundary = boundary,
filename = DISCORD_ATTACHMENT_FILENAME,
)
.as_bytes(),
);
body.extend_from_slice(content.as_bytes());
body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
Ok(DiscordHttpRequest {
headers_json: serde_json::json!({
"Content-Type": format!(
"multipart/form-data; boundary={}",
boundary
)
})
.to_string(),
body,
})
}
fn next_multipart_boundary() -> String {
let counter = MULTIPART_BOUNDARY_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
format!("{}-{:x}-{:x}", DISCORD_MULTIPART_BOUNDARY, nanos, counter)
}
fn build_discord_reply_plan(response: &AgentResponse) -> Result<DiscordReplyPlan, String> {
let embeds = embeds_from_metadata_json(&response.metadata_json);
if response.content.chars().count() <= DISCORD_MESSAGE_CHAR_LIMIT {
return build_discord_json_request(&response.content, embeds.as_ref())
.map(DiscordReplyPlan::Inline);
}
Ok(DiscordReplyPlan::Attachment {
upload: build_discord_attachment_request(&response.content, embeds.as_ref())?,
fallback: build_discord_json_request(
&truncate_message(&response.content),
embeds.as_ref(),
)?,
})
}
fn send_discord_request(
method: &str,
url: &str,
request: &DiscordHttpRequest,
) -> Result<(), String> {
match channel_host::http_request(
method,
url,
&request.headers_json,
Some(&request.body),
None,
) {
Ok(http_response) => {
if http_response.status >= 200 && http_response.status < 300 {
channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord");
Ok(())
} else {
let body_str = String::from_utf8_lossy(&http_response.body);
Err(format!(
"Discord API error: {} - {}",
http_response.status, body_str
))
}
}
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}
/// Workspace path for persisting owner_id across WASM callbacks.
const OWNER_ID_PATH: &str = "state/owner_id";
/// Workspace path for persisting polling_enabled flag.
const POLLING_ENABLED_PATH: &str = "state/polling_enabled";
/// Workspace path for persisting mention channel IDs (JSON array).
const MENTION_CHANNEL_IDS_PATH: &str = "state/mention_channel_ids";
/// Workspace path for persisting dm_policy across WASM callbacks.
const DM_POLICY_PATH: &str = "state/dm_policy";
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
const ALLOW_FROM_PATH: &str = "state/allow_from";
/// Workspace path for the current gateway text-frame batch prepared by the host runtime.
const GATEWAY_EVENT_QUEUE_PATH: &str = "state/gateway_event_queue_processing";
/// Workspace path for persisting the bot user id learned from READY dispatches.
const BOT_USER_ID_PATH: &str = "state/bot_user_id";
/// Channel name for pairing store (used by pairing host APIs).
const CHANNEL_NAME: &str = "discord";
#[derive(Debug, Deserialize)]
struct DiscordGatewayEvent {
op: u64,
#[serde(default)]
t: Option<String>,
#[serde(default)]
d: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct DiscordGatewayReady {
user: DiscordGatewayAuthor,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordGatewayAuthor {
id: String,
username: String,
global_name: Option<String>,
#[serde(default)]
bot: bool,
}
#[derive(Debug, Deserialize)]
struct DiscordGatewayMessageCreate {
channel_id: String,
#[serde(default)]
guild_id: Option<String>,
content: String,
author: DiscordGatewayAuthor,
}
/// A message returned by the Discord REST channel-messages endpoint.
#[derive(Debug, Deserialize)]
struct DiscordChannelMessage {
id: String,
content: String,
channel_id: String,
author: DiscordChannelAuthor,
#[serde(default)]
mentions: Vec<DiscordUser>,
#[serde(default)]
webhook_id: Option<String>,
}
/// Author sub-object for REST channel messages.
#[derive(Debug, Deserialize)]
struct DiscordChannelAuthor {
id: String,
username: String,
global_name: Option<String>,
#[serde(default)]
bot: bool,
}
#[derive(Debug, PartialEq, Eq)]
struct ParsedGatewayMessage {
user_id: String,
user_name: String,
channel_id: String,
content: String,
is_dm: bool,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct GatewayPollResult {
bot_user_id: Option<String>,
messages: Vec<ParsedGatewayMessage>,
}
fn parse_gateway_event_queue(
queue_json: &str,
known_bot_user_id: Option<&str>,
) -> GatewayPollResult {
let frames: Vec<String> = match serde_json::from_str(queue_json) {
Ok(v) => v,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to deserialize gateway event queue: {}", e),
);
return GatewayPollResult::default();
}
};
let mut result = GatewayPollResult::default();
let mut bot_user_id = known_bot_user_id.map(ToOwned::to_owned);
for frame in frames {
let event: DiscordGatewayEvent = match serde_json::from_str(&frame) {
Ok(value) => value,
Err(_) => continue,
};
if event.op != 0 {
continue;
}
match event.t.as_deref() {
Some("READY") => {
if let Ok(ready) = serde_json::from_value::<DiscordGatewayReady>(event.d) {
if !ready.user.id.is_empty() {
bot_user_id = Some(ready.user.id);
}
}
}
Some("MESSAGE_CREATE") => {
let message = match serde_json::from_value::<DiscordGatewayMessageCreate>(event.d) {
Ok(value) => value,
Err(_) => continue,
};
let active_bot_user_id = bot_user_id.as_deref().or(known_bot_user_id);
if message.author.bot
|| active_bot_user_id.is_some_and(|bot_id| message.author.id == bot_id)
{
continue;
}
let is_dm = message.guild_id.is_none();
let content =
match gateway_content_for_agent(&message.content, active_bot_user_id, is_dm) {
Some(value) => value,
None => continue,
};
result.messages.push(ParsedGatewayMessage {
user_id: message.author.id,
user_name: message
.author
.global_name
.unwrap_or(message.author.username),
channel_id: message.channel_id,
content,
is_dm,
});
}
_ => {}
}
}
result.bot_user_id = bot_user_id;
result
}
fn gateway_content_for_agent(
content: &str,
bot_user_id: Option<&str>,
is_dm: bool,
) -> Option<String> {
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
}
if is_dm {
return Some(trimmed.to_string());
}
let bot_user_id = bot_user_id?;
for mention in [
format!("<@{}>", bot_user_id),
format!("<@!{}>", bot_user_id),
] {
if let Some(stripped) = trimmed.strip_prefix(&mention) {
let cleaned = stripped.trim();
return if cleaned.is_empty() {
None
} else {
Some(cleaned.to_string())
};
}
}
None
}
fn default_poll_interval_ms() -> u32 {
30_000
}
/// Channel configuration from capabilities file.
#[derive(Debug, Deserialize)]
struct DiscordConfig {
#[serde(default)]
#[allow(dead_code)]
require_signature_verification: bool,
#[serde(default)]
owner_id: Option<String>,
#[serde(default)]
dm_policy: Option<String>,
#[serde(default)]
allow_from: Option<Vec<String>>,
#[serde(default)]
polling_enabled: bool,
#[serde(default = "default_poll_interval_ms")]
poll_interval_ms: u32,
#[serde(default)]
mention_channel_ids: Vec<String>,
}
struct DiscordChannel;
impl Guest for DiscordChannel {
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
let config: DiscordConfig = serde_json::from_str(&config_json)
.map_err(|e| format!("Failed to parse config: {}", e))?;
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
// Persist owner_id so subsequent callbacks can read it
if let Some(ref owner_id) = config.owner_id {
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
channel_host::log(
channel_host::LogLevel::Info,
&format!("Owner restriction enabled: user {}", owner_id),
);
} else {
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
}
// Persist dm_policy and allow_from for DM pairing
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
.unwrap_or_else(|_| "[]".to_string());
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
// Persist polling config
let _ = channel_host::workspace_write(
POLLING_ENABLED_PATH,
&config.polling_enabled.to_string(),
);
let mention_ids_json =
serde_json::to_string(&config.mention_channel_ids).unwrap_or_else(|_| "[]".to_string());
let _ = channel_host::workspace_write(MENTION_CHANNEL_IDS_PATH, &mention_ids_json);
Ok(ChannelConfig {
display_name: "Discord".to_string(),
http_endpoints: vec![HttpEndpointConfig {
path: "/webhook/discord".to_string(),
methods: vec!["POST".to_string()],
require_secret: true,
}],
poll: if config.polling_enabled {
Some(PollConfig {
interval_ms: config.poll_interval_ms.max(30_000),
enabled: true,
})
} else {
None
},
})
}
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
let body_str = match std::str::from_utf8(&req.body) {
Ok(s) => s,
Err(_) => {
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
}
};
let interaction: DiscordInteraction = match serde_json::from_str(body_str) {
Ok(i) => i,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to parse Discord interaction: {}", e),
);
return json_response(400, serde_json::json!({"error": "Invalid interaction"}));
}
};
match interaction.interaction_type {
// Ping - Discord verification
1 => {
channel_host::log(channel_host::LogLevel::Info, "Responding to Discord ping");
json_response(200, serde_json::json!({"type": 1}))
}
// Application Command (slash command)
2 => {
if handle_slash_command(&interaction) {
json_response(200, serde_json::json!({"type": 5}))
} else {
// Permission denied — ephemeral response
json_response(
200,
serde_json::json!({
"type": 4,
"data": {
"content": "You are not authorized to use this bot.",
"flags": 64
}
}),
)
}
}
// Message Component (buttons, selects)
3 => {
if let Some(ref message) = interaction.message {
handle_message_component(&interaction, message);
}
json_response(200, serde_json::json!({"type": 6}))
}
_ => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Unknown Discord interaction type: {}",
interaction.interaction_type
),
);
json_response(200, serde_json::json!({"type": 6}))
}
}
}
fn on_poll() {
// 1. Process Gateway event queue
let queue_json = channel_host::workspace_read(GATEWAY_EVENT_QUEUE_PATH).unwrap_or_default();
let has_gateway_events = !queue_json.trim().is_empty() && queue_json.trim() != "[]";
if has_gateway_events {
let known_bot_user_id = channel_host::workspace_read(BOT_USER_ID_PATH);
let parsed = parse_gateway_event_queue(&queue_json, known_bot_user_id.as_deref());
if let Err(error) = channel_host::workspace_write(GATEWAY_EVENT_QUEUE_PATH, "[]") {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to clear Discord gateway queue: {}", error),
);
}
if let Some(bot_user_id) = parsed.bot_user_id.as_deref() {
if let Err(error) = channel_host::workspace_write(BOT_USER_ID_PATH, bot_user_id) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist Discord bot user id: {}", error),
);
}
}
for message in parsed.messages {
if !check_sender_permission(
&message.user_id,
Some(&message.user_name),
message.is_dm,
PermissionSource::Gateway,
Some(&PairingReplyCtx {
channel_id: message.channel_id.clone(),
application_id: String::new(),
token: String::new(),
}),
) {
continue;
}
let metadata = DiscordMessageMetadata {
channel_id: message.channel_id,
interaction_id: String::new(),
token: String::new(),
application_id: String::new(),
source_message_id: None,
thread_id: None,
};
let metadata_json = match serde_json::to_string(&metadata) {
Ok(json) => json,
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to serialize gateway metadata: {}", error),
);
continue;
}
};
channel_host::emit_message(&EmittedMessage {
user_id: message.user_id,
user_name: Some(message.user_name),
content: message.content,
thread_id: None,
metadata_json,
attachments: vec![],
});
}
}
// 2. Run mention polling if configured
poll_for_mentions();
}
fn on_respond(response: AgentResponse) -> Result<(), String> {
let metadata: DiscordMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
// Mention-poll replies: include message_reference so Discord renders as a reply
if let Some(ref source_id) = metadata.source_message_id {
if let DiscordResponseRoute::ChannelMessage(ref url) =
response_route_for_metadata(&metadata)
{
let embeds = embeds_from_metadata_json(&response.metadata_json);
let content = if response.content.chars().count() > DISCORD_MESSAGE_CHAR_LIMIT {
truncate_message(&response.content)
} else {
response.content.clone()
};
let mut payload = serde_json::json!({
"content": content,
"message_reference": {
"message_id": source_id
},
"allowed_mentions": {
"replied_user": true
}
});
if let Some(ref e) = embeds {
payload["embeds"] = e.clone();
}
let headers = discord_auth_headers_json(true);
let body = serde_json::to_vec(&payload)
.map_err(|e| format!("Failed to serialize: {}", e))?;
return send_discord_request(
"POST",
url,
&DiscordHttpRequest {
headers_json: headers,
body,
},
);
}
}
let route = response_route_for_metadata(&metadata);
let plan = build_discord_reply_plan(&response)?;
let (method, url) = match &route {
DiscordResponseRoute::InteractionWebhook(url) => ("PATCH", url.as_str()),
DiscordResponseRoute::ChannelMessage(url) => ("POST", url.as_str()),
};
match plan {
DiscordReplyPlan::Inline(request) => send_discord_request(method, url, &request),
DiscordReplyPlan::Attachment { upload, fallback } => {
match send_discord_request(method, url, &upload) {
Ok(()) => Ok(()),
Err(upload_error) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Discord attachment upload failed, falling back to truncated text: {}",
upload_error
),
);
send_discord_request(method, url, &fallback).map_err(|fallback_error| {
format!(
"Discord attachment upload failed: {}; fallback also failed: {}",
upload_error, fallback_error
)
})
}
}
}
}
}
fn on_status(update: StatusUpdate) {
let Some(url) = typing_request_url_for_update(&update) else {
return;
};
let headers = serde_json::json!({
"Content-Type": "application/json"
});
match channel_host::http_request("POST", &url, &headers.to_string(), None, None) {
Ok(response) if (200..300).contains(&response.status) => {}
Ok(response) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Discord typing indicator failed with status {}",
response.status
),
);
}
Err(error) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Discord typing indicator request failed: {}", error),
);
}
}
}
fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> {
broadcast_dm(&user_id, &response.content)
}
fn on_shutdown() {
channel_host::log(
channel_host::LogLevel::Info,
"Discord channel shutting down",
);
}
}
/// Returns true if the message was emitted, false if permission denied.
fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
let user = interaction
.member
.as_ref()
.map(|m| &m.user)
.or(interaction.user.as_ref());
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
let user_name = user
.map(|u| {
u.global_name
.as_ref()
.filter(|s| !s.is_empty())
.unwrap_or(&u.username)
.clone()
})
.unwrap_or_default();
// DM if no guild member context (only direct user field set)
let is_dm = interaction.member.is_none();
// Permission check
if !check_sender_permission(
&user_id,
Some(&user_name),
is_dm,
PermissionSource::Webhook,
Some(&PairingReplyCtx {
channel_id: interaction.channel_id.clone().unwrap_or_default(),
application_id: interaction.application_id.clone(),
token: interaction.token.clone(),
}),
) {
return false;
}
let channel_id = interaction.channel_id.clone().unwrap_or_default();
let command_name = interaction
.data
.as_ref()
.map(|d| d.name.clone())
.unwrap_or_default();
let options = interaction.data.as_ref().and_then(|d| d.options.clone());
let content = if let Some(opts) = options {
let opt_str = opts
.iter()
.map(|o| format!("{}: {}", o.name, o.value))
.collect::<Vec<_>>()
.join(", ");
format!("/{} {}", command_name, opt_str)
} else {
format!("/{}", command_name)
};
let metadata = DiscordMessageMetadata {
channel_id: channel_id.clone(),
interaction_id: interaction.id.clone(),
token: interaction.token.clone(),
application_id: interaction.application_id.clone(),
source_message_id: None,
thread_id: None,
};
let metadata_json = match serde_json::to_string(&metadata) {
Ok(json) => json,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to serialize metadata: {}", e),
);
let url = format!(
"{DISCORD_API_BASE}/webhooks/{}/{}",
interaction.application_id, interaction.token
);
let payload = serde_json::json!({
"content": "❌ Internal Error: Failed to process command metadata.",
"flags": 64
});
let _ = channel_host::http_request(
"POST",
&url,
&serde_json::json!({"Content-Type": "application/json"}).to_string(),
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
None,
);
return true; // Error, but not a permission denial
}
};
channel_host::emit_message(&EmittedMessage {
user_id,
user_name: Some(user_name),
content,
thread_id: None,
metadata_json,
attachments: vec![],
});
true
}
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
let user = interaction
.member
.as_ref()
.map(|m| &m.user)
.or(interaction.user.as_ref());
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
let user_name = user
.map(|u| {
u.global_name
.as_ref()
.filter(|s| !s.is_empty())
.unwrap_or(&u.username)
.clone()
})
.unwrap_or_default();
let is_dm = interaction.member.is_none();
if !check_sender_permission(
&user_id,
Some(&user_name),
is_dm,
PermissionSource::Webhook,
None,
) {
return;
}
let channel_id = message.channel_id.clone();
let metadata = DiscordMessageMetadata {
channel_id: channel_id.clone(),
interaction_id: interaction.id.clone(),
token: interaction.token.clone(),
application_id: interaction.application_id.clone(),
source_message_id: None,
thread_id: None,
};
let metadata_json = match serde_json::to_string(&metadata) {
Ok(json) => json,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to serialize metadata: {}", e),
);
return; // Don't emit message if metadata can't be serialized
}
};
channel_host::emit_message(&EmittedMessage {
user_id,
user_name: Some(user_name),
content: format!("[Button clicked] {}", message.content),
thread_id: None,
metadata_json,
attachments: vec![],
});
}
// ============================================================================
// Permission & Pairing
// ============================================================================
/// Context needed to send a pairing reply via Discord webhook followup.
struct PairingReplyCtx {
channel_id: String,
application_id: String,
token: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionSource {
Webhook,
Gateway,
}
fn should_apply_dm_pairing(_source: PermissionSource, is_dm: bool) -> bool {
// All current permission sources (Webhook, Gateway) apply DM pairing equally.
// Kept as a function for future sources that may bypass pairing (e.g. internal).
is_dm
}
/// Check if a sender is permitted to interact with the bot.
/// Returns true if allowed, false if denied (pairing reply sent if applicable).
fn check_sender_permission(
user_id: &str,
username: Option<&str>,
is_dm: bool,
source: PermissionSource,
reply_ctx: Option<&PairingReplyCtx>,
) -> bool {
// 1. Owner check (highest priority, applies to all contexts)
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
if let Some(ref owner) = owner_id {
if user_id != owner {
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Dropping interaction from non-owner user {} (owner: {})",
user_id, owner
),
);
return false;
}
return true;
}
// 2. DM policy (only for DMs when no owner_id)
if !should_apply_dm_pairing(source, is_dm) {
return true;
}
let dm_policy =
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
if dm_policy == "open" {
return true;
}
// 3. Build merged allow list: config allow_from + pairing store
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
allowed.extend(store_allowed);
}
// 4. Check sender against allow list
let is_allowed = allowed.contains(&"*".to_string())
|| allowed.contains(&user_id.to_string())
|| username.is_some_and(|u| allowed.contains(&u.to_string()));
if is_allowed {
return true;
}
// 5. Not allowed — handle by policy
if dm_policy == "pairing" {
let meta = serde_json::json!({
"user_id": user_id,
"username": username,
})
.to_string();
match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) {
Ok(result) => {
channel_host::log(
channel_host::LogLevel::Info,
&format!("Pairing request for user {}: code {}", user_id, result.code),
);
if result.created {
if let Some(ctx) = reply_ctx {
let _ = send_pairing_reply(ctx, &result.code);
}
}
}
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Pairing upsert failed: {}", e),
);
}
}
}
false
}
fn pairing_reply_route(ctx: &PairingReplyCtx) -> DiscordResponseRoute {
if !ctx.application_id.is_empty() && !ctx.token.is_empty() {
DiscordResponseRoute::InteractionWebhook(format!(
"{DISCORD_API_BASE}/webhooks/{}/{}",
ctx.application_id, ctx.token
))
} else {
DiscordResponseRoute::ChannelMessage(format!(
"{DISCORD_API_BASE}/channels/{}/messages",
ctx.channel_id
))
}
}
/// Send a pairing code reply via webhook followup or channel message.
fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
let route = pairing_reply_route(ctx);
let mut payload = serde_json::json!({
"content": format!(
"To pair with this bot, run: `optimclaw pairing approve discord {}`",
code
)
});
if matches!(route, DiscordResponseRoute::InteractionWebhook(_)) {
payload["flags"] = serde_json::json!(64);
}
let payload_bytes =
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
let headers = serde_json::json!({"Content-Type": "application/json"});
let url = match &route {
DiscordResponseRoute::InteractionWebhook(url) => url,
DiscordResponseRoute::ChannelMessage(url) => url,
};
let result = channel_host::http_request(
"POST",
url,
&headers.to_string(),
Some(&payload_bytes),
None,
);
match result {
Ok(response) if response.status >= 200 && response.status < 300 => Ok(()),
Ok(response) => {
let body_str = String::from_utf8_lossy(&response.body);
Err(format!(
"Discord API error: {} - {}",
response.status, body_str
))
}
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}
// ============================================================================
// Mention Polling
// ============================================================================
/// Maximum number of processed message IDs to keep per channel for dedup.
const DEDUP_CAP: usize = 200;
/// Poll configured channels for new messages that mention the bot.
fn poll_for_mentions() {
let enabled = channel_host::workspace_read(POLLING_ENABLED_PATH)
.map(|v| v.trim() == "true")
.unwrap_or(false);
if !enabled {
return;
}
let bot_id = match get_or_fetch_bot_id() {
Some(id) => id,
None => {
channel_host::log(
channel_host::LogLevel::Warn,
"Mention polling: unable to determine bot user id",
);
return;
}
};
let channel_ids: Vec<String> = channel_host::workspace_read(MENTION_CHANNEL_IDS_PATH)
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
for channel_id in &channel_ids {
poll_channel_mentions(channel_id, &bot_id);
}
}
/// Read the bot user ID from workspace or fetch it from the Discord API.
fn get_or_fetch_bot_id() -> Option<String> {
if let Some(id) = channel_host::workspace_read(BOT_USER_ID_PATH).filter(|s| !s.is_empty()) {
return Some(id);
}
let headers = discord_auth_headers_json(false);
let resp = channel_host::http_request(
"GET",
"{DISCORD_API_BASE}/users/@me",
&headers,
None,
None,
)
.ok()?;
if resp.status < 200 || resp.status >= 300 {
return None;
}
let body: serde_json::Value = serde_json::from_slice(&resp.body).ok()?;
let id = body["id"].as_str()?.to_string();
let _ = channel_host::workspace_write(BOT_USER_ID_PATH, &id);
Some(id)
}
/// Poll a single channel for new mention messages.
fn poll_channel_mentions(channel_id: &str, bot_id: &str) {
let cursor_path = format!("state/mention_cursor/{}", channel_id);
let last_seen = channel_host::workspace_read(&cursor_path).unwrap_or_default();
let messages = if last_seen.is_empty() {
// First poll: initialise cursor without emitting any messages.
if let Some(latest_id) = fetch_latest_message_id(channel_id) {
let _ = channel_host::workspace_write(&cursor_path, &latest_id);
}
return;
} else {
match fetch_messages_after_cursor(channel_id, &last_seen) {
Some(msgs) => msgs,
None => return,
}
};
let mut processed_ids = load_recent_processed_ids(channel_id);
let mut new_cursor = last_seen.clone();
for msg in &messages {
if !is_new_message(&last_seen, &msg.id) {
continue;
}
if processed_ids.contains(&msg.id) {
continue;
}
if msg.author.bot || msg.author.id == bot_id {
remember_processed_id(&msg.id, &mut processed_ids);
continue;
}
if msg.webhook_id.is_some() {
remember_processed_id(&msg.id, &mut processed_ids);
continue;
}
if !message_mentions_bot(msg, bot_id) {
remember_processed_id(&msg.id, &mut processed_ids);
continue;
}
// Permission check (API-based poll uses Webhook source)
if !check_sender_permission(
&msg.author.id,
Some(&msg.author.username),
false,
PermissionSource::Webhook,
None,
) {
remember_processed_id(&msg.id, &mut processed_ids);
continue;
}
let content = strip_bot_mention(&msg.content, bot_id);
if content.is_empty() {
remember_processed_id(&msg.id, &mut processed_ids);
continue;
}
let user_name = msg
.author
.global_name
.clone()
.unwrap_or_else(|| msg.author.username.clone());
let metadata = DiscordMessageMetadata {
channel_id: msg.channel_id.clone(),
interaction_id: String::new(),
token: String::new(),
application_id: String::new(),
source_message_id: Some(msg.id.clone()),
thread_id: None,
};
let metadata_json = match serde_json::to_string(&metadata) {
Ok(json) => json,
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to serialize mention-poll metadata: {}", error),
);
continue;
}
};
channel_host::emit_message(&EmittedMessage {
user_id: msg.author.id.clone(),
user_name: Some(user_name),
content,
thread_id: None,
metadata_json,
attachments: vec![],
});
remember_processed_id(&msg.id, &mut processed_ids);
if compare_message_ids(&msg.id, &new_cursor) == std::cmp::Ordering::Greater {
new_cursor = msg.id.clone();
}
}
if new_cursor != last_seen {
let _ = channel_host::workspace_write(&cursor_path, &new_cursor);
}
save_recent_processed_ids(channel_id, &processed_ids);
}
/// Fetch the latest message ID in a channel (used for cursor initialisation).
fn fetch_latest_message_id(channel_id: &str) -> Option<String> {
let url = format!(
"{DISCORD_API_BASE}/channels/{}/messages?limit=1",
channel_id
);
let headers = discord_auth_headers_json(false);
let resp = channel_host::http_request("GET", &url, &headers, None, None).ok()?;
if resp.status < 200 || resp.status >= 300 {
return None;
}
let messages: Vec<serde_json::Value> = serde_json::from_slice(&resp.body).ok()?;
messages
.first()
.and_then(|m| m["id"].as_str().map(String::from))
}
/// Maximum number of pages to fetch when catching up on missed messages.
const MENTION_POLL_MAX_PAGES: usize = 5;
/// Fetch messages after `last_seen` using the `after` parameter, paginating up
/// to [`MENTION_POLL_MAX_PAGES`] pages of 100 messages each.
fn fetch_messages_after_cursor(
channel_id: &str,
last_seen: &str,
) -> Option<Vec<DiscordChannelMessage>> {
let headers = discord_auth_headers_json(false);
let mut all_messages: Vec<DiscordChannelMessage> = Vec::new();
let mut after = last_seen.to_string();
for _ in 0..MENTION_POLL_MAX_PAGES {
let url = format!(
"{DISCORD_API_BASE}/channels/{}/messages?after={}&limit=100",
channel_id, after
);
let resp = channel_host::http_request("GET", &url, &headers, None, None).ok()?;
if resp.status < 200 || resp.status >= 300 {
let body_str = String::from_utf8_lossy(&resp.body);
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Mention poll: failed to fetch messages for channel {}: {} - {}",
channel_id, resp.status, body_str
),
);
return None;
}
let page: Vec<DiscordChannelMessage> = serde_json::from_slice(&resp.body).ok()?;
let page_len = page.len();
if page.is_empty() {
break;
}
// Discord returns newest-first; find the max ID for the next page cursor
let page_max_id = page
.iter()
.map(|m| m.id.as_str())
.max_by(|a, b| compare_message_ids(a, b))
.map(str::to_string);
all_messages.extend(page);
if page_len < 100 {
break;
}
match page_max_id {
Some(max_id) if max_id != after => after = max_id,
_ => break,
}
}
Some(all_messages)
}
/// Compare two Discord snowflake IDs. Falls back to lexical comparison.
fn compare_message_ids(a: &str, b: &str) -> std::cmp::Ordering {
match (a.parse::<u64>(), b.parse::<u64>()) {
(Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num),
_ => a.cmp(b),
}
}
fn dedup_ids_path(channel_id: &str) -> String {
format!("state/mention_dedup/{}", channel_id)
}
fn load_recent_processed_ids(channel_id: &str) -> Vec<String> {
channel_host::workspace_read(&dedup_ids_path(channel_id))
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_recent_processed_ids(channel_id: &str, ids: &[String]) {
let json = serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string());
let _ = channel_host::workspace_write(&dedup_ids_path(channel_id), &json);
}
fn remember_processed_id(msg_id: &str, ids: &mut Vec<String>) {
if ids.contains(&msg_id.to_string()) {
return;
}
ids.push(msg_id.to_string());
if ids.len() > DEDUP_CAP {
let excess = ids.len() - DEDUP_CAP;
ids.drain(0..excess);
}
}
/// Returns true when `current` is strictly newer than `last_seen`.
fn is_new_message(last_seen: &str, current: &str) -> bool {
compare_message_ids(current, last_seen) == std::cmp::Ordering::Greater
}
/// Returns true if the message mentions the bot (by mention objects or content).
fn message_mentions_bot(msg: &DiscordChannelMessage, bot_id: &str) -> bool {
if msg.mentions.iter().any(|u| u.id == bot_id) {
return true;
}
let mention = format!("<@{}>", bot_id);
let mention_nick = format!("<@!{}>", bot_id);
msg.content.contains(&mention) || msg.content.contains(&mention_nick)
}
/// Strip the bot mention prefix from content.
fn strip_bot_mention(content: &str, bot_id: &str) -> String {
let trimmed = content.trim();
for mention in [format!("<@{}>", bot_id), format!("<@!{}>", bot_id)] {
if let Some(rest) = trimmed.strip_prefix(&mention) {
return rest.trim().to_string();
}
}
trimmed.to_string()
}
/// Build JSON headers string with Discord bot authorization.
/// When `include_content_type` is true, includes `Content-Type: application/json`.
fn discord_auth_headers_json(include_content_type: bool) -> String {
if include_content_type {
serde_json::json!({
"Content-Type": "application/json"
})
.to_string()
} else {
serde_json::json!({}).to_string()
}
}
/// Send a DM to a Discord user by opening (or reusing) a DM channel.
fn broadcast_dm(user_id: &str, content: &str) -> Result<(), String> {
// Validate user_id is a plausible Discord snowflake (numeric, 17-20 digits)
// to avoid injecting arbitrary strings into API URLs.
if user_id.is_empty()
|| !user_id.chars().all(|c| c.is_ascii_digit())
|| user_id.len() < 17
|| user_id.len() > 20
{
return Err(format!("Invalid Discord user ID: '{}'", user_id));
}
// Step 1: Open (or reuse) a DM channel with the target user.
let create_dm_payload = serde_json::json!({ "recipient_id": user_id });
let create_dm_bytes = serde_json::to_vec(&create_dm_payload)
.map_err(|e| format!("Failed to serialize DM channel request: {}", e))?;
let dm_response = channel_host::http_request(
"POST",
&format!("{DISCORD_API_BASE}/users/@me/channels"),
&discord_auth_headers_json(true),
Some(&create_dm_bytes),
Some(10_000),
)
.map_err(|e| format!("Failed to create DM channel: {}", e))?;
if !(200..300).contains(&dm_response.status) {
let body = String::from_utf8_lossy(&dm_response.body);
return Err(format!(
"Discord create-DM failed: {} - {}",
dm_response.status, body
));
}
#[derive(Deserialize)]
struct DmChannelResponse {
id: String,
}
let dm_channel: DmChannelResponse = serde_json::from_slice(&dm_response.body)
.map_err(|e| format!("Failed to parse DM channel response: {}", e))?;
// Step 2: Send the message to the DM channel.
let truncated = truncate_message(content);
let payload = serde_json::json!({ "content": truncated });
let body =
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
send_discord_request(
"POST",
&format!("{DISCORD_API_BASE}/channels/{}/messages", dm_channel.id),
&DiscordHttpRequest {
headers_json: discord_auth_headers_json(true),
body,
},
)
}
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
let body = serde_json::to_vec(&value).unwrap_or_default();
let headers = serde_json::json!({"Content-Type": "application/json"});
OutgoingHttpResponse {
status,
headers_json: headers.to_string(),
body,
}
}
export!(DiscordChannel);
fn truncate_message(content: &str) -> String {
if content.chars().count() <= DISCORD_MESSAGE_CHAR_LIMIT {
content.to_string()
} else {
let suffix = "\n... (truncated)";
let allowed_chars = DISCORD_MESSAGE_CHAR_LIMIT.saturating_sub(suffix.chars().count());
let mut truncated = content.chars().take(allowed_chars).collect::<String>();
truncated.push_str("\n... (truncated)");
truncated
}
}
#[cfg(test)]
mod tests {
use super::*;
const DISCORD_CAPABILITIES_JSON: &str = include_str!("../discord.capabilities.json");
#[test]
fn test_truncate_message() {
let short = "Hello world";
assert_eq!(truncate_message(short), short);
let long = "a".repeat(2005);
let truncated = truncate_message(&long);
assert_eq!(truncated.chars().count(), 2000);
assert!(truncated.ends_with("\n... (truncated)"));
// Test with multibyte characters (Euro sign is 3 bytes)
let multi = "€".repeat(2005);
let truncated_multi = truncate_message(&multi);
assert_eq!(truncated_multi.chars().count(), 2000);
assert!(truncated_multi.ends_with("\n... (truncated)"));
let content_part = &truncated_multi[..truncated_multi.len() - 16];
assert!(content_part.chars().all(|c| c == '€'));
}
#[test]
fn test_reply_plan_uses_character_count_for_attachment_threshold() {
let inline =
build_discord_reply_plan(&test_response(test_metadata_json(), "€".repeat(2000)))
.unwrap();
assert!(matches!(inline, DiscordReplyPlan::Inline(_)));
}
fn test_response(metadata_json: String, content: String) -> AgentResponse {
AgentResponse {
message_id: "msg-1".to_string(),
content,
thread_id: None,
metadata_json,
attachments: vec![],
}
}
fn test_metadata_json() -> String {
serde_json::json!({
"channel_id": "chan-1",
"interaction_id": "int-1",
"token": "tok-1",
"application_id": "app-1",
"thread_id": null,
"embeds": [{"title": "embed title"}]
})
.to_string()
}
#[test]
fn test_reply_plan_threshold_uses_attachment_only_above_2000_chars() {
let inline =
build_discord_reply_plan(&test_response(test_metadata_json(), "a".repeat(2000)))
.unwrap();
assert!(matches!(inline, DiscordReplyPlan::Inline(_)));
let attachment =
build_discord_reply_plan(&test_response(test_metadata_json(), "a".repeat(2001)))
.unwrap();
assert!(matches!(attachment, DiscordReplyPlan::Attachment { .. }));
}
#[test]
fn test_reply_plan_preserves_short_message_content_and_embeds() {
let plan = build_discord_reply_plan(&test_response(
test_metadata_json(),
"short reply".to_string(),
))
.unwrap();
let DiscordReplyPlan::Inline(request) = plan else {
panic!("expected inline plan");
};
assert_eq!(
request.headers_json,
r#"{"Content-Type":"application/json"}"#
);
let payload: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
assert_eq!(payload["content"], "short reply");
assert_eq!(payload["embeds"][0]["title"], "embed title");
}
#[test]
fn test_reply_plan_builds_markdown_attachment_multipart_payload() {
let content = "# Heading\n\nA long markdown reply".repeat(80);
let plan = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone()))
.unwrap();
let DiscordReplyPlan::Attachment { upload, .. } = plan else {
panic!("expected attachment plan");
};
assert!(upload
.headers_json
.contains("multipart/form-data; boundary="));
let body = String::from_utf8(upload.body).unwrap();
assert!(body.contains("name=\"payload_json\""));
assert!(body.contains("filename=\"response.md\""));
assert!(body.contains("Content-Type: text/markdown"));
assert!(body.contains(DISCORD_ATTACHMENT_NOTICE));
assert!(body.contains("embed title"));
assert!(body.contains(&content));
}
#[test]
fn test_reply_plan_uses_dynamic_multipart_boundary() {
let content = "# Heading\n\nA long markdown reply".repeat(80);
let first = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone()))
.unwrap();
let second =
build_discord_reply_plan(&test_response(test_metadata_json(), content)).unwrap();
let DiscordReplyPlan::Attachment {
upload: first_upload,
..
} = first
else {
panic!("expected attachment plan");
};
let DiscordReplyPlan::Attachment {
upload: second_upload,
..
} = second
else {
panic!("expected attachment plan");
};
let first_headers: serde_json::Value =
serde_json::from_str(&first_upload.headers_json).unwrap();
let second_headers: serde_json::Value =
serde_json::from_str(&second_upload.headers_json).unwrap();
let first_boundary = first_headers["Content-Type"]
.as_str()
.unwrap()
.strip_prefix("multipart/form-data; boundary=")
.unwrap();
let second_boundary = second_headers["Content-Type"]
.as_str()
.unwrap()
.strip_prefix("multipart/form-data; boundary=")
.unwrap();
assert!(first_boundary.starts_with(DISCORD_MULTIPART_BOUNDARY));
assert!(second_boundary.starts_with(DISCORD_MULTIPART_BOUNDARY));
assert_ne!(first_boundary, second_boundary);
let first_body = String::from_utf8(first_upload.body).unwrap();
let second_body = String::from_utf8(second_upload.body).unwrap();
assert!(first_body.contains(&format!("--{first_boundary}\r\n")));
assert!(second_body.contains(&format!("--{second_boundary}\r\n")));
}
#[test]
fn test_reply_plan_includes_truncated_text_fallback_for_attachment_failures() {
let content = "a".repeat(2400);
let plan = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone()))
.unwrap();
let DiscordReplyPlan::Attachment { fallback, .. } = plan else {
panic!("expected attachment plan");
};
let payload: serde_json::Value = serde_json::from_slice(&fallback.body).unwrap();
assert_eq!(payload["content"], truncate_message(&content));
assert_eq!(payload["embeds"][0]["title"], "embed title");
}
#[test]
fn test_metadata_serialization() {
let metadata = DiscordMessageMetadata {
channel_id: "123".into(),
interaction_id: "456".into(),
token: "abc".into(),
application_id: "789".into(),
source_message_id: None,
thread_id: None,
};
let json = serde_json::to_string(&metadata).unwrap();
let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.channel_id, "123");
assert_eq!(parsed.interaction_id, "456");
}
#[test]
fn test_metadata_backward_compat_with_old_option_format() {
// Old metadata format used Option<String> for these fields
let old_json = r#"{
"channel_id": "123",
"interaction_id": null,
"token": null,
"application_id": null,
"thread_id": null
}"#;
let parsed: DiscordMessageMetadata = serde_json::from_str(old_json).unwrap();
assert_eq!(parsed.channel_id, "123");
assert!(parsed.interaction_id.is_empty());
// Old format without the fields at all
let minimal_json = r#"{"channel_id": "456"}"#;
let parsed: DiscordMessageMetadata = serde_json::from_str(minimal_json).unwrap();
assert_eq!(parsed.channel_id, "456");
assert!(parsed.interaction_id.is_empty());
assert!(parsed.token.is_empty());
assert!(parsed.application_id.is_empty());
}
#[test]
fn test_response_route_uses_webhook_for_interactions() {
let metadata = DiscordMessageMetadata {
channel_id: "123".into(),
interaction_id: "456".into(),
token: "tok".into(),
application_id: "app".into(),
source_message_id: None,
thread_id: None,
};
assert_eq!(
response_route_for_metadata(&metadata),
DiscordResponseRoute::InteractionWebhook(
format!("{DISCORD_API_BASE}/webhooks/app/tok/messages/@original")
)
);
}
#[test]
fn test_response_route_uses_channel_messages_for_gateway_metadata() {
let metadata = DiscordMessageMetadata {
channel_id: "chan-1".into(),
interaction_id: String::new(),
token: String::new(),
application_id: String::new(),
source_message_id: None,
thread_id: None,
};
assert_eq!(
response_route_for_metadata(&metadata),
DiscordResponseRoute::ChannelMessage(
format!("{DISCORD_API_BASE}/channels/chan-1/messages")
)
);
}
#[test]
fn test_typing_request_url_uses_channel_id_for_thinking_status() {
let update = StatusUpdate {
status: StatusType::Thinking,
message: "Thinking...".to_string(),
metadata_json: serde_json::json!({
"channel_id": "chan-42",
"interaction_id": "",
"token": "",
"application_id": "",
"thread_id": null
})
.to_string(),
};
assert_eq!(
typing_request_url_for_update(&update),
Some(format!("{DISCORD_API_BASE}/channels/chan-42/typing"))
);
}
#[test]
fn test_typing_request_url_ignores_non_thinking_status() {
let update = StatusUpdate {
status: StatusType::Done,
message: "Done".to_string(),
metadata_json: serde_json::json!({
"channel_id": "chan-42",
"interaction_id": "",
"token": "",
"application_id": "",
"thread_id": null
})
.to_string(),
};
assert_eq!(typing_request_url_for_update(&update), None);
}
#[test]
fn test_typing_request_url_ignores_invalid_metadata() {
let update = StatusUpdate {
status: StatusType::Thinking,
message: "Thinking...".to_string(),
metadata_json: "not-json".to_string(),
};
assert_eq!(typing_request_url_for_update(&update), None);
}
#[test]
fn test_parse_slash_command_interaction() {
// Verify that a slash command interaction deserializes correctly.
let json = r#"{
"type": 2,
"id": "int_1",
"application_id": "app_1",
"channel_id": "ch_1",
"member": {
"user": {
"id": "user_1",
"username": "testuser",
"global_name": "Test User"
}
},
"data": {
"id": "cmd_1",
"name": "ask",
"options": [
{"name": "question", "value": "What is rust?"}
]
},
"token": "token_abc"
}"#;
let interaction: DiscordInteraction = serde_json::from_str(json).unwrap();
assert_eq!(interaction.interaction_type, 2);
assert!(interaction.data.is_some());
}
#[test]
fn test_capabilities_default_to_gateway_mode() {
let caps: serde_json::Value =
serde_json::from_str(DISCORD_CAPABILITIES_JSON).expect("capabilities parse");
let allowlist = caps["capabilities"]["http"]["allowlist"]
.as_array()
.expect("http allowlist array");
assert_eq!(
caps["capabilities"]["channel"]["allow_polling"],
serde_json::Value::Bool(true)
);
assert!(allowlist.iter().any(|entry| {
entry["host"] == serde_json::Value::String("gateway.discord.gg".to_string())
&& entry["methods"] == serde_json::json!(["GET"])
}));
assert_eq!(
caps["capabilities"]["websocket"]["url"],
serde_json::Value::String("wss://gateway.discord.gg/?v=10&encoding=json".to_string())
);
assert_eq!(
caps["capabilities"]["websocket"]["connect_on_start"],
serde_json::Value::Bool(true)
);
assert_eq!(
caps["capabilities"]["websocket"]["identify_secret_name"],
serde_json::Value::String("discord_bot_token".to_string())
);
assert_eq!(
caps["capabilities"]["websocket"]["identify"]["intents"],
serde_json::Value::Number(4609u64.into())
);
}
#[test]
fn test_parse_gateway_event_queue_emits_message_create_after_ready() {
let queue_json = serde_json::json!([
serde_json::json!({
"op": 0,
"t": "READY",
"d": {
"user": {
"id": "bot-1",
"username": "ironclaw",
"global_name": "IronClaw",
"bot": true
}
}
})
.to_string(),
serde_json::json!({
"op": 0,
"t": "MESSAGE_CREATE",
"d": {
"channel_id": "chan-1",
"guild_id": "guild-1",
"content": "<@bot-1> hello from discord",
"author": {
"id": "user-1",
"username": "alice",
"global_name": "Alice",
"bot": false
}
}
})
.to_string()
])
.to_string();
let result = parse_gateway_event_queue(&queue_json, None);
assert_eq!(result.bot_user_id.as_deref(), Some("bot-1"));
assert_eq!(
result.messages,
vec![ParsedGatewayMessage {
user_id: "user-1".to_string(),
user_name: "Alice".to_string(),
channel_id: "chan-1".to_string(),
content: "hello from discord".to_string(),
is_dm: false,
}]
);
}
#[test]
fn test_parse_gateway_event_queue_ignores_bot_and_unmentioned_guild_messages() {
let queue_json = serde_json::json!([
serde_json::json!({
"op": 0,
"t": "MESSAGE_CREATE",
"d": {
"channel_id": "chan-1",
"guild_id": "guild-1",
"content": "this should not trigger",
"author": {
"id": "user-1",
"username": "alice",
"global_name": "Alice",
"bot": false
}
}
})
.to_string(),
serde_json::json!({
"op": 0,
"t": "MESSAGE_CREATE",
"d": {
"channel_id": "dm-1",
"content": "bot echo",
"author": {
"id": "bot-1",
"username": "ironclaw",
"global_name": "IronClaw",
"bot": true
}
}
})
.to_string(),
serde_json::json!({
"op": 0,
"t": "MESSAGE_CREATE",
"d": {
"channel_id": "dm-2",
"content": "direct message",
"author": {
"id": "user-2",
"username": "bob",
"global_name": null,
"bot": false
}
}
})
.to_string()
])
.to_string();
let result = parse_gateway_event_queue(&queue_json, Some("bot-1"));
assert_eq!(result.bot_user_id.as_deref(), Some("bot-1"));
assert_eq!(
result.messages,
vec![ParsedGatewayMessage {
user_id: "user-2".to_string(),
user_name: "bob".to_string(),
channel_id: "dm-2".to_string(),
content: "direct message".to_string(),
is_dm: true,
}]
);
}
#[test]
fn test_non_gateway_dm_pairing_behavior_is_unchanged() {
assert!(should_apply_dm_pairing(PermissionSource::Webhook, true));
assert!(!should_apply_dm_pairing(PermissionSource::Webhook, false));
}
#[test]
fn test_gateway_dm_pairing_behavior_matches_webhook_dm() {
assert!(should_apply_dm_pairing(PermissionSource::Gateway, true));
assert!(!should_apply_dm_pairing(PermissionSource::Gateway, false));
}
#[test]
fn test_pairing_reply_route_uses_channel_messages_for_gateway_metadata() {
let route = pairing_reply_route(&PairingReplyCtx {
channel_id: "chan-1".to_string(),
application_id: String::new(),
token: String::new(),
});
assert_eq!(
route,
DiscordResponseRoute::ChannelMessage(
format!("{DISCORD_API_BASE}/channels/chan-1/messages")
)
);
}
#[test]
fn test_pairing_reply_route_uses_webhook_for_interactions() {
let route = pairing_reply_route(&PairingReplyCtx {
channel_id: "chan-1".to_string(),
application_id: "app-1".to_string(),
token: "tok-1".to_string(),
});
assert_eq!(
route,
DiscordResponseRoute::InteractionWebhook(
format!("{DISCORD_API_BASE}/webhooks/app-1/tok-1")
)
);
}
// ======================================================================
// Mention polling tests
// ======================================================================
#[test]
fn test_is_new_message() {
assert!(is_new_message("100", "200"));
assert!(!is_new_message("200", "100"));
assert!(!is_new_message("100", "100"));
// Large snowflake-like IDs
assert!(is_new_message("1234567890123456789", "1234567890123456790"));
assert!(!is_new_message(
"1234567890123456790",
"1234567890123456789"
));
}
#[test]
fn test_strip_bot_mention() {
assert_eq!(
strip_bot_mention("<@bot-123> hello world", "bot-123"),
"hello world"
);
assert_eq!(
strip_bot_mention("<@!bot-123> hi there", "bot-123"),
"hi there"
);
// No mention prefix — return content as-is
assert_eq!(
strip_bot_mention("no mention here", "bot-123"),
"no mention here"
);
// Only mention, no content after stripping
assert_eq!(strip_bot_mention("<@bot-123>", "bot-123"), "");
assert_eq!(strip_bot_mention("<@bot-123> ", "bot-123"), "");
}
#[test]
fn test_message_mentions_bot() {
// Via mentions array
let msg = DiscordChannelMessage {
id: "1".to_string(),
content: "hello".to_string(),
channel_id: "ch-1".to_string(),
author: DiscordChannelAuthor {
id: "user-1".to_string(),
username: "alice".to_string(),
global_name: None,
bot: false,
},
mentions: vec![DiscordUser {
id: "bot-1".to_string(),
username: "ironclaw".to_string(),
global_name: None,
}],
webhook_id: None,
};
assert!(message_mentions_bot(&msg, "bot-1"));
assert!(!message_mentions_bot(&msg, "other-bot"));
// Via content
let msg2 = DiscordChannelMessage {
id: "2".to_string(),
content: "<@bot-2> do something".to_string(),
channel_id: "ch-1".to_string(),
author: DiscordChannelAuthor {
id: "user-1".to_string(),
username: "alice".to_string(),
global_name: None,
bot: false,
},
mentions: vec![],
webhook_id: None,
};
assert!(message_mentions_bot(&msg2, "bot-2"));
assert!(!message_mentions_bot(&msg2, "other-bot"));
}
#[test]
fn test_compare_message_ids() {
use std::cmp::Ordering;
assert_eq!(compare_message_ids("100", "200"), Ordering::Less);
assert_eq!(compare_message_ids("200", "100"), Ordering::Greater);
assert_eq!(compare_message_ids("100", "100"), Ordering::Equal);
// Non-numeric fallback
assert_eq!(compare_message_ids("abc", "abd"), Ordering::Less);
assert_eq!(compare_message_ids("abd", "abc"), Ordering::Greater);
}
#[test]
fn test_remember_processed_id_dedup_and_cap() {
let mut ids = Vec::new();
// Basic add
remember_processed_id("msg-1", &mut ids);
assert_eq!(ids, vec!["msg-1".to_string()]);
// Duplicate is ignored
remember_processed_id("msg-1", &mut ids);
assert_eq!(ids.len(), 1);
// Fill beyond DEDUP_CAP
for i in 2..=(DEDUP_CAP + 5) {
remember_processed_id(&format!("msg-{}", i), &mut ids);
}
assert_eq!(ids.len(), DEDUP_CAP);
// Oldest entries should have been drained
assert!(!ids.contains(&"msg-1".to_string()));
assert!(ids.contains(&format!("msg-{}", DEDUP_CAP + 5)));
}
#[test]
fn test_discord_auth_headers_json_shape() {
let with_ct = discord_auth_headers_json(true);
let parsed: serde_json::Value = serde_json::from_str(&with_ct).unwrap();
assert_eq!(parsed["Content-Type"], "application/json");
let without_ct = discord_auth_headers_json(false);
let parsed: serde_json::Value = serde_json::from_str(&without_ct).unwrap();
assert!(parsed.get("Content-Type").is_none());
}
}