use super::DiscordState;
use crate::brain::agent::AgentService;
use crate::channels::background_work::{
FlowOutcome, bg_indicator_for, outcome_for_error, subagent_counts_for, waiting_verb,
};
use crate::channels::group_history;
use crate::config::{Config, RespondTo};
use crate::db::ChannelMessageRepository;
use crate::db::models::ChannelMessage as DbChannelMessage;
use crate::services::SessionService;
use crate::utils::sanitize::redact_secrets;
use crate::utils::truncate_str;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
use uuid::Uuid;
use serenity::builder::{CreateAttachment, CreateMessage};
use serenity::http::Http;
use serenity::model::channel::{Message, MessageFlags, MessageSnapshot};
use serenity::model::id::ChannelId;
use serenity::prelude::*;
fn splits_cleanly(prefix: &str) -> bool {
if !prefix.matches('`').count().is_multiple_of(2) {
return false;
}
let mut depth: i32 = 0;
let mut rest = prefix;
while let Some(open) = rest.find('<') {
let after = &rest[open + 1..];
let Some(close) = after.find('>') else {
return false; };
let tag = &after[..close];
if !tag.starts_with('!') {
if tag.starts_with('/') {
depth -= 1;
} else if !tag.ends_with('/') {
depth += 1;
}
}
rest = &after[close + 1..];
}
depth == 0
}
pub fn split_message(text: &str, max_len: usize) -> Vec<String> {
if text.len() <= max_len {
return vec![text.to_string()];
}
let mut chunks = Vec::new();
let mut start = 0;
let mut reopen = String::new();
while start < text.len() {
let budget = max_len.saturating_sub(reopen.len());
let mut end = (start + budget).min(text.len());
while end < text.len() && !text.is_char_boundary(end) {
end -= 1;
}
let break_at = if end < text.len() {
let window = &text[start..end];
let floor = (end - start).saturating_sub(200);
let want_even = reopen.is_empty();
let mut chosen = None;
for (pos, _) in window.char_indices().rev().filter(|(_, c)| *c == '\n') {
if pos <= floor {
break;
}
if splits_cleanly(&window[..pos]) == want_even {
chosen = Some(start + pos + 1);
break;
}
}
chosen
.or_else(|| {
for (pos, _) in window.char_indices().rev().filter(|(_, c)| *c == '\n') {
if splits_cleanly(&window[..pos]) == want_even {
return Some(start + pos + 1);
}
}
None
})
.unwrap_or_else(|| {
window
.rfind('\n')
.filter(|&pos| pos > floor)
.map(|pos| start + pos + 1)
.unwrap_or(end)
})
} else {
end
};
let piece = &text[start..break_at];
let mut chunk = String::with_capacity(reopen.len() + piece.len() + 8);
chunk.push_str(&reopen);
let piece_bt = piece.matches('`').count();
let fence_open = !(piece_bt + if reopen.is_empty() { 0 } else { 3 }).is_multiple_of(2);
if !fence_open || break_at >= text.len() {
chunk.push_str(piece);
reopen.clear();
} else if start + piece.len() < text.len() {
chunk.push_str(piece);
chunk.push_str("\n```");
reopen = String::from("```\n");
} else {
chunk.push_str(piece);
reopen.clear();
}
chunks.push(chunk);
start = break_at;
}
chunks
}
const FLOW_TICKER_INTERVAL: std::time::Duration = std::time::Duration::from_secs(4);
const FLOW_TICKER_CAP: std::time::Duration = std::time::Duration::from_secs(30 * 60);
pub(super) fn spawn_flow_ticker(
http: Arc<serenity::http::Http>,
channel: serenity::model::id::ChannelId,
group_mid: Arc<Mutex<Option<serenity::model::id::MessageId>>>,
dstate: Arc<super::state::DiscordState>,
) {
tokio::spawn(async move {
use serenity::builder::EditMessage;
let born = std::time::Instant::now();
loop {
tokio::time::sleep(FLOW_TICKER_INTERVAL).await;
if born.elapsed() > FLOW_TICKER_CAP {
break;
}
let Some(mid) = *group_mid.lock().await else {
continue;
};
let Some(group) = dstate.tool_group_snapshot(mid.get()).await else {
break; };
if group.settled.is_some() {
break; }
if !dstate.governor.chrome_admits(channel.get()) {
tracing::debug!(
"Discord: flow ticker edit dropped by governor (mid={})",
mid.get()
);
continue;
}
let edit = EditMessage::new()
.content(super::tool_group::render_content(&group))
.components(super::tool_group::render_components(&group, mid.get()));
if let Err(e) = channel.edit_message(&http, mid, edit).await {
super::governor::note_if_429(&dstate.governor, channel.get(), Some(&e));
tracing::warn!("Discord: flow ticker edit failed (mid={}): {e}", mid.get());
}
super::plan_card::refresh_for_channel(&dstate, &http, channel).await;
match dstate.tool_group_snapshot(mid.get()).await {
Some(re) if re.settled.is_some() => {
dstate
.governor
.gate(channel.get(), super::governor::Surface::Final)
.await;
let edit = EditMessage::new()
.content(super::tool_group::render_content(&re))
.components(super::tool_group::render_components(&re, mid.get()));
if let Err(e) = channel.edit_message(&http, mid, edit).await {
super::governor::note_if_429(&dstate.governor, channel.get(), Some(&e));
tracing::warn!("Discord: flow ticker settle fixup failed: {e}");
}
break;
}
Some(_) => {} None => break, }
}
});
}
pub(crate) fn forwarded_snapshot_text(snapshots: &[MessageSnapshot]) -> String {
let mut out = String::new();
for snap in snapshots {
let text = snap.content.trim();
if !text.is_empty() {
out.push_str(&format!("\n\n[forwarded message]: {text}"));
}
for att in &snap.attachments {
let mime = att.content_type.as_deref().unwrap_or("");
if mime.starts_with("image/") {
out.push_str(&format!(" <<IMG:{}>>", att.url));
} else {
out.push_str(&format!(
"\n[forwarded attachment]: {} {}",
att.filename, att.url
));
}
}
}
out
}
pub(crate) fn folded_message_text(content: &str, snapshots: &[MessageSnapshot]) -> String {
let fwd = forwarded_snapshot_text(snapshots);
if fwd.is_empty() {
content.to_string()
} else if content.trim().is_empty() {
fwd.trim_start().to_string()
} else {
format!("{content}{fwd}")
}
}
pub(crate) enum EmptyContentDecision {
DispatchWith(String),
Drop(&'static str),
}
pub(crate) fn decide_empty_content(
in_mention_mode: bool,
replied_folded: Option<&str>,
) -> EmptyContentDecision {
if in_mention_mode {
EmptyContentDecision::DispatchWith(bare_mention_content(replied_folded))
} else {
EmptyContentDecision::Drop("no content, no attachments, and no qualifying mention")
}
}
pub(crate) fn bare_mention_content(replied_folded: Option<&str>) -> String {
match replied_folded.map(str::trim).filter(|s| !s.is_empty()) {
Some(text) => format!("The user mentioned you in reply to this message:\n\n{text}"),
None => "The user mentioned you with no other content.".to_string(),
}
}
async fn resolve_replied_folded(ctx: &Context, msg: &Message) -> Option<String> {
let reference = msg.message_reference.as_ref()?;
let referenced = reference.message_id?;
let channel = reference.channel_id;
match ctx.http.get_message(channel, referenced).await {
Ok(replied) => Some(folded_message_text(
&replied.content,
&replied.message_snapshots,
)),
Err(e) => {
tracing::warn!(error = %e, "Discord: could not resolve reply target of a bare mention (#1890)");
None
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn handle_message(
ctx: &Context,
msg: &Message,
agent: Arc<AgentService>,
session_svc: SessionService,
shared_session: Arc<Mutex<Option<Uuid>>>,
discord_state: Arc<DiscordState>,
config_rx: tokio::sync::watch::Receiver<Config>,
channel_msg_repo: ChannelMessageRepository,
) {
let cfg = config_rx.borrow().clone();
let dc_cfg = &cfg.channels.discord;
let allowed: HashSet<i64> = dc_cfg
.allowed_users
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let allowed_channels: HashSet<String> = dc_cfg.allowed_channels.iter().cloned().collect();
let idle_timeout_hours = dc_cfg.session_idle_hours;
let voice_config = cfg.voice_config();
let user_id = msg.author.id.get() as i64;
let forwarded_history = forwarded_snapshot_text(&msg.message_snapshots);
let store_channel_msg = |text: String| {
let repo = channel_msg_repo.clone();
let fwd = forwarded_history.clone();
let channel_chat_id = msg.channel_id.get().to_string();
let guild_name = msg
.guild_id
.map(|g| g.get().to_string())
.unwrap_or_else(|| "DM".to_string());
let sender_id = msg.author.id.get().to_string();
let sender_name = msg.author.name.clone();
let msg_id = msg.id.get().to_string();
async move {
let mut text = text;
if !fwd.is_empty() {
if text.is_empty() {
text = fwd.trim_start().to_string();
} else {
text.push_str(&fwd);
}
}
if text.is_empty() {
return;
}
let cm = DbChannelMessage::new(
"discord".into(),
channel_chat_id,
Some(guild_name),
sender_id,
sender_name,
text,
"text".into(),
Some(msg_id),
);
if let Err(e) = repo.insert(&cm).await {
tracing::warn!("Failed to store Discord channel message: {e}");
}
}
};
let is_dm = msg.guild_id.is_none();
let channel_str = msg.channel_id.get().to_string();
let needs_parent = !is_dm && (!allowed_channels.is_empty() || !dc_cfg.channels.is_empty());
let parent_id: Option<String> = if needs_parent {
super::commands::parent_channel_id(&ctx.http, msg.channel_id).await
} else {
None
};
let open_here = !is_dm && dc_cfg.channel_open(&channel_str, parent_id.as_deref());
let respond_to_here = dc_cfg.respond_to_for(&channel_str, parent_id.as_deref());
let respond_to = &respond_to_here;
let is_owner = crate::config::owner::is_owner(
&dc_cfg.allowed_users,
&dc_cfg.bot_owner,
&user_id.to_string(),
);
let in_allowlist = allowed.contains(&user_id);
let role_granted = msg.guild_id.is_some()
&& !dc_cfg.allowed_roles.is_empty()
&& msg.member.as_ref().is_some_and(|m| {
m.roles.iter().any(|r| {
dc_cfg
.allowed_roles
.iter()
.any(|ar| ar == &r.get().to_string())
})
});
let unconfigured =
allowed.is_empty() && dc_cfg.allowed_roles.is_empty() && dc_cfg.bot_owner.is_empty();
if unconfigured || !(is_owner || in_allowlist || role_granted || open_here) {
tracing::debug!(
"Discord: ignoring message from non-allowed user {} (deny-by-default, OC-02)",
user_id
);
return;
}
if !is_dm {
if !allowed_channels.is_empty() && !allowed_channels.contains(&channel_str) {
let parent_allowed = parent_id
.as_deref()
.is_some_and(|p| allowed_channels.contains(p));
if !parent_allowed {
tracing::debug!(
"Discord: ignoring message in non-allowed channel {} (parent not allowed either)",
channel_str
);
store_channel_msg(msg.content.clone()).await;
return;
}
}
match respond_to {
RespondTo::DmOnly => {
tracing::debug!("Discord: respond_to=dm_only, ignoring channel message");
store_channel_msg(msg.content.clone()).await;
return;
}
RespondTo::Mention => {
let bot_id = discord_state.bot_user_id().await;
let mentioned =
bot_id.is_some_and(|bid| msg.mentions.iter().any(|u| u.id.get() == bid));
let owner_scope_command = is_owner
&& (crate::channels::respond_to_scope::is_respond_to_command(&msg.content)
|| super::cowork::is_cowork_command(&msg.content));
if !mentioned && !owner_scope_command {
tracing::debug!("Discord: respond_to=mention, bot not mentioned โ ignoring");
store_channel_msg(msg.content.clone()).await;
return;
}
}
RespondTo::All => {} RespondTo::Auto => {
let bot_id = discord_state.bot_user_id().await;
let mentioned =
bot_id.is_some_and(|bid| msg.mentions.iter().any(|u| u.id.get() == bid));
if !mentioned {
tracing::debug!("Discord: respond_to=auto, bot not mentioned โ ignoring");
store_channel_msg(msg.content.clone()).await;
return;
}
}
}
}
if !is_dm {
store_channel_msg(msg.content.clone()).await;
}
let audio_attachment = msg.attachments.iter().find(|a| {
a.content_type
.as_ref()
.is_some_and(|ct| ct.starts_with("audio/"))
});
let mut is_voice = false;
let mut content = msg.content.clone();
let mut target = msg.channel_id;
if dc_cfg.bang_new_thread && msg.guild_id.is_some() && content.starts_with('!') {
let stripped = content[1..].trim_start().to_string();
if !stripped.is_empty() {
content = stripped;
let title = thread_title(&content);
let body = serde_json::json!({ "name": title });
match ctx
.http
.create_thread_from_message(msg.channel_id, msg.id, &body, None)
.await
{
Ok(thread) => target = thread.id,
Err(e) => {
tracing::warn!("Discord: bang-thread creation failed, replying inline: {e}")
}
}
}
}
if audio_attachment.is_some()
&& voice_config.stt_enabled
&& let Err(e) = msg.channel_id.broadcast_typing(&ctx.http).await
{
tracing::warn!(error = %e, "failed to broadcast Discord typing");
}
if let Some(audio) = audio_attachment
&& voice_config.stt_enabled
&& let Ok(resp) = reqwest::get(&audio.url).await
&& let Ok(bytes) = resp.bytes().await
{
match crate::channels::voice::transcribe(bytes.to_vec(), &voice_config).await {
Ok(transcript) => {
tracing::info!(
"Discord: transcribed voice: {}",
truncate_str(&transcript, 80)
);
content = transcript;
is_voice = true;
}
Err(e) => tracing::error!("Discord: STT error: {e}"),
}
}
if !is_dm
&& respond_to == &RespondTo::Mention
&& let Some(bot_id) = discord_state.bot_user_id().await
{
let mention_tag = format!("<@{}>", bot_id);
content = content.replace(&mention_tag, "").trim().to_string();
}
content = folded_message_text(&content, &msg.message_snapshots);
if content.is_empty() && msg.attachments.is_empty() {
let in_mention_mode = !is_dm && respond_to == &RespondTo::Mention;
let replied_folded = if in_mention_mode {
resolve_replied_folded(ctx, msg).await
} else {
None
};
match decide_empty_content(in_mention_mode, replied_folded.as_deref()) {
EmptyContentDecision::DispatchWith(text) => {
tracing::debug!(
"Discord: bare mention after tag-strip resolved to a dispatch (#1890)"
);
content = text;
}
EmptyContentDecision::Drop(reason) => {
tracing::debug!("Discord: dropping empty message: {reason}");
return;
}
}
}
if !is_voice {
use crate::utils::{inject_file_content, process_file_with_vision};
for attachment in &msg.attachments {
let mime = attachment.content_type.as_deref().unwrap_or("");
let fname = &attachment.filename;
if mime.starts_with("image/") {
if content.is_empty() {
content = "Describe this image.".to_string();
}
content.push_str(&format!(" <<IMG:{}>>", attachment.url));
} else if !mime.starts_with("audio/")
&& let Ok(resp) = reqwest::get(attachment.url.as_str()).await
&& let Ok(bytes) = resp.bytes().await
{
let cfg = config_rx.borrow();
let fc = process_file_with_vision(&bytes, mime, fname, &cfg);
let injected = inject_file_content(&fc).0;
if !injected.is_empty() {
content.push_str(&format!("\n\n{injected}"));
}
}
}
}
if content.is_empty() {
return;
}
let text_preview = truncate_str(&content, 50);
tracing::info!(
"Discord: message from {} ({}): {}",
msg.author.name,
user_id,
text_preview
);
let is_owner = dc_cfg.is_owner(&user_id.to_string());
if is_owner {
discord_state.set_owner_channel(msg.channel_id.get()).await;
}
if let Some(guild_id) = msg.guild_id {
discord_state.set_guild_id(guild_id.get()).await;
}
let session_id = {
use crate::channels::session_resolve;
let (id_str, legacy_title) = if is_dm {
(
format!("discord-dm-{}", msg.author.id.get()),
format!("Discord: DM {} ({})", msg.author.name, msg.author.id.get()),
)
} else {
(
format!("discord-{}", msg.channel_id.get()),
format!("Discord: #{}", msg.channel_id.get()),
)
};
let suffix = session_resolve::chat_id_suffix(&id_str);
let session_title = format!("{legacy_title} {suffix}");
match session_resolve::resolve_or_create_channel_session(
&session_svc,
&suffix,
&legacy_title,
&session_title,
idle_timeout_hours,
"Discord",
)
.await
{
Ok(id) => id,
Err(e) => {
tracing::error!("Discord: failed to resolve session: {e:#} (#442)");
if let Err(send_err) = super::governor::say(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
format!(
"โ ๏ธ Could not load this chat's session ({e}). Your history is \
intact and this message was NOT processed. Try again, or send \
/new if you deliberately want a fresh session."
),
)
.await
{
tracing::warn!(error = %send_err, "failed to send Discord session error message");
}
return;
}
}
};
if !is_dm {
crate::memory::mark_session_shared(session_id);
}
if crate::utils::stop_intent::is_stop_command_or_intent(&msg.content) {
discord_state.cancel_session(session_id).await;
if let Err(e) = super::governor::say(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
"Operation cancelled.",
)
.await
{
tracing::warn!(error = %e, "failed to send Discord message");
}
return;
}
let session_meta = session_svc.get_session(session_id).await.ok().flatten();
crate::channels::commands::sync_provider_for_session(
&agent,
session_id,
session_meta
.as_ref()
.and_then(|s| s.provider_name.as_deref()),
session_meta.as_ref().and_then(|s| s.model.as_deref()),
)
.await;
let cowork_name = if !is_dm && super::cowork::is_cowork_command(&content) {
super::cowork::channel_name(&ctx.http, msg.channel_id).await
} else {
None
};
let scope_reply = super::cowork::cowork_discord_channel(
&content,
is_owner,
!is_dm,
&channel_str,
cowork_name.as_deref(),
super::cowork::write_channel_open,
)
.or_else(|| {
crate::channels::respond_to_scope::respond_to_discord_channel(
&content,
is_owner,
&channel_str,
&respond_to_here,
super::commands::write_channel_respond_to,
)
});
if let Some(reply) = scope_reply {
if let Err(e) = msg.channel_id.say(&ctx.http, reply).await {
tracing::warn!(error = %e, "failed to send Discord message");
}
return;
}
{
use crate::channels::commands::{self, ChannelCommand};
let cmd =
commands::handle_command(&content, session_id, &agent, &session_svc, is_owner, None)
.await;
if let Some(reply) = commands::try_execute_text_command(&cmd).await {
if let Err(e) = super::governor::say(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
&reply,
)
.await
{
tracing::warn!(error = %e, "failed to send Discord message");
}
return;
}
match cmd {
ChannelCommand::Models(resp) => {
use serenity::builder::{CreateActionRow, CreateButton, CreateMessage};
use serenity::model::application::ButtonStyle;
let rows: Vec<CreateActionRow> = resp
.providers
.chunks(5)
.take(5)
.map(|chunk| {
CreateActionRow::Buttons(
chunk
.iter()
.map(|(name, label, configured)| {
let marker = crate::channels::commands::provider_marker(
name,
&resp.current_provider,
*configured,
);
let display = match marker {
"๐" => format!("๐ {} (setup)", label),
"โ" => format!("โ {}", label),
_ => label.clone(),
};
let display = if display.len() > 80 {
format!("{}โฆ", display.chars().take(79).collect::<String>())
} else {
display
};
let cb = if *configured {
format!("provider:{}", name)
} else {
format!("setup:{}", name)
};
CreateButton::new(cb)
.label(display)
.style(ButtonStyle::Secondary)
})
.collect(),
)
})
.collect();
let builder = CreateMessage::new().content(&resp.text).components(rows);
if let Err(e) = super::governor::send_content(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
builder,
)
.await
{
tracing::error!(
"Discord: reply with components failed in channel {}: {e}",
msg.channel_id
);
}
return;
}
ChannelCommand::NewSession => {
let session_title = if is_dm {
format!("Discord: DM {} ({})", msg.author.name, msg.author.id.get())
} else {
format!("Discord: #{}", msg.channel_id.get())
};
let prior_session = session_svc
.find_session_by_title(&session_title)
.await
.unwrap_or_else(|e| {
tracing::error!("Discord: /new prior-session lookup failed: {e:#}");
None
});
if !is_owner
&& let Some(old) = prior_session.as_ref()
&& let Err(e) = session_svc.archive_session(old.id).await
{
tracing::error!("Discord: failed to archive old session {}: {}", old.id, e);
}
match crate::channels::session_init::create_channel_session(
&session_svc,
Some(session_title),
prior_session.as_ref(),
)
.await
{
Ok(new_session) => {
if is_owner && is_dm {
*shared_session.lock().await = Some(new_session.id);
}
discord_state
.register_session_channel(new_session.id, msg.channel_id.get())
.await;
let new_meta = session_svc.get_session(new_session.id).await.ok().flatten();
crate::channels::commands::sync_provider_for_session(
&agent,
new_session.id,
new_meta.as_ref().and_then(|s| s.provider_name.as_deref()),
new_meta.as_ref().and_then(|s| s.model.as_deref()),
)
.await;
let baseline = agent.base_context_tokens();
let ctx_max = agent.context_limit_for_session(new_session.id);
let footer = crate::utils::format_ctx_footer(baseline, ctx_max, None);
let msg_text = format!("โ
New session started.\n\n{footer}");
if let Err(e) = super::governor::say(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
&msg_text,
)
.await
{
tracing::warn!(error = %e, "failed to send Discord message");
}
tracing::info!(
"Discord /new: sent ctx footer='{}' (baseline={}, ctx_max={})",
footer,
baseline,
ctx_max,
);
}
Err(e) => {
tracing::error!("Discord: failed to create session: {}", e);
if let Err(send_err) = super::governor::say(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
"Failed to create session.",
)
.await
{
tracing::warn!(error = %send_err, "failed to send Discord session creation error");
}
}
}
return;
}
ChannelCommand::Sessions(resp) => {
use serenity::builder::{CreateActionRow, CreateButton, CreateMessage};
use serenity::model::application::ButtonStyle;
let rows: Vec<CreateActionRow> = resp
.sessions
.chunks(5)
.take(5)
.map(|chunk| {
CreateActionRow::Buttons(
chunk
.iter()
.map(|(id, label)| {
let display = if *id == resp.current_session_id {
format!("โธ {} โ current", label)
} else {
label.clone()
};
let display = if display.len() > 80 {
format!("{}โฆ", display.chars().take(79).collect::<String>())
} else {
display
};
CreateButton::new(format!("session:{}", id))
.label(display)
.style(ButtonStyle::Secondary)
})
.collect(),
)
})
.collect();
let builder = CreateMessage::new().content(&resp.text).components(rows);
if let Err(e) = super::governor::send_content(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
builder,
)
.await
{
tracing::error!(
"Discord: reply with components failed in channel {}: {e}",
msg.channel_id
);
}
return;
}
ChannelCommand::Stop => {
let cancelled = discord_state.cancel_session(session_id).await;
let reply = if cancelled {
"Operation cancelled."
} else {
"No operation in progress."
};
if let Err(e) = super::governor::say(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
reply,
)
.await
{
tracing::warn!(error = %e, "failed to send Discord message");
}
return;
}
ChannelCommand::Compact => {
if let Err(e) = super::governor::say(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
"โณ Compacting context...",
)
.await
{
tracing::warn!(error = %e, "failed to send Discord compact notification");
}
content =
"[SYSTEM: Compact context now. Summarize this conversation for continuity.]"
.to_string();
}
ChannelCommand::ClearContext => {
let reply = match agent.clear_context(session_id).await {
Ok(receipt) => receipt.user_line(),
Err(e) => {
tracing::error!("/clear failed: {e}");
format!("/clear did nothing, the context is unchanged: {e}")
}
};
if let Err(e) = super::governor::say(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
reply,
)
.await
{
tracing::warn!(error = %e, "failed to send Discord clear receipt");
}
return;
}
ChannelCommand::UserPrompt(prompt) => {
content = prompt;
}
ChannelCommand::NotACommand => {}
ChannelCommand::Profiles(resp) => {
if let Err(e) = super::governor::say(
&discord_state.governor,
msg.channel_id,
&ctx.http,
super::governor::Surface::Send,
&resp.text,
)
.await
{
tracing::warn!(error = %e, "failed to send Discord message");
}
return;
}
_ => {}
}
}
let reply_context = msg.referenced_message.as_ref().and_then(|reply| {
let reply_text = crate::utils::strip_ctx_footer(reply.content.trim());
if reply_text.is_empty() {
return None;
}
let reply_sender = if reply.author.bot {
"assistant".to_string()
} else {
reply.author.name.clone()
};
Some(format!("[Replying to {reply_sender}: \"{reply_text}\"]"))
});
let display_text = if is_owner && msg.guild_id.is_none() {
content.clone()
} else {
format!("{}: {}", msg.author.name, content)
};
let agent_input = if msg.guild_id.is_some() {
let name = &msg.author.name;
let uid = msg.author.id.get();
let channel = msg.channel_id.get().to_string();
let role = if is_owner { "owner" } else { "user" };
format!(
"{}\n{content}",
group_history::current_sender_label(
"Discord channel",
&channel,
name,
&format!(" (ID {uid})"),
role,
)
)
} else if !is_owner {
let name = &msg.author.name;
let uid = msg.author.id.get();
format!("[Discord DM from {name} (ID {uid})]\n{content}")
} else {
content
};
let agent_input = if let Some(ref ctx) = reply_context {
format!("{ctx}\n{agent_input}")
} else {
agent_input
};
let agent_input = if msg.guild_id.is_some() {
let chat_id_str = msg.channel_id.get().to_string();
let fetched = channel_msg_repo
.recent(Some("discord"), &chat_id_str, 30, None, None)
.await
.unwrap_or_default();
match group_history::build_preamble(
session_svc.pool(),
session_id,
fetched,
"channel",
"Discord",
)
.await
{
Some(preamble) => format!("{preamble}\n{agent_input}"),
None => agent_input,
}
} else {
agent_input
};
let channel_id = msg.channel_id.get();
let agent_input = format!(
"[Channel: Discord (channel_id: {channel_id}) โ your text response is automatically sent to this channel. \
Do NOT call discord_send to deliver your answer. Only use discord_send for: \
sending to a different channel, embeds, reactions, threads, files, or moderation.]\n{agent_input}"
);
discord_state
.register_session_channel(session_id, msg.channel_id.get())
.await;
let Some(_turn_guard) = discord_state.try_begin_turn(session_id) else {
tracing::info!("Discord: mid-turn follow-up queued for session {session_id} (#1990)");
discord_state.enqueue_followup(
session_id,
crate::brain::agent::QueuedUserMessage {
context_text: agent_input.clone(),
display_text: display_text.clone(),
origin: crate::brain::agent::PushOrigin::Ingress,
bg_meta: None,
},
);
use serenity::model::channel::ReactionType;
if let Err(e) = msg
.react(&ctx.http, ReactionType::Unicode("๐".to_string()))
.await
{
tracing::debug!("Discord: ๐ ack on queued follow-up failed: {e}");
}
return;
};
crate::brain::agent::service::session_routes::claim_for_channel(
session_id,
agent.message_enqueue_callback(),
);
let approval_cb = make_approval_callback(discord_state.clone());
let cancel_token = tokio_util::sync::CancellationToken::new();
discord_state
.store_cancel_token(session_id, cancel_token.clone())
.await;
let typing_cancel = tokio_util::sync::CancellationToken::new();
super::typing::spawn_typing(
ctx.http.clone(),
msg.channel_id,
typing_cancel.clone(),
agent.background_manager(),
agent.subagent_manager(),
session_id,
);
let _typing_guard = super::typing::TypingGuard(typing_cancel);
type SentIntermediate = String;
let sent_intermediates: Arc<Mutex<Vec<SentIntermediate>>> = Arc::new(Mutex::new(Vec::new()));
let intermediate_handles: Arc<std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let intermediate_handles_cb = intermediate_handles.clone();
let intermediate_handles_final = intermediate_handles.clone();
let sent_intermediates_final = sent_intermediates.clone();
let last_trace_body: Arc<std::sync::Mutex<Option<String>>> =
Arc::new(std::sync::Mutex::new(None));
let last_trace_body_cb = last_trace_body.clone();
use serenity::model::id::MessageId;
let turn_group_mid: Arc<Mutex<Option<MessageId>>> = Arc::new(Mutex::new(None));
let progress_cb: crate::brain::agent::ProgressCallback = {
use crate::brain::agent::ProgressEvent;
use serenity::builder::EditMessage;
use super::tool_group::{GroupEntry, GroupState};
let tools: Arc<Mutex<Vec<GroupEntry>>> = Arc::new(Mutex::new(Vec::new()));
let group_msg_id = turn_group_mid.clone();
let trace_narration = dc_cfg.trace_narration;
let group_state_cb = discord_state.clone();
let http = ctx.http.clone();
let channel = target;
Arc::new(move |session_id, event| {
let tools = tools.clone();
let http = http.clone();
match event {
ProgressEvent::Compacting { .. } => {
let http = http.clone();
tokio::spawn(async move {
for _ in 0..12 {
if let Err(e) = channel.broadcast_typing(&http).await {
tracing::warn!(error = %e, "failed to broadcast Discord typing");
}
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
}
});
}
ProgressEvent::ToolStarted {
tool_name,
tool_input,
} => {
let ctx_hint = crate::utils::tool_context_hint(&tool_name, &tool_input);
let gmid = group_msg_id.clone();
let dstate = group_state_cb.clone();
tokio::spawn(async move {
let entries = {
let mut t = tools.lock().await;
t.push(GroupEntry {
name: tool_name,
context: ctx_hint,
status: None,
});
t.clone()
};
let mut mid_guard = gmid.lock().await;
match *mid_guard {
Some(mid) => {
let group = dstate
.upsert_tool_group(
mid.get(),
GroupState {
entries,
notes: Vec::new(),
expanded: false,
started_at: Instant::now(),
settled: None,
},
)
.await;
let edit = EditMessage::new()
.content(super::tool_group::render_content(&group))
.components(super::tool_group::render_components(
&group,
mid.get(),
));
if let Some(Err(e)) = super::governor::edit_chrome(
&dstate.governor,
channel,
&http,
mid,
edit,
)
.await
{
tracing::warn!("Discord: tool group edit failed (append): {e}");
}
}
None => {
let group = GroupState {
entries,
notes: Vec::new(),
expanded: false,
started_at: Instant::now(),
settled: None,
};
let content = super::tool_group::render_content(&group);
match super::governor::say(
&dstate.governor,
channel,
&http,
super::governor::Surface::Send,
&content,
)
.await
{
Ok(sent) => {
let comps = super::tool_group::render_components(
&group,
sent.id.get(),
);
if !comps.is_empty()
&& let Some(Err(e)) = super::governor::edit_chrome(
&dstate.governor,
channel,
&http,
sent.id,
EditMessage::new().components(comps),
)
.await
{
tracing::warn!(
"Discord: tool group component fixup failed: {e}"
);
}
dstate.upsert_tool_group(sent.id.get(), group).await;
*mid_guard = Some(sent.id);
}
Err(e) => tracing::warn!(
"Discord: failed to post tool group message: {e}"
),
}
}
}
});
}
ProgressEvent::ToolCompleted {
tool_name, success, ..
} => {
let gmid = group_msg_id.clone();
let dstate = group_state_cb.clone();
tokio::spawn(async move {
let entries = {
let mut t = tools.lock().await;
if let Some(entry) = t
.iter_mut()
.rev()
.find(|e| e.name == tool_name && e.status.is_none())
{
entry.status = Some(success);
}
t.clone()
};
if let Some(mid) = *gmid.lock().await {
let group = dstate
.upsert_tool_group(
mid.get(),
GroupState {
entries,
notes: Vec::new(),
expanded: false,
started_at: Instant::now(),
settled: None,
},
)
.await;
let edit = EditMessage::new()
.content(super::tool_group::render_content(&group))
.components(super::tool_group::render_components(
&group,
mid.get(),
));
if let Some(Err(e)) = super::governor::edit_chrome(
&dstate.governor,
channel,
&http,
mid,
edit,
)
.await
{
tracing::warn!("Discord: tool group edit failed (status): {e}");
}
}
});
}
ProgressEvent::SelfHealingAlert { message } => {
let dstate = group_state_cb.clone();
tokio::spawn(async move {
let text =
format!("๐ง {}", crate::utils::sanitize::normalize_dashes(&message));
if let Err(e) = super::governor::say(
&dstate.governor,
channel,
&http,
super::governor::Surface::Send,
&text,
)
.await
{
tracing::warn!(error = %e, "failed to send Discord message");
}
});
}
ProgressEvent::IntermediateText { text, .. } => {
let clean = crate::utils::sanitize::strip_llm_artifacts(&text);
let clean = redact_secrets(&clean);
let (clean, _) = crate::utils::extract_img_markers(&clean);
let (clean, _) = crate::utils::extract_vid_markers(&clean);
let clean = super::table_convert::tables_to_discord(&clean);
if clean.trim().is_empty() {
return;
}
if trace_narration {
if let Ok(mut last) = last_trace_body_cb.lock() {
*last = Some(clean.clone());
}
let gmid = group_msg_id.clone();
let dstate = group_state_cb.clone();
let http = http.clone();
let channel = channel;
let handles = intermediate_handles_cb.clone();
let note = super::tool_group::clip_note(&clean);
let handle = tokio::spawn(async move {
let Some(mid) = *gmid.lock().await else {
return;
};
let Some(group) = dstate.append_note(mid.get(), note).await else {
return;
};
let edit = EditMessage::new()
.content(super::tool_group::render_content(&group))
.components(super::tool_group::render_components(
&group,
mid.get(),
));
if let Some(Err(e)) = super::governor::edit_chrome(
&dstate.governor,
channel,
&http,
mid,
edit,
)
.await
{
tracing::debug!("Discord: trace note edit failed: {e}");
}
});
if let Ok(mut g) = handles.lock() {
g.push(handle);
}
return;
}
let sent = sent_intermediates.clone();
let handles = intermediate_handles_cb.clone();
let http = http.clone();
let channel = channel;
let dstate = group_state_cb.clone();
let handle = tokio::spawn(async move {
{
let mut prev = sent.lock().await;
if prev.iter().any(|b| b == &clean) {
return;
}
prev.push(clean.clone());
}
for chunk in split_message(&clean, 2000) {
if let Err(e) = super::governor::say(
&dstate.governor,
channel,
&http,
super::governor::Surface::Send,
&chunk,
)
.await
{
tracing::debug!("Discord: intermediate text send failed: {}", e)
}
}
});
if let Ok(mut g) = handles.lock() {
g.push(handle);
}
}
ProgressEvent::RetryAttempt {
attempt,
max,
reason,
} => {
let http = http.clone();
let channel = channel;
let dstate = group_state_cb.clone();
tokio::spawn(async move {
let text = format!("โณ Retry {}/{} โ {}", attempt, max, reason);
if let Err(e) = super::governor::say(
&dstate.governor,
channel,
&http,
super::governor::Surface::Send,
&text,
)
.await
{
tracing::warn!(error = %e, "failed to send Discord message");
}
});
}
ProgressEvent::ProviderSwitched {
to_name, to_model, ..
} => {
let http = http.clone();
let channel = channel;
let dstate = group_state_cb.clone();
tokio::spawn(async move {
let text = format!("๐ Now using {}/{}", to_name, to_model);
if let Err(e) = super::governor::say(
&dstate.governor,
channel,
&http,
super::governor::Surface::Send,
&text,
)
.await
{
tracing::warn!(error = %e, "failed to send Discord message");
}
});
}
ProgressEvent::SuggestedOptions(options) => {
let http = http.clone();
let state = group_state_cb.clone();
let raw_options: Vec<String> =
options.into_iter().map(|item| item.label).collect();
tokio::spawn(async move {
super::suggest_options::render_suggestions(
&http,
&state,
session_id,
raw_options,
)
.await;
});
}
_ => {}
}
})
};
let turn_shell = super::tool_group::GroupState {
entries: Vec::new(),
notes: Vec::new(),
expanded: false,
started_at: Instant::now(),
settled: None,
};
match super::governor::say(
&discord_state.governor,
target,
&ctx.http,
super::governor::Surface::Send,
&super::tool_group::render_content(&turn_shell),
)
.await
{
Ok(sent) => {
discord_state
.upsert_tool_group(sent.id.get(), turn_shell)
.await;
*turn_group_mid.lock().await = Some(sent.id);
}
Err(e) => tracing::warn!("Discord: turn-start group shell post failed: {e}"),
}
spawn_flow_ticker(
ctx.http.clone(),
target,
turn_group_mid.clone(),
discord_state.clone(),
);
let discord_chat_id = msg.channel_id.get().to_string();
let result = agent
.send_message_with_tools_and_display(
session_id,
agent_input,
Some(display_text),
None,
Some(cancel_token),
Some(approval_cb),
Some(progress_cb),
"discord",
Some(&discord_chat_id),
None, )
.await;
discord_state.remove_cancel_token(session_id).await;
match result {
Ok(response) => {
let (response_content, react_emoji) =
crate::utils::extract_react_marker(&response.content);
if let Some(ref em) = react_emoji {
use serenity::model::channel::ReactionType;
let em = em.trim().to_string();
if let Err(e) = msg
.react(&ctx.http, ReactionType::Unicode(em.clone()))
.await
{
tracing::warn!("Discord: react-back {em} failed: {e}");
}
}
let (text_only, img_paths) = crate::utils::extract_img_markers(&response_content);
let text_only = crate::utils::sanitize::strip_llm_artifacts(&text_only);
let text_only = redact_secrets(&text_only);
let text_only = super::table_convert::tables_to_discord(&text_only);
let ctx_max = agent.context_limit_for_session(session_id);
let ctx_line = crate::utils::format_ctx_footer(
response.context_tokens,
ctx_max,
response.tokens_per_second,
);
let pending = {
let mut g = intermediate_handles_final.lock().expect("poisoned");
std::mem::take(&mut *g)
};
for h in pending {
if let Err(e) = h.await {
tracing::warn!("Discord: intermediate post task panicked: {e}");
}
}
let text_only = super::trace_answer::final_text_for_delivery(
text_only,
dc_cfg.trace_narration,
last_trace_body.lock().ok().and_then(|mut g| g.take()),
);
let skip_final_post = {
let posted = sent_intermediates_final.lock().await;
if text_only.trim().is_empty() {
true
} else {
let final_key = norm_key(&text_only);
posted.iter().any(|b| norm_key(b) == final_key)
}
};
let answer_head = text_only
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("")
.to_lowercase();
if let Some(mid) = *turn_group_mid.lock().await
&& let Some(group) = discord_state
.drop_note_if(mid.get(), |n| answer_head.starts_with(&n.to_lowercase()))
.await
{
let edit = serenity::builder::EditMessage::new()
.content(super::tool_group::render_content(&group))
.components(super::tool_group::render_components(&group, mid.get()));
if let Some(Err(e)) = super::governor::edit_chrome(
&discord_state.governor,
target,
&ctx.http,
mid,
edit,
)
.await
{
tracing::debug!("Discord: trace mirror-note drop failed: {e}");
}
}
let waiting = {
let (_, bg_count) = bg_indicator_for(&agent, session_id);
waiting_verb(bg_count, subagent_counts_for(&agent, session_id))
};
if let Some(mid) = *turn_group_mid.lock().await
&& let Some(group) = discord_state
.settle_tool_group(
mid.get(),
if ctx_line.is_empty() {
None
} else {
Some(ctx_line.clone())
},
waiting.clone(),
None,
)
.await
{
if waiting.is_some() {
discord_state
.register_waiting_group(session_id, mid.get())
.await;
}
let edit = serenity::builder::EditMessage::new()
.content(super::tool_group::render_content(&group))
.components(super::tool_group::render_components(&group, mid.get()));
if let Err(e) = super::governor::edit_content(
&discord_state.governor,
target,
&ctx.http,
mid,
super::governor::Surface::Final,
edit,
)
.await
{
tracing::debug!("Discord: settled status stamp failed: {e}");
}
super::plan_card::refresh_plan_card(
&discord_state,
&ctx.http,
target,
session_id,
true,
)
.await;
}
let mut attachments: Vec<CreateAttachment> = Vec::new();
for img_path in &img_paths {
match tokio::fs::read(img_path).await {
Ok(bytes) => {
let fname = std::path::Path::new(img_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("image.png")
.to_string();
attachments.push(CreateAttachment::bytes(bytes, fname));
}
Err(e) => {
tracing::error!("Discord: failed to read image {}: {}", img_path, e);
}
}
}
for batch in attachments.chunks(10) {
let mut message = CreateMessage::new();
for file in batch {
message = message.add_file(file.clone());
}
if let Err(e) = super::governor::send_content(
&discord_state.governor,
target,
&ctx.http,
super::governor::Surface::Send,
message,
)
.await
{
tracing::error!("Discord: failed to send media gallery batch: {}", e);
}
}
if skip_final_post {
} else {
let chunks: Vec<String> = split_message(&text_only, 2000);
let auto_thread = dc_cfg.auto_thread_min_chars > 0
&& text_only.chars().count() >= dc_cfg.auto_thread_min_chars;
if auto_thread {
let anchor = (*turn_group_mid.lock().await).unwrap_or(msg.id);
let title = thread_title(&text_only);
let body = serde_json::json!({ "name": title });
match ctx
.http
.create_thread_from_message(target, anchor, &body, None)
.await
{
Ok(thread) => {
let truncated = text_only.chars().count() > 280;
let teaser: String = text_only.chars().take(280).collect();
let teaser = format!(
"{teaser}{}\n\n-# Full response in thread: <#{}>",
if truncated { "โฆ" } else { "" },
thread.id
);
if let Err(e) = super::governor::say(
&discord_state.governor,
target,
&ctx.http,
super::governor::Surface::Send,
&teaser,
)
.await
{
tracing::error!("Discord: auto-thread teaser failed: {e}");
}
for chunk in &chunks {
if let Err(e) = super::governor::say(
&discord_state.governor,
thread.id,
&ctx.http,
super::governor::Surface::Send,
chunk,
)
.await
{
tracing::error!("Discord: auto-thread body failed: {e}");
}
}
}
Err(e) => {
tracing::warn!("Discord: auto-thread failed, posting inline: {e}");
for chunk in &chunks {
if let Err(e) = super::governor::say(
&discord_state.governor,
target,
&ctx.http,
super::governor::Surface::Send,
chunk,
)
.await
{
tracing::error!("Discord: failed to send reply: {}", e);
}
}
}
}
} else {
for chunk in &chunks {
if let Err(e) = super::governor::say(
&discord_state.governor,
target,
&ctx.http,
super::governor::Surface::Send,
chunk,
)
.await
{
tracing::error!("Discord: failed to send reply: {}", e);
}
}
}
}
if !is_dm && !text_only.trim().is_empty() {
let bot_id = discord_state.bot_user_id().await;
let bot_sender_id = bot_id
.map(|id| id.to_string())
.unwrap_or_else(|| "bot:opencrabs".to_string());
let guild_name = msg
.guild_id
.map(|g| g.get().to_string())
.unwrap_or_else(|| "DM".to_string());
let cm = DbChannelMessage::new(
"discord".into(),
target.get().to_string(),
Some(guild_name),
bot_sender_id,
"OpenCrabs".into(),
text_only.clone(),
"text".into(),
None,
);
if let Err(e) = channel_msg_repo.insert(&cm).await {
tracing::warn!(
"Discord: failed to record bot reply in channel_messages: {}",
e
);
}
}
if is_voice && voice_config.tts_enabled {
match crate::channels::voice::synthesize(&response.content, &voice_config).await {
Ok(audio_bytes) => {
send_tts_voice(
&discord_state.governor,
&ctx.http,
msg.channel_id,
&audio_bytes,
)
.await;
}
Err(e) => tracing::error!("Discord: TTS error: {e}"),
}
}
}
Err(ref e) if matches!(e, crate::brain::agent::AgentError::Cancelled) => {
tracing::info!("Discord: agent call cancelled for session {}", session_id);
if let Some(mid) = *turn_group_mid.lock().await
&& let Some(group) = discord_state
.settle_tool_group(mid.get(), None, None, Some(FlowOutcome::Cancelled))
.await
{
let edit = serenity::builder::EditMessage::new()
.content(super::tool_group::render_content(&group))
.components(super::tool_group::render_components(&group, mid.get()));
if let Err(e) = super::governor::edit_content(
&discord_state.governor,
target,
&ctx.http,
mid,
super::governor::Surface::Final,
edit,
)
.await
{
tracing::debug!("Discord: cancelled settle stamp failed: {e}");
}
}
}
Err(e) => {
tracing::error!("Discord: agent error: {}", e);
let error_msg = format!("โ Error\n\n{}", crate::brain::agent::format_user_error(&e));
if let Err(e) = super::governor::say(
&discord_state.governor,
target,
&ctx.http,
super::governor::Surface::Send,
error_msg,
)
.await
{
tracing::warn!(error = %e, "failed to send Discord message");
}
if let Some(mid) = *turn_group_mid.lock().await
&& let Some(group) = discord_state
.settle_tool_group(mid.get(), None, None, Some(outcome_for_error(&e)))
.await
{
let edit = serenity::builder::EditMessage::new()
.content(super::tool_group::render_content(&group))
.components(super::tool_group::render_components(&group, mid.get()));
if let Err(e) = super::governor::edit_content(
&discord_state.governor,
target,
&ctx.http,
mid,
super::governor::Surface::Final,
edit,
)
.await
{
tracing::debug!("Discord: error settle stamp failed: {e}");
}
}
}
}
drop(_turn_guard);
if let Some(joined) = discord_state.drain_followups(session_id) {
let http = ctx.http.clone();
let dstate = discord_state.clone();
let agent = agent.clone();
let channel = target;
let text = joined.context_text.clone();
let display = joined.display_text.clone();
tokio::spawn(async move {
if matches!(
super::tracked_turn::run_tracked_resume_turn(
http,
channel,
session_id,
dstate.clone(),
agent,
super::tracked_turn::ResumeDispatch::Display {
text,
display_tag: Some(display),
},
)
.await,
super::tracked_turn::ResumeTurnOutcome::Queued
) {
dstate.enqueue_followup(session_id, joined);
}
});
}
}
const TTS_ATTACHMENT_NAME: &str = "response.ogg";
pub(crate) fn voice_reply_builder(audio: &[u8]) -> CreateMessage {
CreateMessage::new()
.add_file(CreateAttachment::bytes(audio, TTS_ATTACHMENT_NAME))
.flags(MessageFlags::IS_VOICE_MESSAGE)
}
pub(crate) fn plain_attachment_builder(audio: &[u8]) -> CreateMessage {
CreateMessage::new().add_file(CreateAttachment::bytes(audio, TTS_ATTACHMENT_NAME))
}
pub(crate) async fn send_tts_voice(
gov: &super::governor::Governor,
http: &Http,
channel: ChannelId,
audio: &[u8],
) {
if let Err(e) = super::governor::send_content(
gov,
channel,
http,
super::governor::Surface::Send,
voice_reply_builder(audio),
)
.await
{
tracing::warn!(
"Discord: voice-flagged TTS send rejected ({e}); retrying as a plain attachment (#1849)"
);
if let Err(e2) = super::governor::send_content(
gov,
channel,
http,
super::governor::Surface::Send,
plain_attachment_builder(audio),
)
.await
{
tracing::error!("Discord: failed to send TTS voice: {e2}");
}
}
}
pub(crate) fn make_approval_callback(
state: Arc<super::DiscordState>,
) -> crate::brain::agent::ApprovalCallback {
use crate::brain::agent::ToolApprovalInfo;
use crate::utils::{check_approval_policy, persist_auto_session_policy};
use serenity::builder::{CreateActionRow, CreateButton, CreateMessage, EditMessage};
use serenity::model::application::ButtonStyle;
use serenity::model::id::ChannelId;
use tokio::sync::oneshot;
Arc::new(move |info: ToolApprovalInfo| {
let state = state.clone();
Box::pin(async move {
if let Some(result) = check_approval_policy() {
return Ok(result);
}
let http = match state.http().await {
Some(h) => h,
None => {
tracing::warn!("Discord approval: bot not connected");
return Ok((false, false));
}
};
let channel_id = match state.session_channel(info.session_id).await {
Some(id) => id,
None => match state.owner_channel_id().await {
Some(id) => id,
None => {
tracing::warn!(
"Discord approval: no channel_id for session {}",
info.session_id
);
return Ok((false, false));
}
},
};
let approval_id = uuid::Uuid::new_v4().to_string();
let safe_input = crate::utils::redact_tool_input(&info.tool_input);
let input_pretty = serde_json::to_string_pretty(&safe_input)
.unwrap_or_else(|_| safe_input.to_string());
let text = format!(
"๐ **Tool Approval Required**\n\nTool: `{}`\nInput:\n```json\n{}\n```",
info.tool_name,
truncate_str(&input_pretty, 1800),
);
let row = CreateActionRow::Buttons(vec![
CreateButton::new(format!("approve:{}", approval_id))
.label("โ
Yes")
.style(ButtonStyle::Success),
CreateButton::new(format!("always:{}", approval_id))
.label("๐ Always (session)")
.style(ButtonStyle::Primary),
CreateButton::new(format!("yolo:{}", approval_id))
.label("๐ฅ YOLO")
.style(ButtonStyle::Secondary),
CreateButton::new(format!("deny:{}", approval_id))
.label("โ No")
.style(ButtonStyle::Danger),
]);
let (tx, rx) = oneshot::channel();
state
.register_pending_approval(approval_id.clone(), tx)
.await;
tracing::info!(
"Discord approval: registered pending id={}, sending to channel={}",
approval_id,
channel_id
);
let mut sent_msg = match super::governor::send_content(
&state.governor,
ChannelId::new(channel_id),
&http,
super::governor::Surface::Final,
CreateMessage::new().content(&text).components(vec![row]),
)
.await
{
Ok(m) => m,
Err(e) => {
tracing::error!("Discord approval: failed to send message: {}", e);
return Ok((false, false));
}
};
tracing::info!(
"Discord approval: message sent, waiting for response (id={})",
approval_id
);
match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
Ok(Ok((approved, always))) => {
tracing::info!(
"Discord approval: user responded id={}, approved={}, always={}",
approval_id,
approved,
always
);
if always {
persist_auto_session_policy();
}
let label = if always {
"๐ Always approved (session)"
} else if approved {
"โ
Approved"
} else {
"โ Denied"
};
if let Err(e) = sent_msg
.edit(&http, EditMessage::new().content(label).components(vec![]))
.await
{
tracing::warn!(error = %e, "failed to edit Discord approval button");
}
Ok((approved, always))
}
Ok(Err(_)) => {
tracing::warn!(
"Discord approval: oneshot channel closed (id={})",
approval_id
);
Ok((false, false))
}
Err(_) => {
tracing::warn!(
"Discord approval: 5-minute timeout โ auto-denying (id={})",
approval_id
);
if let Err(e) = sent_msg
.edit(
&http,
EditMessage::new()
.content("โฑ๏ธ Approval timed out โ denied")
.components(vec![]),
)
.await
{
tracing::warn!(error = %e, "failed to edit Discord timeout message");
}
Ok((false, false))
}
}
})
})
}
pub(crate) fn norm_key(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn thread_title(raw: &str) -> String {
const MAX: usize = 64;
let cleaned = raw.trim_start_matches('!');
let cleaned = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
let mut out = String::new();
let mut truncated = false;
for word in cleaned.split(' ') {
let extra = word.chars().count() + usize::from(!out.is_empty());
if !out.is_empty() && out.chars().count() + extra > MAX {
truncated = true;
break;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
if out.chars().count() > MAX {
out = out.chars().take(MAX).collect();
truncated = true;
}
if truncated {
out.push('โฆ');
}
format!("๐งต {out}")
}