feat(tools): add on-demand Discord history fetch

Add a Discord history tool that can auto-resolve the active Discord channel from runtime context, enforce safe cross-channel defaults, and return structured message snapshots for downstream reasoning.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
NB😈 2026-02-25 12:26:34 +01:00 committed by Argenis
parent 27c9f8a9fd
commit ca79d9cfcf
5 changed files with 968 additions and 20 deletions

View File

@ -1972,6 +1972,12 @@ pub async fn run(
"Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run (optionally with connected_account_id), 'connect' to OAuth.",
));
}
if config.channels_config.discord.is_some() {
tool_descs.push((
"discord_history_fetch",
"Fetch Discord message history on demand for current conversation context or explicit channel_id.",
));
}
tool_descs.push((
"schedule",
"Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
@ -2388,6 +2394,12 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
if config.composio.enabled {
tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
}
if config.channels_config.discord.is_some() {
tool_descs.push((
"discord_history_fetch",
"Fetch Discord message history on demand for current conversation context or explicit channel_id.",
));
}
if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
tool_descs.push((

View File

@ -81,6 +81,9 @@ use crate::observability::{self, runtime_trace, Observer};
use crate::providers::{self, ChatMessage, Provider};
use crate::runtime;
use crate::security::SecurityPolicy;
use crate::tools::channel_runtime_context::{
with_channel_runtime_context, ChannelRuntimeContext as ToolChannelRuntimeContext,
};
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::{Context, Result};
@ -3299,29 +3302,38 @@ semantic_match={:.2} (threshold {:.2}), category={}.",
prompt_tx: approval_prompt_tx.clone(),
})
};
let runtime_context = ToolChannelRuntimeContext {
channel: msg.channel.clone(),
reply_target: msg.reply_target.clone(),
thread_ts: msg.thread_ts.clone(),
sender: msg.sender.clone(),
message_id: msg.id.clone(),
};
let llm_result = tokio::select! {
() = cancellation_token.cancelled() => LlmExecutionResult::Cancelled,
result = tokio::time::timeout(
Duration::from_secs(timeout_budget_secs),
run_tool_call_loop_with_non_cli_approval_context(
active_provider.as_ref(),
&mut history,
ctx.tools_registry.as_ref(),
ctx.observer.as_ref(),
route.provider.as_str(),
route.model.as_str(),
runtime_defaults.temperature,
true,
Some(ctx.approval_manager.as_ref()),
msg.channel.as_str(),
non_cli_approval_context,
&ctx.multimodal,
ctx.max_tool_iterations,
Some(cancellation_token.clone()),
delta_tx,
ctx.hooks.as_deref(),
&excluded_tools_snapshot,
with_channel_runtime_context(
runtime_context,
run_tool_call_loop_with_non_cli_approval_context(
active_provider.as_ref(),
&mut history,
ctx.tools_registry.as_ref(),
ctx.observer.as_ref(),
route.provider.as_str(),
route.model.as_str(),
runtime_defaults.temperature,
true,
Some(ctx.approval_manager.as_ref()),
msg.channel.as_str(),
non_cli_approval_context,
&ctx.multimodal,
ctx.max_tool_iterations,
Some(cancellation_token.clone()),
delta_tx,
ctx.hooks.as_deref(),
&excluded_tools_snapshot,
),
),
) => LlmExecutionResult::Completed(result),
};
@ -4884,6 +4896,12 @@ pub async fn start_channels(config: Config) -> Result<()> {
"Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover actions, 'list_accounts' to retrieve connected account IDs, 'execute' to run (optionally with connected_account_id), and 'connect' for OAuth.",
));
}
if config.channels_config.discord.is_some() {
tool_descs.push((
"discord_history_fetch",
"Fetch Discord message history on demand for current conversation context or explicit channel_id. Useful for tasks like selecting a random participant from recent chat history.",
));
}
tool_descs.push((
"schedule",
"Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",

View File

@ -0,0 +1,85 @@
use std::future::Future;
/// Runtime context for the currently processed channel message.
///
/// This context is task-scoped and only available while handling channel
/// messages. CLI/background agent turns do not set this context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChannelRuntimeContext {
pub channel: String,
pub reply_target: String,
pub thread_ts: Option<String>,
pub sender: String,
pub message_id: String,
}
tokio::task_local! {
static CHANNEL_RUNTIME_CONTEXT: ChannelRuntimeContext;
}
/// Run `future` with the provided channel runtime context scoped to this task.
pub async fn with_channel_runtime_context<F, T>(ctx: ChannelRuntimeContext, future: F) -> T
where
F: Future<Output = T>,
{
CHANNEL_RUNTIME_CONTEXT.scope(ctx, future).await
}
/// Return the current channel runtime context, if one is set for this task.
pub fn current_channel_runtime_context() -> Option<ChannelRuntimeContext> {
CHANNEL_RUNTIME_CONTEXT
.try_with(ChannelRuntimeContext::clone)
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_context(channel: &str, reply_target: &str) -> ChannelRuntimeContext {
ChannelRuntimeContext {
channel: channel.to_string(),
reply_target: reply_target.to_string(),
thread_ts: Some("thread-1".to_string()),
sender: "user_a".to_string(),
message_id: "msg_123".to_string(),
}
}
#[tokio::test]
async fn context_unavailable_outside_scope() {
assert!(current_channel_runtime_context().is_none());
}
#[tokio::test]
async fn context_available_inside_scope() {
let ctx = sample_context("discord", "C123");
let seen = with_channel_runtime_context(ctx.clone(), async {
current_channel_runtime_context().expect("context should be available")
})
.await;
assert_eq!(seen, ctx);
assert!(current_channel_runtime_context().is_none());
}
#[tokio::test]
async fn parallel_scopes_do_not_leak_context() {
let ctx_a = sample_context("discord", "C111");
let ctx_b = sample_context("discord", "C222");
let (seen_a, seen_b) = tokio::join!(
with_channel_runtime_context(ctx_a.clone(), async {
tokio::task::yield_now().await;
current_channel_runtime_context().expect("context A should be available")
}),
with_channel_runtime_context(ctx_b.clone(), async {
tokio::task::yield_now().await;
current_channel_runtime_context().expect("context B should be available")
})
);
assert_eq!(seen_a.reply_target, "C111");
assert_eq!(seen_b.reply_target, "C222");
}
}

View File

@ -0,0 +1,778 @@
use super::channel_runtime_context::current_channel_runtime_context;
use super::traits::{Tool, ToolResult};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashSet;
use std::sync::Arc;
const DEFAULT_LIMIT: i64 = 50;
const MIN_LIMIT: i64 = 1;
const MAX_LIMIT: i64 = 100;
const DEFAULT_DISCORD_API_BASE: &str = "https://discord.com/api/v10";
pub struct DiscordHistoryFetchTool {
security: Arc<SecurityPolicy>,
bot_token: String,
api_base_url: String,
client: reqwest::Client,
}
#[derive(Debug, Deserialize)]
struct DiscordApiMessage {
#[serde(default)]
id: String,
#[serde(default)]
timestamp: String,
#[serde(default)]
content: String,
#[serde(default)]
attachments: Vec<DiscordApiAttachment>,
#[serde(default)]
author: DiscordApiAuthor,
#[serde(default, rename = "type")]
message_type: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
struct DiscordApiAuthor {
#[serde(default)]
id: String,
#[serde(default)]
username: String,
#[serde(default)]
global_name: Option<String>,
#[serde(default)]
bot: bool,
}
#[derive(Debug, Deserialize)]
struct DiscordApiAttachment {
#[serde(default)]
id: String,
#[serde(default)]
filename: String,
#[serde(default)]
content_type: Option<String>,
#[serde(default)]
size: Option<u64>,
#[serde(default)]
url: Option<String>,
}
#[derive(Debug, Serialize)]
struct HistoryFetchOutput {
channel_id: String,
fetched_count: usize,
unique_human_authors: usize,
messages: Vec<HistoryMessage>,
}
#[derive(Debug, Serialize)]
struct HistoryMessage {
id: String,
timestamp: String,
author: HistoryAuthor,
content: String,
attachments: Vec<HistoryAttachment>,
}
#[derive(Debug, Serialize)]
struct HistoryAuthor {
id: String,
username: String,
display_name: String,
is_bot: bool,
}
#[derive(Debug, Serialize)]
struct HistoryAttachment {
id: String,
filename: String,
content_type: Option<String>,
size: Option<u64>,
url: Option<String>,
}
impl DiscordHistoryFetchTool {
pub fn new(security: Arc<SecurityPolicy>, bot_token: String) -> Self {
Self {
security,
bot_token,
api_base_url: DEFAULT_DISCORD_API_BASE.to_string(),
client: crate::config::build_runtime_proxy_client_with_timeouts(
"tool.discord_history_fetch",
30,
10,
),
}
}
#[cfg(test)]
fn new_with_base_url(
security: Arc<SecurityPolicy>,
bot_token: String,
api_base_url: String,
) -> Self {
Self {
security,
bot_token,
api_base_url,
client: reqwest::Client::new(),
}
}
fn error_result(message: impl Into<String>) -> ToolResult {
ToolResult {
success: false,
output: String::new(),
error: Some(message.into()),
}
}
fn trim_opt_string(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
}
fn parse_bool(args: &serde_json::Value, key: &str, default: bool) -> bool {
args.get(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(default)
}
fn parse_limit(args: &serde_json::Value) -> i64 {
let raw = args
.get("limit")
.and_then(serde_json::Value::as_i64)
.unwrap_or(DEFAULT_LIMIT);
raw.clamp(MIN_LIMIT, MAX_LIMIT)
}
fn parse_cursor_args(
args: &serde_json::Value,
) -> anyhow::Result<(Option<String>, Option<String>, Option<String>)> {
let before = Self::trim_opt_string(args.get("before_message_id").and_then(|v| v.as_str()));
let after = Self::trim_opt_string(args.get("after_message_id").and_then(|v| v.as_str()));
let around = Self::trim_opt_string(args.get("around_message_id").and_then(|v| v.as_str()));
let set_count = [before.is_some(), after.is_some(), around.is_some()]
.into_iter()
.filter(|v| *v)
.count();
if set_count > 1 {
anyhow::bail!(
"Only one of before_message_id, after_message_id, or around_message_id may be set"
);
}
Ok((before, after, around))
}
fn resolve_channel_id(
&self,
args: &serde_json::Value,
allow_cross_channel: bool,
) -> anyhow::Result<String> {
let explicit_channel_id =
Self::trim_opt_string(args.get("channel_id").and_then(|v| v.as_str()));
let context = current_channel_runtime_context();
match (context, explicit_channel_id) {
(Some(ctx), Some(channel_id)) => {
if ctx.channel == "discord"
&& !allow_cross_channel
&& !ctx.reply_target.is_empty()
&& channel_id != ctx.reply_target
{
anyhow::bail!(
"Cross-channel fetch blocked: requested channel_id differs from current Discord conversation. Set allow_cross_channel=true to override."
);
}
Ok(channel_id)
}
(Some(ctx), None) if ctx.channel == "discord" => {
let reply_target = ctx.reply_target.trim();
if reply_target.is_empty() {
anyhow::bail!(
"Current Discord runtime context has an empty reply_target; pass channel_id explicitly"
);
}
Ok(reply_target.to_string())
}
(Some(ctx), None) => anyhow::bail!(
"channel_id is required outside Discord runtime context (current channel={})",
ctx.channel
),
(None, Some(channel_id)) => Ok(channel_id),
(None, None) => {
anyhow::bail!("channel_id is required when no Discord runtime context is available")
}
}
}
fn is_system_message(message_type: Option<u64>) -> bool {
// Discord type 0 is a regular chat message. Non-zero types include
// system/service-style messages and non-standard events.
message_type.unwrap_or(0) != 0
}
fn message_url(&self, channel_id: &str) -> String {
format!(
"{}/channels/{channel_id}/messages",
self.api_base_url.trim_end_matches('/')
)
}
fn display_name(author: &DiscordApiAuthor) -> String {
author
.global_name
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
.unwrap_or_else(|| author.username.clone())
}
fn render_history_output(
&self,
channel_id: String,
mut messages: Vec<DiscordApiMessage>,
include_bots: bool,
include_system: bool,
include_content: bool,
include_attachments: bool,
) -> anyhow::Result<ToolResult> {
// Discord API returns newest-first; return oldest-first for predictable
// downstream reasoning and sampling.
messages.reverse();
let mut human_authors = HashSet::new();
let mut rendered_messages = Vec::new();
for msg in messages {
if !include_bots && msg.author.bot {
continue;
}
if !include_system && Self::is_system_message(msg.message_type) {
continue;
}
if !msg.author.bot && !msg.author.id.trim().is_empty() {
human_authors.insert(msg.author.id.clone());
}
let display_name = Self::display_name(&msg.author);
let author = HistoryAuthor {
id: msg.author.id.clone(),
username: msg.author.username.clone(),
display_name,
is_bot: msg.author.bot,
};
let attachments = if include_attachments {
msg.attachments
.into_iter()
.map(|att| HistoryAttachment {
id: att.id,
filename: att.filename,
content_type: att.content_type,
size: att.size,
url: att.url,
})
.collect()
} else {
Vec::new()
};
rendered_messages.push(HistoryMessage {
id: msg.id,
timestamp: msg.timestamp,
author,
content: if include_content {
msg.content
} else {
String::new()
},
attachments,
});
}
let output = HistoryFetchOutput {
channel_id,
fetched_count: rendered_messages.len(),
unique_human_authors: human_authors.len(),
messages: rendered_messages,
};
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&output)?,
error: None,
})
}
}
#[async_trait]
impl Tool for DiscordHistoryFetchTool {
fn name(&self) -> &str {
"discord_history_fetch"
}
fn description(&self) -> &str {
"Fetch Discord channel message history on demand. In Discord runtime it auto-targets the current conversation by default; outside Discord runtime pass channel_id explicitly."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"channel_id": {
"type": "string",
"description": "Discord channel ID. Optional in Discord runtime (auto-resolves to current conversation); required elsewhere."
},
"limit": {
"type": "integer",
"description": "Number of messages to fetch (clamped to 1..100). Default: 50.",
"default": 50
},
"before_message_id": {
"type": "string",
"description": "Fetch messages before this message ID."
},
"after_message_id": {
"type": "string",
"description": "Fetch messages after this message ID."
},
"around_message_id": {
"type": "string",
"description": "Fetch messages around this message ID."
},
"include_bots": {
"type": "boolean",
"description": "Include bot-authored messages. Default: false.",
"default": false
},
"include_system": {
"type": "boolean",
"description": "Include non-standard/system Discord message types. Default: false.",
"default": false
},
"include_content": {
"type": "boolean",
"description": "Include message content. Default: true.",
"default": true
},
"include_attachments": {
"type": "boolean",
"description": "Include attachment metadata. Default: true.",
"default": true
},
"allow_cross_channel": {
"type": "boolean",
"description": "When in Discord runtime, allow explicit channel_id different from current conversation. Default: false.",
"default": false
}
}
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
if !self.security.can_act() {
return Ok(Self::error_result("Action blocked: autonomy is read-only"));
}
if !self.security.record_action() {
return Ok(Self::error_result("Action blocked: rate limit exceeded"));
}
let token = self.bot_token.trim();
if token.is_empty() {
return Ok(Self::error_result(
"Discord history fetch requires channels_config.discord.bot_token",
));
}
let allow_cross_channel = Self::parse_bool(&args, "allow_cross_channel", false);
let channel_id = match self.resolve_channel_id(&args, allow_cross_channel) {
Ok(v) => v,
Err(e) => return Ok(Self::error_result(e.to_string())),
};
let limit = Self::parse_limit(&args);
let include_bots = Self::parse_bool(&args, "include_bots", false);
let include_system = Self::parse_bool(&args, "include_system", false);
let include_content = Self::parse_bool(&args, "include_content", true);
let include_attachments = Self::parse_bool(&args, "include_attachments", true);
let (before, after, around) = match Self::parse_cursor_args(&args) {
Ok(v) => v,
Err(e) => return Ok(Self::error_result(e.to_string())),
};
let mut query: Vec<(String, String)> = vec![("limit".to_string(), limit.to_string())];
if let Some(v) = before {
query.push(("before".to_string(), v));
}
if let Some(v) = after {
query.push(("after".to_string(), v));
}
if let Some(v) = around {
query.push(("around".to_string(), v));
}
let response = match self
.client
.get(self.message_url(&channel_id))
.header("Authorization", format!("Bot {token}"))
.query(&query)
.send()
.await
{
Ok(resp) => resp,
Err(err) => {
return Ok(Self::error_result(format!(
"Discord history request failed: {err}"
)))
}
};
let status = response.status();
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = response
.text()
.await
.unwrap_or_else(|err| format!("<failed to read response body: {err}>"));
if status.as_u16() == 429 {
let suffix = if retry_after.is_empty() {
String::new()
} else {
format!(" Retry-After: {retry_after}.")
};
return Ok(Self::error_result(format!(
"Discord API rate limited (429).{suffix} Response: {body}"
)));
}
if !status.is_success() {
return Ok(Self::error_result(format!(
"Discord history fetch failed ({}): {body}",
status
)));
}
let messages: Vec<DiscordApiMessage> = match serde_json::from_str(&body) {
Ok(parsed) => parsed,
Err(err) => {
return Ok(Self::error_result(format!(
"Discord history fetch returned invalid JSON: {err}"
)))
}
};
self.render_history_output(
channel_id,
messages,
include_bots,
include_system,
include_content,
include_attachments,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::channel_runtime_context::{
with_channel_runtime_context, ChannelRuntimeContext,
};
use serde_json::Value;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_context(channel: &str, reply_target: &str) -> ChannelRuntimeContext {
ChannelRuntimeContext {
channel: channel.to_string(),
reply_target: reply_target.to_string(),
thread_ts: None,
sender: "user_a".to_string(),
message_id: "discord_123".to_string(),
}
}
fn test_tool(base_url: String) -> DiscordHistoryFetchTool {
DiscordHistoryFetchTool::new_with_base_url(
Arc::new(SecurityPolicy::default()),
"test-token".to_string(),
base_url,
)
}
async fn execute_with_ctx(
tool: &DiscordHistoryFetchTool,
ctx: ChannelRuntimeContext,
args: serde_json::Value,
) -> ToolResult {
with_channel_runtime_context(ctx, async { tool.execute(args).await.unwrap() }).await
}
#[tokio::test]
async fn resolve_requires_channel_id_without_context() {
let tool = test_tool("http://localhost".to_string());
let result = tool.execute(json!({})).await.unwrap();
assert!(!result.success);
assert!(
result
.error
.unwrap_or_default()
.contains("channel_id is required"),
"unexpected error message"
);
}
#[tokio::test]
async fn resolve_requires_channel_id_outside_discord_context() {
let tool = test_tool("http://localhost".to_string());
let result = execute_with_ctx(&tool, test_context("telegram", "chat_1"), json!({})).await;
assert!(!result.success);
assert!(
result
.error
.unwrap_or_default()
.contains("required outside Discord runtime context"),
"unexpected error message"
);
}
#[tokio::test]
async fn resolve_uses_current_discord_reply_target_when_channel_id_omitted() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/channels/C456/messages"))
.and(query_param("limit", "50"))
.and(header("authorization", "Bot test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&server)
.await;
let tool = test_tool(server.uri());
let result = execute_with_ctx(&tool, test_context("discord", "C456"), json!({})).await;
assert!(result.success);
let parsed: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(parsed["channel_id"], "C456");
assert_eq!(parsed["fetched_count"], 0);
}
#[tokio::test]
async fn resolve_blocks_cross_channel_by_default() {
let tool = test_tool("http://localhost".to_string());
let result = execute_with_ctx(
&tool,
test_context("discord", "C456"),
json!({"channel_id": "C123"}),
)
.await;
assert!(!result.success);
assert!(
result
.error
.unwrap_or_default()
.contains("Cross-channel fetch blocked"),
"unexpected error message"
);
}
#[tokio::test]
async fn resolve_allows_cross_channel_with_opt_in() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/channels/C123/messages"))
.and(query_param("limit", "50"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&server)
.await;
let tool = test_tool(server.uri());
let result = execute_with_ctx(
&tool,
test_context("discord", "C456"),
json!({"channel_id": "C123", "allow_cross_channel": true}),
)
.await;
assert!(result.success);
let parsed: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(parsed["channel_id"], "C123");
}
#[tokio::test]
async fn successful_fetch_is_oldest_first_and_filters_default_bot_system() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/channels/C123/messages"))
.and(query_param("limit", "50"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{
"id": "3",
"timestamp": "2026-02-25T03:00:00.000000+00:00",
"content": "bot message",
"type": 0,
"author": {"id": "bot_1", "username": "bot", "bot": true},
"attachments": []
},
{
"id": "2",
"timestamp": "2026-02-25T02:00:00.000000+00:00",
"content": "system message",
"type": 1,
"author": {"id": "u_2", "username": "user2", "bot": false},
"attachments": []
},
{
"id": "1",
"timestamp": "2026-02-25T01:00:00.000000+00:00",
"content": "hello",
"type": 0,
"author": {"id": "u_1", "username": "user1", "global_name": "User One", "bot": false},
"attachments": [{
"id": "a1",
"filename": "file.txt",
"content_type": "text/plain",
"size": 12,
"url": "https://cdn.discordapp.com/file.txt"
}]
}
])))
.mount(&server)
.await;
let tool = test_tool(server.uri());
let result = tool.execute(json!({"channel_id": "C123"})).await.unwrap();
assert!(result.success);
let parsed: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(parsed["fetched_count"], 1);
assert_eq!(parsed["unique_human_authors"], 1);
assert_eq!(parsed["messages"][0]["id"], "1");
assert_eq!(parsed["messages"][0]["author"]["display_name"], "User One");
assert_eq!(parsed["messages"][0]["attachments"][0]["id"], "a1");
}
#[tokio::test]
async fn include_flags_keep_messages_but_strip_content_and_attachments_when_disabled() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/channels/C777/messages"))
.and(query_param("limit", "50"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{
"id": "2",
"timestamp": "2026-02-25T02:00:00.000000+00:00",
"content": "bot message",
"type": 0,
"author": {"id": "bot_1", "username": "bot", "bot": true},
"attachments": [{"id": "a2", "filename": "f.txt", "url": "https://cdn.example/f.txt"}]
},
{
"id": "1",
"timestamp": "2026-02-25T01:00:00.000000+00:00",
"content": "system message",
"type": 1,
"author": {"id": "u_1", "username": "user1", "bot": false},
"attachments": [{"id": "a1", "filename": "g.txt", "url": "https://cdn.example/g.txt"}]
}
])))
.mount(&server)
.await;
let tool = test_tool(server.uri());
let result = tool
.execute(json!({
"channel_id": "C777",
"include_bots": true,
"include_system": true,
"include_content": false,
"include_attachments": false
}))
.await
.unwrap();
assert!(result.success);
let parsed: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(parsed["fetched_count"], 2);
assert_eq!(parsed["messages"][0]["id"], "1");
assert_eq!(parsed["messages"][1]["id"], "2");
assert_eq!(parsed["messages"][0]["content"], "");
assert_eq!(
parsed["messages"][0]["attachments"]
.as_array()
.unwrap()
.len(),
0
);
}
#[tokio::test]
async fn limit_is_clamped_to_discord_max() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/channels/C123/messages"))
.and(query_param("limit", "100"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&server)
.await;
let tool = test_tool(server.uri());
let result = tool
.execute(json!({"channel_id": "C123", "limit": 999}))
.await
.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn returns_actionable_error_for_rate_limit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/channels/C999/messages"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("Retry-After", "2")
.set_body_string("{\"message\":\"Too Many Requests\"}"),
)
.mount(&server)
.await;
let tool = test_tool(server.uri());
let result = tool.execute(json!({"channel_id": "C999"})).await.unwrap();
assert!(!result.success);
let err = result.error.unwrap_or_default();
assert!(err.contains("rate limited (429)"));
assert!(err.contains("Retry-After: 2"));
}
#[tokio::test]
async fn returns_actionable_error_for_non_success_status() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/channels/C403/messages"))
.respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
.mount(&server)
.await;
let tool = test_tool(server.uri());
let result = tool.execute(json!({"channel_id": "C403"})).await.unwrap();
assert!(!result.success);
let err = result.error.unwrap_or_default();
assert!(err.contains("Discord history fetch failed (403 Forbidden)"));
assert!(err.contains("forbidden"));
}
}

View File

@ -19,6 +19,7 @@ pub mod agents_ipc;
pub mod apply_patch;
pub mod browser;
pub mod browser_open;
pub mod channel_runtime_context;
pub mod cli_discovery;
pub mod composio;
pub mod content_search;
@ -30,6 +31,7 @@ pub mod cron_runs;
pub mod cron_update;
pub mod delegate;
pub mod delegate_coordination_status;
pub mod discord_history_fetch;
pub mod file_edit;
pub mod file_read;
pub mod file_write;
@ -79,6 +81,7 @@ pub use cron_runs::CronRunsTool;
pub use cron_update::CronUpdateTool;
pub use delegate::DelegateTool;
pub use delegate_coordination_status::DelegateCoordinationStatusTool;
pub use discord_history_fetch::DiscordHistoryFetchTool;
pub use file_edit::FileEditTool;
pub use file_read::FileReadTool;
pub use file_write::FileWriteTool;
@ -277,6 +280,16 @@ pub fn all_tools_with_runtime(
)),
];
if let Some(discord) = root_config.channels_config.discord.as_ref() {
let token = discord.bot_token.trim();
if !token.is_empty() {
tool_arcs.push(Arc::new(DiscordHistoryFetchTool::new(
security.clone(),
token.to_string(),
)));
}
}
if has_shell_access {
tool_arcs.push(Arc::new(ShellTool::new_with_syscall_detector(
security.clone(),
@ -529,7 +542,7 @@ pub fn all_tools_with_runtime(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{BrowserConfig, Config, MemoryConfig, WasmRuntimeConfig};
use crate::config::{BrowserConfig, Config, DiscordConfig, MemoryConfig, WasmRuntimeConfig};
use crate::runtime::WasmRuntime;
use tempfile::TempDir;
@ -611,12 +624,54 @@ mod tests {
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"browser_open"));
assert!(!names.contains(&"discord_history_fetch"));
assert!(names.contains(&"schedule"));
assert!(names.contains(&"model_routing_config"));
assert!(names.contains(&"pushover"));
assert!(names.contains(&"proxy_config"));
}
#[test]
fn all_tools_includes_discord_history_fetch_when_discord_configured() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let mut cfg = test_config(&tmp);
cfg.channels_config.discord = Some(DiscordConfig {
bot_token: "discord-token".into(),
guild_id: None,
allowed_users: vec!["*".into()],
listen_to_bots: false,
mention_only: false,
});
let tools = all_tools(
Arc::new(cfg.clone()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"discord_history_fetch"));
}
#[test]
fn all_tools_includes_browser_when_enabled() {
let tmp = TempDir::new().unwrap();