mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Add GitHub tool and Discord channel (#34)
* Add GitHub tool for IronClaw - manage repos, issues, PRs, and workflows * Add Discord channel for IronClaw - slash commands and button interactions * Security fixes: URL encoding, secret validation, Discord button handler - Add URL encoding for all path segments and query parameters (P1) - Add path segment validation to prevent path traversal - Add secret_exists check for better error messages (P2) - Fix http_request signature to use 5 args (P2) - Fix Discord button handler to check member field (P2) - Fix typo in Discord slash command format (P2) - Add github.capabilities.json and discord.capabilities.json (Blocker) - Add Cargo.toml for Discord channel (Blocker) - Add limit caps (max 100) for all list operations (P3) - Remove debug logging * Apply Copilot review fixes Security & Code Quality: - Use secret_get instead of workspace_read for GitHub token - Remove manual Authorization header (host injects via capabilities) - Add validation for file paths (reject path traversal) - Add validation for workflow_id and git refs - Fix url_encode_query comment - Add release profile optimizations to Cargo.toml files - Fix package names to match conventions (github-tool, discord-channel) - Add metadata fields to Cargo.toml - Fix rate limits to be consistent (60/min, 3600/hr) - Fix Discord user_name to filter empty global_name - Fix Discord metadata serialization error handling - Update Discord README to clarify which secrets are used by host vs WASM - Better formatting for Discord command option values * applied all PR change requests and comments * cleaned up workspace * Adding validation for empty path segments and event enum in GitHub tool * addedvalidation for events and vaidation to reject empty file path in github tools and implemented safe UTF-8 trunacating * added codegen units and updated truncating logic also update capabilities.json as requested by copilot review * added codegen units and updated truncating logic also update capabilities.json as requested by copilot review * fixed message trucating and remove url_encode alias, also appled all requested changes from last PR comment --------- Co-authored-by: root <root@cafx> Co-authored-by: Peni <[email protected]> Co-authored-by: Illia Polosukhin <[email protected]> Co-authored-by: firat.sertgoz <[email protected]>
This commit is contained in:
co-authored by
root
Peni
Illia Polosukhin
firat.sertgoz
parent
0e981429ee
commit
61a123a746
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "discord-channel"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Discord channel for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wit-bindgen = "0.41.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Discord Channel for IronClaw
|
||||
|
||||
WASM channel for Discord integration - handle slash commands and button interactions via webhooks.
|
||||
|
||||
## Features
|
||||
|
||||
- **Slash Commands** - Process Discord slash commands
|
||||
- **Button Interactions** - Handle button clicks
|
||||
- **Thread Support** - Respond in threads
|
||||
- **DM Support** - Handle direct messages
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a Discord Application at <https://discord.com/developers/applications>
|
||||
2. Create a Bot and get the token
|
||||
3. Set up Interactions URL to point to your IronClaw instance
|
||||
4. Copy the Application ID and Public Key
|
||||
5. Store in IronClaw secrets:
|
||||
|
||||
```bash
|
||||
ironclaw secret set discord_bot_token YOUR_BOT_TOKEN
|
||||
```
|
||||
|
||||
**Note:** The `discord_bot_token` secret is the only value read directly by this
|
||||
Discord channel WASM component. The `discord_app_id` and `discord_public_key`
|
||||
secrets are used by the IronClaw host (for example, to verify Discord
|
||||
interaction signatures and manage slash command registration) and are not
|
||||
accessed from the WASM module itself.
|
||||
|
||||
## Discord Configuration
|
||||
|
||||
### Register Slash Commands
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bot YOUR_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://discord.com/api/v10/applications/YOUR_APP_ID/commands \
|
||||
-d '{
|
||||
"name": "ask",
|
||||
"description": "Ask the AI agent",
|
||||
"options": [{
|
||||
"name": "question",
|
||||
"description": "Your question",
|
||||
"type": 3,
|
||||
"required": true
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
### Set Interactions Endpoint
|
||||
|
||||
In your Discord app settings, set:
|
||||
|
||||
- Interactions Endpoint URL: `https://your-ironclaw.com/webhook/discord`
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Slash Command
|
||||
|
||||
User types: `/ask question: What is the weather?`
|
||||
|
||||
The agent receives:
|
||||
|
||||
```text
|
||||
User: @username
|
||||
Content: /ask question: What is the weather?
|
||||
```
|
||||
|
||||
### Button Click
|
||||
|
||||
When a user clicks a button in a message, the agent receives:
|
||||
|
||||
```text
|
||||
User: @username
|
||||
Content: [Button clicked] Original message content
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
If an internal error occurs (e.g., metadata serialization failure), the tool attempts to send an ephemeral message to the user:
|
||||
|
||||
```text
|
||||
❌ Internal Error: Failed to process command metadata.
|
||||
```
|
||||
|
||||
Check the host logs for detailed error information.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Embeds
|
||||
|
||||
To send embeds, include an `embeds` array in the `metadata_json` field of the agent's response. The structure should match the Discord API `embed` object.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid Signature"
|
||||
|
||||
- Check that `discord_public_key` is set correctly in IronClaw secrets.
|
||||
- This validation happens on the host before reaching the WASM.
|
||||
|
||||
### "401 Unauthorized"
|
||||
|
||||
- Check that `discord_bot_token` is set correctly in IronClaw secrets.
|
||||
- Ensure the bot is added to the server.
|
||||
|
||||
### "Interaction Failed"
|
||||
|
||||
- The interaction might have timed out (Discord requires a response within 3 seconds).
|
||||
- The `interactions_endpoint_url` might be unreachable.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd channels-src/discord
|
||||
cargo build --target wasm32-wasi --release
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT/Apache-2.0
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"type": "channel",
|
||||
"name": "discord",
|
||||
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "discord.com", "path_prefix": "/api/v10" }
|
||||
],
|
||||
"credentials": {
|
||||
"discord_bot_token": {
|
||||
"secret_name": "discord_bot_token",
|
||||
"location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " },
|
||||
"host_patterns": ["discord.com"]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 60,
|
||||
"requests_per_hour": 3600
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["discord_bot_token", "discord_*"]
|
||||
},
|
||||
"channel": {
|
||||
"allowed_paths": ["/webhook/discord"],
|
||||
"allow_polling": false,
|
||||
"callback_timeout_secs": 45,
|
||||
"workspace_prefix": "channels/discord/",
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"require_signature_verification": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! 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>,
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
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 => {
|
||||
handle_slash_command(&interaction);
|
||||
json_response(
|
||||
200,
|
||||
serde_json::json!({
|
||||
"type": 5,
|
||||
"data": {
|
||||
"content": "🤔 Thinking..."
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_slash_command(interaction: &DiscordInteraction) {
|
||||
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 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),
|
||||
);
|
||||
// Attempt to notify user of internal error
|
||||
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 // 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;
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id,
|
||||
user_name: Some(user_name),
|
||||
content,
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
});
|
||||
}
|
||||
|
||||
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 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,
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user