mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
- 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]>
1692 lines
54 KiB
Rust
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
|
|
}
|
|
}
|