mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Port Telegram's permission model (owner_id, dm_policy, allow_from, pairing codes)
to Discord, Slack, and WhatsApp WASM channels. Add web UI for configuration and
pairing approval. Fix extension registry issues preventing Discord install and
causing Slack activation to hit the wrong endpoint.
WASM channels:
- Discord: add DiscordConfig, permission checks, ephemeral pairing replies,
fix capabilities.json (header_name→name), downgrade wit-bindgen to 0.36
- Slack: expand SlackConfig with permission fields, add check_sender_permission
and send_pairing_reply via chat.postMessage
- WhatsApp: expand WhatsAppConfig with permission fields, add permission checks
and pairing reply via Cloud API
- Telegram: reformat capabilities.json, add setup.required_secrets
Extension system:
- Add Discord to KNOWN_CHANNELS in bundled.rs and to extension registry
- Rename "slack" MCP→"slack-mcp", "slack-channel"→"slack" to fix name collision
- Add ExtensionSource::Bundled variant handling in discovery.rs
- Add get_setup_schema/save_setup_secrets to ExtensionManager
- Add needs_setup field to InstalledExtension
Web gateway:
- Add GET/POST /api/extensions/{name}/setup for configuration modal
- Add GET /api/pairing/{channel} and POST /api/pairing/{channel}/approve
- Add configure modal UI (password fields, provided badges, auto-generate hints)
- Add pairing request UI on active WASM channel cards
- Show "Restart to activate" label instead of Activate button for WASM channels
Co-authored-by: Claude Opus 4.6 <[email protected]>
687 lines
21 KiB
Rust
687 lines
21 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 by the host (webhook secrets)
|
|
//! - Bot token is injected by host during HTTP requests
|
|
//! - WASM never sees raw credentials
|
|
|
|
wit_bindgen::generate!({
|
|
world: "sandboxed-channel",
|
|
path: "../../wit/channel.wit",
|
|
});
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use exports::near::agent::channel::{
|
|
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
|
OutgoingHttpResponse, 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,
|
|
}
|
|
|
|
/// Metadata stored with emitted messages for response routing.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct DiscordMessageMetadata {
|
|
/// Discord channel ID
|
|
channel_id: String,
|
|
|
|
/// Interaction ID for followups
|
|
interaction_id: String,
|
|
|
|
/// Interaction token for responding
|
|
token: String,
|
|
|
|
/// Application ID
|
|
application_id: String,
|
|
|
|
/// Thread ID (for forum threads)
|
|
thread_id: Option<String>,
|
|
}
|
|
|
|
/// 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";
|
|
|
|
/// Channel configuration from capabilities file.
|
|
#[derive(Debug, Deserialize)]
|
|
struct DiscordConfig {
|
|
#[serde(default)]
|
|
#[allow(dead_code)]
|
|
require_signature_verification: bool,
|
|
#[serde(default)]
|
|
owner_id: Option<String>,
|
|
#[serde(default)]
|
|
dm_policy: Option<String>,
|
|
#[serde(default)]
|
|
allow_from: Option<Vec<String>>,
|
|
}
|
|
|
|
struct DiscordChannel;
|
|
|
|
impl Guest for DiscordChannel {
|
|
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
|
let config: DiscordConfig = serde_json::from_str(&config_json)
|
|
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
|
|
|
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
|
|
|
|
// Persist owner_id so subsequent callbacks can read it
|
|
if let Some(ref owner_id) = config.owner_id {
|
|
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
&format!("Owner restriction enabled: user {}", owner_id),
|
|
);
|
|
} else {
|
|
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
|
}
|
|
|
|
// Persist dm_policy and allow_from for DM pairing
|
|
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
|
|
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
|
|
|
|
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
|
.unwrap_or_else(|_| "[]".to_string());
|
|
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
|
|
|
Ok(ChannelConfig {
|
|
display_name: "Discord".to_string(),
|
|
http_endpoints: vec![HttpEndpointConfig {
|
|
path: "/webhook/discord".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: true,
|
|
}],
|
|
poll: None,
|
|
})
|
|
}
|
|
|
|
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
|
|
let body_str = match std::str::from_utf8(&req.body) {
|
|
Ok(s) => s,
|
|
Err(_) => {
|
|
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
|
|
}
|
|
};
|
|
|
|
let interaction: DiscordInteraction = match serde_json::from_str(body_str) {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to parse Discord interaction: {}", e),
|
|
);
|
|
return json_response(400, serde_json::json!({"error": "Invalid interaction"}));
|
|
}
|
|
};
|
|
|
|
match interaction.interaction_type {
|
|
// Ping - Discord verification
|
|
1 => {
|
|
channel_host::log(channel_host::LogLevel::Info, "Responding to Discord ping");
|
|
json_response(200, serde_json::json!({"type": 1}))
|
|
}
|
|
|
|
// Application Command (slash command)
|
|
2 => {
|
|
if handle_slash_command(&interaction) {
|
|
json_response(200, serde_json::json!({"type": 5}))
|
|
} else {
|
|
// Permission denied — ephemeral response
|
|
json_response(
|
|
200,
|
|
serde_json::json!({
|
|
"type": 4,
|
|
"data": {
|
|
"content": "You are not authorized to use this bot.",
|
|
"flags": 64
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Message Component (buttons, selects)
|
|
3 => {
|
|
if let Some(ref message) = interaction.message {
|
|
handle_message_component(&interaction, message);
|
|
}
|
|
json_response(200, serde_json::json!({"type": 6}))
|
|
}
|
|
|
|
_ => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Warn,
|
|
&format!(
|
|
"Unknown Discord interaction type: {}",
|
|
interaction.interaction_type
|
|
),
|
|
);
|
|
json_response(200, serde_json::json!({"type": 6}))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_poll() {}
|
|
|
|
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))?;
|
|
|
|
// Use webhook endpoint for followup
|
|
let url = format!(
|
|
"https://discord.com/api/v10/webhooks/{}/{}",
|
|
metadata.application_id, metadata.token
|
|
);
|
|
|
|
// 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 result = channel_host::http_request(
|
|
"POST",
|
|
&url,
|
|
&headers.to_string(),
|
|
Some(&payload_bytes),
|
|
None,
|
|
);
|
|
|
|
match result {
|
|
Ok(http_response) => {
|
|
if http_response.status >= 200 && http_response.status < 300 {
|
|
channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord");
|
|
Ok(())
|
|
} else {
|
|
let body_str = String::from_utf8_lossy(&http_response.body);
|
|
Err(format!(
|
|
"Discord API error: {} - {}",
|
|
http_response.status, body_str
|
|
))
|
|
}
|
|
}
|
|
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
fn on_status(_update: StatusUpdate) {}
|
|
|
|
fn on_shutdown() {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
"Discord channel shutting down",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Returns true if the message was emitted, false if permission denied.
|
|
fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
|
|
let user = interaction
|
|
.member
|
|
.as_ref()
|
|
.map(|m| &m.user)
|
|
.or(interaction.user.as_ref());
|
|
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
|
|
let user_name = user
|
|
.map(|u| {
|
|
u.global_name
|
|
.as_ref()
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or(&u.username)
|
|
.clone()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
// DM if no guild member context (only direct user field set)
|
|
let is_dm = interaction.member.is_none();
|
|
|
|
// Permission check
|
|
if !check_sender_permission(
|
|
&user_id,
|
|
Some(&user_name),
|
|
is_dm,
|
|
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: interaction.id.clone(),
|
|
token: interaction.token.clone(),
|
|
application_id: interaction.application_id.clone(),
|
|
thread_id: None,
|
|
};
|
|
|
|
let metadata_json = match serde_json::to_string(&metadata) {
|
|
Ok(json) => json,
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Failed to serialize metadata: {}", e),
|
|
);
|
|
let url = format!(
|
|
"https://discord.com/api/v10/webhooks/{}/{}",
|
|
interaction.application_id, interaction.token
|
|
);
|
|
let payload = serde_json::json!({
|
|
"content": "❌ Internal Error: Failed to process command metadata.",
|
|
"flags": 64
|
|
});
|
|
let _ = channel_host::http_request(
|
|
"POST",
|
|
&url,
|
|
&serde_json::json!({"Content-Type": "application/json"}).to_string(),
|
|
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
|
|
None,
|
|
);
|
|
return true; // Error, but not a permission denial
|
|
}
|
|
};
|
|
|
|
channel_host::emit_message(&EmittedMessage {
|
|
user_id,
|
|
user_name: Some(user_name),
|
|
content,
|
|
thread_id: None,
|
|
metadata_json,
|
|
});
|
|
true
|
|
}
|
|
|
|
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
|
|
let user = interaction
|
|
.member
|
|
.as_ref()
|
|
.map(|m| &m.user)
|
|
.or(interaction.user.as_ref());
|
|
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
|
|
let user_name = user
|
|
.map(|u| {
|
|
u.global_name
|
|
.as_ref()
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or(&u.username)
|
|
.clone()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let is_dm = interaction.member.is_none();
|
|
if !check_sender_permission(&user_id, Some(&user_name), is_dm, None) {
|
|
return;
|
|
}
|
|
|
|
let channel_id = message.channel_id.clone();
|
|
|
|
let metadata = DiscordMessageMetadata {
|
|
channel_id: channel_id.clone(),
|
|
interaction_id: interaction.id.clone(),
|
|
token: interaction.token.clone(),
|
|
application_id: interaction.application_id.clone(),
|
|
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,
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Permission & Pairing
|
|
// ============================================================================
|
|
|
|
/// 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; // Guild interactions bypass DM policy
|
|
}
|
|
|
|
let dm_policy =
|
|
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
|
|
|
if dm_policy == "open" {
|
|
return true;
|
|
}
|
|
|
|
// 3. Build merged allow list: config allow_from + pairing store
|
|
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default();
|
|
|
|
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
|
allowed.extend(store_allowed);
|
|
}
|
|
|
|
// 4. Check sender against allow list
|
|
let is_allowed = allowed.contains(&"*".to_string())
|
|
|| allowed.contains(&user_id.to_string())
|
|
|| username.is_some_and(|u| allowed.contains(&u.to_string()));
|
|
|
|
if is_allowed {
|
|
return true;
|
|
}
|
|
|
|
// 5. Not allowed — handle by policy
|
|
if dm_policy == "pairing" {
|
|
let meta = serde_json::json!({
|
|
"user_id": user_id,
|
|
"username": username,
|
|
})
|
|
.to_string();
|
|
|
|
match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) {
|
|
Ok(result) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Info,
|
|
&format!(
|
|
"Pairing request for user {}: code {}",
|
|
user_id, result.code
|
|
),
|
|
);
|
|
if result.created {
|
|
if let Some(ctx) = reply_ctx {
|
|
let _ = send_pairing_reply(ctx, &result.code);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
channel_host::log(
|
|
channel_host::LogLevel::Error,
|
|
&format!("Pairing upsert failed: {}", e),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Send a pairing code as an ephemeral Discord followup message.
|
|
fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
|
|
let url = format!(
|
|
"https://discord.com/api/v10/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 // Ephemeral — only visible to the sender
|
|
});
|
|
|
|
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)),
|
|
}
|
|
}
|
|
|
|
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::*;
|
|
|
|
#[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: "456".into(),
|
|
token: "abc".into(),
|
|
application_id: "789".into(),
|
|
thread_id: None,
|
|
};
|
|
let json = serde_json::to_string(&metadata).unwrap();
|
|
let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.channel_id, "123");
|
|
assert_eq!(parsed.interaction_id, "456");
|
|
}
|
|
}
|