Files
optimclaw/channels-src/discord/src/lib.rs
T
9ce3a9fc53 feat(discord): implement on_broadcast via DM channel creation (#1693)
- Implement broadcast_dm() that creates a DM channel with the target
  user (POST /users/@me/channels, cached by Discord) and sends the
  message to it
- Extract DISCORD_API_BASE constant for all Discord REST API URLs
- Extract send_channel_message() shared helper to deduplicate message
  posting between on_respond and broadcast_dm
- Add snowflake validation on user_id before API calls
- Fix pre-existing clippy redundant_closure warning
- Use typed DmChannelResponse struct instead of serde_json::Value

Closes no specific issue — completes the previously stubbed on_broadcast.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-28 16:31:27 +01:00

1692 lines
54 KiB
Rust

//! Discord Gateway/Webhook channel for IronClaw.
//!
//! 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
//! - Automatic message truncation (> 2000 chars)
//!
//! # Security
//!
//! - Signature validation is handled in-channel using Discord's Ed25519 headers
//! - 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 std::{cmp::Ordering, collections::HashMap};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
/// Discord REST API v10 base URL.
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, PollConfig, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage};
/// 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,
}
#[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>,
}
#[derive(Debug, Deserialize)]
struct DiscordChannelAuthor {
id: String,
username: String,
global_name: Option<String>,
#[serde(default)]
bot: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DiscordRuntimeConfig {
#[serde(default = "default_require_signature_verification")]
require_signature_verification: bool,
#[serde(default)]
webhook_secret: Option<String>,
#[serde(default)]
polling_enabled: bool,
#[serde(default = "default_poll_interval_ms")]
poll_interval_ms: u32,
#[serde(default)]
mention_channel_ids: Vec<String>,
#[serde(default)]
owner_id: Option<String>,
#[serde(default = "default_dm_policy")]
dm_policy: String,
#[serde(default)]
allow_from: Vec<String>,
}
fn default_poll_interval_ms() -> u32 {
30_000
}
fn default_require_signature_verification() -> bool {
true
}
fn default_dm_policy() -> String {
"pairing".to_string()
}
fn default_runtime_config() -> DiscordRuntimeConfig {
DiscordRuntimeConfig {
require_signature_verification: default_require_signature_verification(),
webhook_secret: None,
polling_enabled: false,
poll_interval_ms: default_poll_interval_ms(),
mention_channel_ids: Vec::new(),
owner_id: None,
dm_policy: default_dm_policy(),
allow_from: Vec::new(),
}
}
/// Workspace path for persisting owner_id across WASM callbacks.
const OWNER_ID_PATH: &str = "state/owner_id";
/// 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";
/// Channel name for pairing store (used by pairing host APIs).
const CHANNEL_NAME: &str = "discord";
/// 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)]
interaction_id: Option<String>,
/// Interaction token for responding
#[serde(default)]
token: Option<String>,
/// Application ID
#[serde(default)]
application_id: Option<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>,
}
struct DiscordChannel;
impl Guest for DiscordChannel {
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
let config =
serde_json::from_str::<DiscordRuntimeConfig>(&config_json).unwrap_or_else(|e| {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Invalid config JSON, using defaults: {}", e),
);
default_runtime_config()
});
if let Ok(serialized) = serde_json::to_string(&config) {
let _ = channel_host::workspace_write("config.json", &serialized);
}
if config.require_signature_verification
&& config
.webhook_secret
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.is_none()
{
channel_host::log(
channel_host::LogLevel::Error,
"Discord channel misconfigured: require_signature_verification=true but webhook_secret is empty",
);
} else if !config.require_signature_verification {
channel_host::log(
channel_host::LogLevel::Warn,
"Discord signature verification is disabled; webhook endpoint is unprotected",
);
}
// 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 _ = channel_host::workspace_write(DM_POLICY_PATH, &config.dm_policy);
let allow_from_json =
serde_json::to_string(&config.allow_from).unwrap_or_else(|_| "[]".to_string());
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
Ok(ChannelConfig {
display_name: "Discord".to_string(),
http_endpoints: vec![HttpEndpointConfig {
path: "/webhook/discord".to_string(),
methods: vec!["POST".to_string()],
require_secret: false,
}],
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 config = load_runtime_config();
let headers: HashMap<String, String> =
serde_json::from_str(&req.headers_json).unwrap_or_default();
if config.require_signature_verification {
if config
.webhook_secret
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.is_none()
{
channel_host::log(
channel_host::LogLevel::Error,
"Discord channel misconfigured: webhook_secret not set while verification is required",
);
return json_response(
500,
serde_json::json!({"error": "Channel misconfigured: webhook_secret not set"}),
);
}
if !verify_discord_request_signature(
headers,
&req.body,
config.webhook_secret.as_deref(),
) {
channel_host::log(
channel_host::LogLevel::Warn,
"Discord signature verification failed",
);
return json_response(401, serde_json::json!({"error": "Invalid signature"}));
}
} else {
channel_host::log(
channel_host::LogLevel::Warn,
"Discord signature verification is disabled; accepting unverified webhook request",
);
}
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,
"data": {
"content": "🤔 Thinking..."
}
}),
)
} else {
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() {
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))?;
// Truncate content to 2000 characters to comply with Discord limits
let content = truncate_message(&response.content);
let mut payload = serde_json::json!({ "content": content });
// Check for embeds in metadata
if let Ok(meta_json) = serde_json::from_str::<serde_json::Value>(&response.metadata_json) {
if let Some(embeds) = meta_json.get("embeds") {
payload["embeds"] = embeds.clone();
}
}
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 (method, url) = if let (Some(application_id), Some(token)) =
(metadata.application_id.as_ref(), metadata.token.as_ref())
{
(
"PATCH",
format!(
"{DISCORD_API_BASE}/webhooks/{}/{}/messages/@original",
application_id, token
),
)
} else if let Some(source_message_id) = metadata.source_message_id.as_ref() {
payload["message_reference"] = serde_json::json!({
"message_id": source_message_id
});
payload["allowed_mentions"] = serde_json::json!({
"replied_user": true
});
return send_channel_message(&metadata.channel_id, payload);
} else {
return Err("Unsupported Discord response metadata".to_string());
};
let result = channel_host::http_request(
method,
&url,
&headers.to_string(),
Some(&payload_bytes),
None,
);
map_discord_response(result)
}
fn on_status(_update: StatusUpdate) {}
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",
);
}
}
fn map_discord_response(
result: Result<near::agent::channel_host::HttpResponse, String>,
) -> Result<(), String> {
match result {
Ok(http_response) => {
if http_response.status >= 200 && http_response.status < 300 {
channel_host::log(channel_host::LogLevel::Debug, "Posted response 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)),
}
}
/// Post a JSON payload to a Discord channel as a new message.
fn send_channel_message(channel_id: &str, payload: serde_json::Value) -> Result<(), String> {
let payload_bytes = serde_json::to_vec(&payload)
.map_err(|e| format!("Failed to serialize message: {}", e))?;
let url = format!("{DISCORD_API_BASE}/channels/{}/messages", channel_id);
let result = channel_host::http_request(
"POST",
&url,
&discord_auth_headers_json(true),
Some(&payload_bytes),
None,
);
map_discord_response(result)
}
fn load_runtime_config() -> DiscordRuntimeConfig {
channel_host::workspace_read("config.json")
.and_then(|raw| serde_json::from_str::<DiscordRuntimeConfig>(&raw).ok())
.unwrap_or_else(default_runtime_config)
}
fn poll_for_mentions() {
let config = load_runtime_config();
if !config.polling_enabled || config.mention_channel_ids.is_empty() {
return;
}
let bot_id = match get_or_fetch_bot_id() {
Some(id) => id,
None => {
channel_host::log(
channel_host::LogLevel::Warn,
"Skipping mention polling: failed to resolve bot user id",
);
return;
}
};
for channel_id in &config.mention_channel_ids {
poll_channel_mentions(channel_id, &bot_id);
}
}
fn get_or_fetch_bot_id() -> Option<String> {
if let Some(id) = channel_host::workspace_read("bot_user_id.txt") {
let trimmed = id.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let response = channel_host::http_request(
"GET",
&format!("{DISCORD_API_BASE}/users/@me"),
&discord_auth_headers_json(false),
None,
Some(10_000),
)
.ok()?;
if !(200..300).contains(&response.status) {
return None;
}
let value: serde_json::Value = serde_json::from_slice(&response.body).ok()?;
let id = value.get("id")?.as_str()?.to_string();
let _ = channel_host::workspace_write("bot_user_id.txt", &id);
Some(id)
}
fn poll_channel_mentions(channel_id: &str, bot_id: &str) {
let cursor_path = format!("cursor_{}.txt", channel_id);
let last_seen = channel_host::workspace_read(&cursor_path).map(|s| s.trim().to_string());
// On first run for a channel, initialize the cursor to "latest seen" and
// skip back-processing historical messages.
if last_seen.is_none() {
if let Some(latest) = fetch_latest_message_id(channel_id) {
let _ = channel_host::workspace_write(&cursor_path, &latest);
}
return;
}
let Some(mut messages) =
fetch_messages_after_cursor(channel_id, last_seen.as_deref().unwrap_or(""))
else {
return;
};
if messages.is_empty() {
return;
}
messages.sort_by(|a, b| compare_message_ids(&a.id, &b.id));
let mut max_seen = last_seen.clone();
let mut recent_ids = load_recent_processed_ids(channel_id);
let mut dedup_updated = false;
for msg in messages {
if is_new_message(max_seen.as_deref(), &msg.id) {
max_seen = Some(msg.id.clone());
}
if msg.webhook_id.is_some() || msg.author.bot || msg.author.id == bot_id {
continue;
}
if !message_mentions_bot(&msg, bot_id) {
continue;
}
if recent_ids.iter().any(|id| id == &msg.id) {
continue;
}
let user_name = msg
.author
.global_name
.as_ref()
.filter(|s| !s.is_empty())
.unwrap_or(&msg.author.username)
.clone();
if !check_sender_permission(&msg.author.id, Some(&user_name), false, None) {
continue;
}
let content = strip_bot_mention(&msg.content, bot_id);
let metadata = DiscordMessageMetadata {
channel_id: msg.channel_id.clone(),
interaction_id: None,
token: None,
application_id: None,
source_message_id: Some(msg.id.clone()),
thread_id: None,
};
let metadata_json = match serde_json::to_string(&metadata) {
Ok(v) => v,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to serialize mention metadata: {}", e),
);
continue;
}
};
channel_host::emit_message(&EmittedMessage {
user_id: msg.author.id.clone(),
user_name: Some(user_name.clone()),
content: if content.is_empty() {
"mention".to_string()
} else {
content
},
thread_id: None,
metadata_json,
attachments: vec![],
});
remember_processed_id(&mut recent_ids, &msg.id);
dedup_updated = true;
}
if let Some(cursor) = max_seen {
let _ = channel_host::workspace_write(&cursor_path, &cursor);
}
if dedup_updated {
let _ = save_recent_processed_ids(channel_id, &recent_ids);
}
}
fn fetch_latest_message_id(channel_id: &str) -> Option<String> {
let url = format!(
"{DISCORD_API_BASE}/channels/{}/messages?limit=1",
channel_id
);
let response = channel_host::http_request(
"GET",
&url,
&discord_auth_headers_json(false),
None,
Some(10_000),
)
.ok()?;
if !(200..300).contains(&response.status) {
let body = String::from_utf8_lossy(&response.body);
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Discord initial poll failed for channel {}: status={} body={}",
channel_id, response.status, body
),
);
return None;
}
let messages: Vec<DiscordChannelMessage> = serde_json::from_slice(&response.body).ok()?;
messages.first().map(|m| m.id.clone())
}
fn fetch_messages_after_cursor(
channel_id: &str,
last_seen: &str,
) -> Option<Vec<DiscordChannelMessage>> {
const PAGE_LIMIT: usize = 100;
const MAX_PAGES: usize = 50;
let mut all_messages = Vec::new();
let mut after = last_seen.to_string();
for page in 0..MAX_PAGES {
let url = format!(
"{DISCORD_API_BASE}/channels/{}/messages?limit={}&after={}",
channel_id, PAGE_LIMIT, after
);
let response = match channel_host::http_request(
"GET",
&url,
&discord_auth_headers_json(false),
None,
Some(10_000),
) {
Ok(r) => r,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Discord poll request failed for channel {}: {}",
channel_id, e
),
);
return None;
}
};
if !(200..300).contains(&response.status) {
let body = String::from_utf8_lossy(&response.body);
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Discord poll failed for channel {}: status={} body={}",
channel_id, response.status, body
),
);
return None;
}
let messages: Vec<DiscordChannelMessage> = match serde_json::from_slice(&response.body) {
Ok(v) => v,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to parse polled Discord messages: {}", e),
);
return None;
}
};
let page_len = messages.len();
if messages.is_empty() {
break;
}
let page_max_id = messages
.iter()
.map(|m| m.id.as_str())
.max_by(|a, b| compare_message_ids(a, b))
.map(str::to_string);
all_messages.extend(messages.into_iter());
if page_len < PAGE_LIMIT {
break;
}
if let Some(max_id) = page_max_id {
if max_id == after {
break;
}
after = max_id;
} else {
break;
}
if page + 1 == MAX_PAGES {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Discord poll pagination limit reached for channel {}; processing partial batch",
channel_id
),
);
}
}
Some(all_messages)
}
fn compare_message_ids(a: &str, b: &str) -> Ordering {
match (a.parse::<u64>(), b.parse::<u64>()) {
(Ok(left), Ok(right)) => left.cmp(&right),
_ => a.cmp(b),
}
}
fn dedup_ids_path(channel_id: &str) -> String {
format!("dedup_{}.json", channel_id)
}
fn load_recent_processed_ids(channel_id: &str) -> Vec<String> {
let path = dedup_ids_path(channel_id);
channel_host::workspace_read(&path)
.and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
.unwrap_or_default()
}
fn save_recent_processed_ids(channel_id: &str, ids: &[String]) -> Result<(), String> {
let path = dedup_ids_path(channel_id);
let raw =
serde_json::to_string(ids).map_err(|e| format!("Failed to serialize dedup ids: {}", e))?;
channel_host::workspace_write(&path, &raw)
}
fn remember_processed_id(ids: &mut Vec<String>, message_id: &str) {
const MAX_RECENT_IDS: usize = 200;
if ids.iter().any(|id| id == message_id) {
return;
}
ids.push(message_id.to_string());
if ids.len() > MAX_RECENT_IDS {
let drop_count = ids.len() - MAX_RECENT_IDS;
ids.drain(0..drop_count);
}
}
fn is_new_message(last_seen: Option<&str>, current: &str) -> bool {
match last_seen {
None => true,
Some(prev) => {
let prev_num = prev.parse::<u64>().ok();
let cur_num = current.parse::<u64>().ok();
match (prev_num, cur_num) {
(Some(p), Some(c)) => c > p,
_ => current > prev,
}
}
}
}
fn message_mentions_bot(msg: &DiscordChannelMessage, bot_id: &str) -> bool {
msg.mentions.iter().any(|u| u.id == bot_id)
|| msg.content.contains(&format!("<@{}>", bot_id))
|| msg.content.contains(&format!("<@!{}>", bot_id))
}
fn strip_bot_mention(content: &str, bot_id: &str) -> String {
content
.replace(&format!("<@{}>", bot_id), "")
.replace(&format!("<@!{}>", bot_id), "")
.trim()
.to_string()
}
fn discord_auth_headers_json(include_content_type: bool) -> String {
if include_content_type {
serde_json::json!({
"Content-Type": "application/json",
"Authorization": "Bot {DISCORD_BOT_TOKEN}"
})
.to_string()
} else {
serde_json::json!({
"Authorization": "Bot {DISCORD_BOT_TOKEN}"
})
.to_string()
}
}
fn verify_discord_request_signature(
headers: HashMap<String, String>,
body: &[u8],
public_key_hex: Option<&str>,
) -> bool {
let Some(public_key_hex) = public_key_hex.map(str::trim).filter(|s| !s.is_empty()) else {
return false;
};
let Some(signature_hex) = header_case_insensitive(&headers, "x-signature-ed25519") else {
return false;
};
let Some(timestamp) = header_case_insensitive(&headers, "x-signature-timestamp") else {
return false;
};
let public_key_bytes = match hex::decode(public_key_hex) {
Ok(v) => v,
Err(_) => return false,
};
let public_key_arr: [u8; 32] = match public_key_bytes.try_into() {
Ok(v) => v,
Err(_) => return false,
};
let verifying_key = match VerifyingKey::from_bytes(&public_key_arr) {
Ok(v) => v,
Err(_) => return false,
};
let sig_bytes = match hex::decode(signature_hex.trim()) {
Ok(v) => v,
Err(_) => return false,
};
let sig_arr: [u8; 64] = match sig_bytes.try_into() {
Ok(v) => v,
Err(_) => return false,
};
let signature = Signature::from_bytes(&sig_arr);
let mut signed_message = Vec::with_capacity(timestamp.len() + body.len());
signed_message.extend_from_slice(timestamp.as_bytes());
signed_message.extend_from_slice(body);
verifying_key.verify(&signed_message, &signature).is_ok()
}
fn header_case_insensitive<'a>(
headers: &'a HashMap<String, String>,
name: &str,
) -> Option<&'a str> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
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();
if !check_sender_permission(
&user_id,
Some(&user_name),
is_dm,
Some(&PairingReplyCtx {
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: Some(interaction.id.clone()),
token: Some(interaction.token.clone()),
application_id: Some(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),
);
// Attempt to notify user of internal error
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 // Ephemeral
});
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;
}
};
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) {
// Check member first (for server contexts), then user (for DMs)
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, None) {
return;
}
let channel_id = message.channel_id.clone();
let metadata = DiscordMessageMetadata {
channel_id: channel_id.clone(),
interaction_id: Some(interaction.id.clone()),
token: Some(interaction.token.clone()),
application_id: Some(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![],
});
}
/// Context needed to send a pairing reply via Discord webhook followup.
struct PairingReplyCtx {
application_id: String,
token: String,
}
/// 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,
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 !is_dm {
return true;
}
let dm_policy =
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(default_dm_policy);
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
}
/// Send a pairing code as an ephemeral Discord followup message.
fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
let url = format!(
"{DISCORD_API_BASE}/webhooks/{}/{}",
ctx.application_id, ctx.token
);
let payload = serde_json::json!({
"content": format!(
"To pair with this bot, run: `ironclaw pairing approve discord {}`",
code
),
"flags": 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 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)),
}
}
/// Send a broadcast message to a Discord user via DM.
///
/// Creates a DM channel with the user (Discord caches this, so repeated calls
/// for the same user reuse the existing channel) and then posts the message.
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))?;
let channel_id = &dm_channel.id;
// Step 2: Send the message to the DM channel.
let truncated = truncate_message(content);
let payload = serde_json::json!({ "content": truncated });
send_channel_message(channel_id, payload)
}
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.len() <= 2000 {
content.to_string()
} else {
let max_bytes = 1990;
let cutoff = content
.char_indices()
.map(|(i, c)| i + c.len_utf8())
.take_while(|&end| end <= max_bytes)
.last()
.unwrap_or(0);
let mut truncated = content[..cutoff].to_string();
truncated.push_str("\n... (truncated)");
truncated
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
#[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.len(), 2006); // 1990 + 16 chars suffix
assert!(truncated.ends_with("\n... (truncated)"));
// Test with multibyte characters (Euro sign is 3 bytes)
// 1000 chars * 3 bytes = 3000 bytes
let multi = "€".repeat(1000);
let truncated_multi = truncate_message(&multi);
// 1990 bytes limit. 1990 / 3 = 663 with remainder 1.
// Should truncate at 663 chars (1989 bytes).
// Suffix is 16 bytes. Total: 1989 + 16 = 2005 bytes.
assert!(truncated_multi.len() <= 2006);
assert!(truncated_multi.len() >= 2006 - 4); // Allow for max utf8 char width variance
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_metadata_serialization() {
let metadata = DiscordMessageMetadata {
channel_id: "123".into(),
interaction_id: Some("456".into()),
token: Some("abc".into()),
application_id: Some("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.as_deref(), Some("456"));
}
#[test]
fn test_is_new_message() {
assert!(is_new_message(None, "100"));
assert!(is_new_message(Some("100"), "200"));
assert!(!is_new_message(Some("200"), "100"));
assert!(!is_new_message(Some("100"), "100"));
assert!(is_new_message(Some("abc"), "abd"));
assert!(!is_new_message(Some("abd"), "abc"));
}
#[test]
fn test_strip_bot_mention() {
assert_eq!(strip_bot_mention("<@123> hello", "123"), "hello");
assert_eq!(strip_bot_mention("<@!123> hello", "123"), "hello");
assert_eq!(strip_bot_mention("<@123>", "123"), "");
assert_eq!(
strip_bot_mention("hello <@123> world <@!123>", "123"),
"hello world"
);
}
#[test]
fn test_message_mentions_bot() {
let msg = DiscordChannelMessage {
id: "1".to_string(),
content: "hello <@123>".to_string(),
channel_id: "10".to_string(),
author: DiscordChannelAuthor {
id: "u1".to_string(),
username: "alice".to_string(),
global_name: None,
bot: false,
},
mentions: vec![],
webhook_id: None,
};
assert!(message_mentions_bot(&msg, "123"));
assert!(!message_mentions_bot(&msg, "999"));
}
#[test]
fn test_message_mentions_bot_via_mentions_array() {
let msg = DiscordChannelMessage {
id: "2".to_string(),
content: "hello".to_string(),
channel_id: "10".to_string(),
author: DiscordChannelAuthor {
id: "u1".to_string(),
username: "alice".to_string(),
global_name: None,
bot: false,
},
mentions: vec![DiscordUser {
id: "777".to_string(),
username: "bot".to_string(),
global_name: None,
}],
webhook_id: None,
};
assert!(message_mentions_bot(&msg, "777"));
}
#[test]
fn test_compare_message_ids_numeric_and_lexical_fallback() {
assert_eq!(compare_message_ids("100", "20"), Ordering::Greater);
assert_eq!(compare_message_ids("20", "100"), Ordering::Less);
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();
for i in 0..220 {
remember_processed_id(&mut ids, &format!("{}", i));
}
assert_eq!(ids.len(), 200);
assert_eq!(ids.first().map(String::as_str), Some("20"));
assert_eq!(ids.last().map(String::as_str), Some("219"));
remember_processed_id(&mut ids, "219");
assert_eq!(ids.len(), 200);
assert_eq!(ids.last().map(String::as_str), Some("219"));
}
#[test]
fn test_header_case_insensitive() {
let mut headers = HashMap::new();
headers.insert("X-Signature-Timestamp".to_string(), "123".to_string());
assert_eq!(
header_case_insensitive(&headers, "x-signature-timestamp"),
Some("123")
);
assert_eq!(header_case_insensitive(&headers, "missing"), None);
}
#[test]
fn test_discord_auth_headers_json_shape() {
let with_ct: serde_json::Value =
serde_json::from_str(&discord_auth_headers_json(true)).unwrap();
assert_eq!(
with_ct.get("Content-Type").and_then(|v| v.as_str()),
Some("application/json")
);
assert_eq!(
with_ct.get("Authorization").and_then(|v| v.as_str()),
Some("Bot {DISCORD_BOT_TOKEN}")
);
let no_ct: serde_json::Value =
serde_json::from_str(&discord_auth_headers_json(false)).unwrap();
assert!(no_ct.get("Content-Type").is_none());
assert_eq!(
no_ct.get("Authorization").and_then(|v| v.as_str()),
Some("Bot {DISCORD_BOT_TOKEN}")
);
}
#[test]
fn test_verify_discord_request_signature_valid() {
let signing_key = SigningKey::from_bytes(&[7u8; 32]);
let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
let timestamp = "1234567890";
let body = br#"{"type":1}"#;
let mut signed = Vec::new();
signed.extend_from_slice(timestamp.as_bytes());
signed.extend_from_slice(body);
let signature = signing_key.sign(&signed);
let mut headers = HashMap::new();
headers.insert(
"x-signature-ed25519".to_string(),
hex::encode(signature.to_bytes()),
);
headers.insert("x-signature-timestamp".to_string(), timestamp.to_string());
assert!(verify_discord_request_signature(
headers,
body,
Some(&public_key_hex)
));
}
#[test]
fn test_verify_discord_request_signature_tampered_body() {
let signing_key = SigningKey::from_bytes(&[9u8; 32]);
let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
let timestamp = "1234567890";
let body = b"hello";
let mut signed = Vec::new();
signed.extend_from_slice(timestamp.as_bytes());
signed.extend_from_slice(body);
let signature = signing_key.sign(&signed);
let mut headers = HashMap::new();
headers.insert(
"x-signature-ed25519".to_string(),
hex::encode(signature.to_bytes()),
);
headers.insert("x-signature-timestamp".to_string(), timestamp.to_string());
assert!(!verify_discord_request_signature(
headers,
b"hello-modified",
Some(&public_key_hex)
));
}
#[test]
fn test_verify_discord_request_signature_wrong_public_key() {
let signing_key = SigningKey::from_bytes(&[11u8; 32]);
let wrong_key = SigningKey::from_bytes(&[12u8; 32]);
let timestamp = "1234567890";
let body = b"payload";
let mut signed = Vec::new();
signed.extend_from_slice(timestamp.as_bytes());
signed.extend_from_slice(body);
let signature = signing_key.sign(&signed);
let mut headers = HashMap::new();
headers.insert(
"x-signature-ed25519".to_string(),
hex::encode(signature.to_bytes()),
);
headers.insert("x-signature-timestamp".to_string(), timestamp.to_string());
assert!(!verify_discord_request_signature(
headers,
body,
Some(&hex::encode(wrong_key.verifying_key().to_bytes()))
));
}
#[test]
fn test_verify_discord_request_signature_missing_headers() {
let headers = HashMap::new();
assert!(!verify_discord_request_signature(
headers,
b"abc",
Some("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
));
}
#[test]
fn test_verify_discord_request_signature_invalid_signature_hex() {
let mut headers = HashMap::new();
headers.insert("x-signature-ed25519".to_string(), "not-hex".to_string());
headers.insert(
"x-signature-timestamp".to_string(),
"1234567890".to_string(),
);
assert!(!verify_discord_request_signature(
headers,
b"abc",
Some("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
));
}
#[test]
fn test_verify_discord_request_signature_invalid_public_key_hex() {
let mut headers = HashMap::new();
headers.insert("x-signature-ed25519".to_string(), "00".repeat(64));
headers.insert(
"x-signature-timestamp".to_string(),
"1234567890".to_string(),
);
assert!(!verify_discord_request_signature(
headers,
b"abc",
Some("not-hex")
));
}
#[test]
fn test_verify_discord_request_signature_invalid_lengths() {
let mut headers = HashMap::new();
headers.insert("x-signature-ed25519".to_string(), "00".repeat(10));
headers.insert(
"x-signature-timestamp".to_string(),
"1234567890".to_string(),
);
assert!(!verify_discord_request_signature(
headers.clone(),
b"abc",
Some("00".repeat(31).as_str())
));
assert!(!verify_discord_request_signature(
headers,
b"abc",
Some("00".repeat(32).as_str())
));
}
#[test]
fn test_verify_discord_request_signature_case_insensitive_headers() {
let signing_key = SigningKey::from_bytes(&[13u8; 32]);
let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
let timestamp = "1234567890";
let body = b"case-header";
let mut signed = Vec::new();
signed.extend_from_slice(timestamp.as_bytes());
signed.extend_from_slice(body);
let signature = signing_key.sign(&signed);
let mut headers = HashMap::new();
headers.insert(
"X-Signature-Ed25519".to_string(),
hex::encode(signature.to_bytes()),
);
headers.insert("X-Signature-Timestamp".to_string(), timestamp.to_string());
assert!(verify_discord_request_signature(
headers,
body,
Some(&public_key_hex)
));
}
#[test]
fn test_verify_discord_request_signature_empty_public_key() {
let mut headers = HashMap::new();
headers.insert("x-signature-ed25519".to_string(), "00".repeat(64));
headers.insert(
"x-signature-timestamp".to_string(),
"1234567890".to_string(),
);
assert!(!verify_discord_request_signature(headers, b"abc", Some("")));
}
#[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_broadcast_dm_payload_format() {
// Verify the DM channel creation payload is well-formed JSON that
// Discord's API expects.
let user_id = "123456789012345678";
let payload = serde_json::json!({ "recipient_id": user_id });
let serialized = serde_json::to_vec(&payload).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&serialized).unwrap();
assert_eq!(
parsed.get("recipient_id").and_then(|v| v.as_str()),
Some(user_id)
);
}
#[test]
fn test_broadcast_message_truncation() {
// Broadcast uses truncate_message, verify it handles content within
// Discord's 2000-char limit for DMs.
let short = "Hello from broadcast";
assert_eq!(truncate_message(short), short);
let long = "x".repeat(2500);
let result = truncate_message(&long);
assert!(result.len() <= 2006); // 1990 content + 16 suffix
assert!(result.ends_with("\n... (truncated)"));
}
#[test]
fn test_broadcast_dm_validates_snowflake() {
// broadcast_dm rejects invalid Discord snowflake IDs before making
// any API calls. We can call it directly since invalid IDs are
// rejected before any host function is invoked.
assert!(broadcast_dm("", "hi").is_err());
assert!(broadcast_dm("abc", "hi").is_err());
assert!(broadcast_dm("12345", "hi").is_err()); // too short
assert!(broadcast_dm("123456789012345678901", "hi").is_err()); // too long
assert!(broadcast_dm("12345678901234567x", "hi").is_err()); // non-digit
}
}