use serenity::builder::{CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter};
use serenity::model::application::ButtonStyle;
use std::time::{Duration, Instant};
use uuid::Uuid;
use super::DiscordState;
use super::tool_card::{
card_color, card_footer, card_title, clamp_page, collapse_repeats, page_count, page_slice,
row_line,
};
use crate::channels::background_work::FlowOutcome;
#[derive(Debug, Clone)]
pub(crate) struct GroupEntry {
pub name: String,
pub context: String,
pub call_id: String,
pub status: Option<bool>,
pub task_id: Option<String>,
}
impl GroupEntry {
pub(crate) fn display_name(&self) -> String {
crate::channels::background_work::detached_row_label(
&self.name,
self.task_id.as_deref(),
self.status,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DetachedRender {
Post,
Edit(u64),
Skip,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum DetachedEvent<'a> {
Started { task_id: &'a str, label: &'a str },
Finished { task_id: &'a str, success: bool },
}
pub(crate) fn detached_render(
entries: &mut Vec<GroupEntry>,
group_mid: Option<u64>,
event: DetachedEvent<'_>,
) -> DetachedRender {
match event {
DetachedEvent::Started { task_id, label } => {
entries.push(GroupEntry {
name: label.to_string(),
context: String::new(),
call_id: String::new(),
status: None,
task_id: Some(task_id.to_string()),
});
match group_mid {
Some(mid) => DetachedRender::Edit(mid),
None => DetachedRender::Post,
}
}
DetachedEvent::Finished { task_id, success } => {
let Some(entry) = entries
.iter_mut()
.find(|e| e.task_id.as_deref() == Some(task_id))
else {
return DetachedRender::Skip;
};
entry.status = Some(success);
match group_mid {
Some(mid) => DetachedRender::Edit(mid),
None => DetachedRender::Skip,
}
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct GroupState {
pub entries: Vec<GroupEntry>,
pub notes: Vec<String>,
pub expanded: bool,
pub page: usize,
pub started_at: Instant,
pub settled: Option<SettledStatus>,
}
#[derive(Debug, Clone)]
pub(crate) struct SettledStatus {
pub elapsed: Duration,
pub ctx: Option<String>,
pub waiting: Option<String>,
pub outcome: Option<FlowOutcome>,
}
pub(crate) const NOTE_CAP: usize = 6;
pub(crate) const NOTE_MAX_CHARS: usize = 160;
pub(crate) fn clip_note(text: &str) -> String {
let line = text
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("");
let mut out: String = line.chars().take(NOTE_MAX_CHARS).collect();
if line.chars().count() > NOTE_MAX_CHARS {
out.push('…');
}
out
}
fn notes_block(notes: &[String]) -> String {
notes
.iter()
.map(|n| format!("-# {n}"))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn entry_icon(status: Option<bool>) -> &'static str {
match status {
None => "⚙️",
Some(true) => "✅",
Some(false) => "❌",
}
}
fn clock(elapsed: Duration) -> String {
let (h, m, s) = (
elapsed.as_secs() / 3600,
(elapsed.as_secs() % 3600) / 60,
elapsed.as_secs() % 60,
);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
const ACTIVITY_MAX_CHARS: usize = 100;
fn activity_segment(group: &GroupState) -> Option<String> {
let text = latest_activity(group)?;
let clipped: String = text.chars().take(ACTIVITY_MAX_CHARS).collect();
let out = if text.chars().count() > ACTIVITY_MAX_CHARS {
format!("{clipped}…")
} else {
clipped
};
(!out.is_empty()).then_some(out)
}
fn latest_activity(group: &GroupState) -> Option<String> {
if let Some(text) = group
.notes
.iter()
.rev()
.find_map(|n| crate::channels::telegram::flow::human_readable_preview(n))
{
return Some(text);
}
if let Some(comments) = group.entries.iter().rev().find_map(|e| {
if e.name == "bash" {
crate::channels::telegram::flow::extract_status_from_text(&e.context)
} else {
None
}
}) {
return Some(comments);
}
group
.entries
.iter()
.rev()
.map(|e| {
let ctx = e.context.trim_start();
if ctx.is_empty() {
e.display_name()
} else {
format!("{} {ctx}", e.display_name())
}
})
.next()
}
fn summary_line(group: &GroupState) -> String {
let n = group.entries.len();
let failed = group
.entries
.iter()
.filter(|e| e.status == Some(false))
.count();
let counts = format!("**{n} tool call{}**", if n == 1 { "" } else { "s" });
match &group.settled {
Some(s) => {
let mut line = if let Some(verb) = &s.waiting {
format!("⏳ {verb} · {counts}")
} else if let Some(outcome) = s.outcome {
let (icon, verb) = outcome.icon_verb();
format!("{icon} {verb} · {counts}")
} else if failed > 0 {
format!("❌ {counts} · {failed} failed")
} else {
format!("✅ {counts}")
};
if let Some(ctx) = &s.ctx {
line.push_str(&format!(" · {ctx}"));
}
line.push_str(&format!(" · ⏱️ {}", clock(s.elapsed)));
line
}
None => {
let running = group.entries.iter().filter(|e| e.status.is_none()).count();
let (icon, tail) = if running > 0 {
("⚙️", format!(" · {running} running"))
} else if failed > 0 {
("❌", format!(" · {failed} failed"))
} else {
("✅", String::new())
};
let clock = format!("🕒 {}", clock(group.started_at.elapsed()));
match activity_segment(group) {
Some(activity) => format!("{icon} {activity} · {counts}{tail} · {clock}"),
None => format!("{icon} {counts}{tail} · {clock}"),
}
}
}
}
pub(crate) const CONTENT_MAX_CHARS: usize = 2000;
fn hard_clip(body: String) -> String {
if body.chars().count() <= CONTENT_MAX_CHARS {
return body;
}
let cut: String = body.chars().take(CONTENT_MAX_CHARS - 1).collect();
format!("{cut}…")
}
pub(crate) fn render_content(group: &GroupState) -> String {
let tools_part = if group.entries.len() == 1 && !group.expanded && group.settled.is_none() {
let e = &group.entries[0];
format!(
"{} **{}**{}",
entry_icon(e.status),
e.display_name(),
e.context
)
} else {
summary_line(group)
};
let mut body = if group.notes.is_empty() {
tools_part.clone()
} else {
format!("{tools_part}\n{}", notes_block(&group.notes))
};
let mut notes: Vec<String> = group.notes.clone();
while body.chars().count() > CONTENT_MAX_CHARS && notes.len() > 1 {
notes.remove(0);
body = format!("{tools_part}\n{}", notes_block(¬es));
}
hard_clip(body)
}
pub(crate) fn stale_card_reply() -> serenity::builder::CreateInteractionResponse {
serenity::builder::CreateInteractionResponse::Message(
serenity::builder::CreateInteractionResponseMessage::new()
.ephemeral(true)
.content("This status bubble is too old to use."),
)
}
pub(crate) fn render_components(group: &GroupState, message_id: u64) -> Vec<CreateActionRow> {
if group.entries.len() < 2 {
return Vec::new();
}
let log = CreateButton::new(format!("tglog:{message_id}"))
.label("Log")
.style(ButtonStyle::Secondary);
if !group.expanded {
return vec![CreateActionRow::Buttons(vec![
CreateButton::new(format!("toolgroup:{message_id}"))
.label("Expand ▼")
.style(ButtonStyle::Secondary),
log,
])];
}
let rows = collapse_repeats(&group.entries);
let page = clamp_page(group.page, rows.len());
let last = page_count(rows.len()) - 1;
vec![CreateActionRow::Buttons(vec![
CreateButton::new(format!("tgpage:{message_id}:{}", page.saturating_sub(1)))
.label("◀")
.style(ButtonStyle::Secondary)
.disabled(page == 0),
CreateButton::new(format!("tgpage:{message_id}:{}", (page + 1).min(last)))
.label("▶")
.style(ButtonStyle::Secondary)
.disabled(page >= last),
log,
CreateButton::new(format!("toolgroup:{message_id}"))
.label("Collapse ▲")
.style(ButtonStyle::Secondary),
])]
}
pub(crate) fn render_embeds(group: &GroupState) -> Vec<CreateEmbed> {
if !group.expanded || group.entries.len() < 2 {
return Vec::new();
}
let rows = collapse_repeats(&group.entries);
let page = clamp_page(group.page, rows.len());
let body = page_slice(&rows, page)
.iter()
.map(row_line)
.collect::<Vec<_>>()
.join("\n");
vec![
CreateEmbed::new()
.title(card_title(&rows))
.colour(card_color(&rows))
.description(body)
.footer(CreateEmbedFooter::new(card_footer(
&rows,
page,
group.entries.len(),
))),
]
}
pub(crate) async fn apply_detached_event(
dstate: &DiscordState,
channel: serenity::model::id::ChannelId,
http: &serenity::http::Http,
tools: &tokio::sync::Mutex<Vec<GroupEntry>>,
gmid: &tokio::sync::Mutex<Option<serenity::model::id::MessageId>>,
event: DetachedEvent<'_>,
) {
use serenity::builder::EditMessage;
use serenity::model::id::MessageId;
let (render, entries) = {
let mut t = tools.lock().await;
let mid = *gmid.lock().await;
let render = detached_render(&mut t, mid.map(|m| m.get()), event);
(render, t.clone())
};
match render {
DetachedRender::Skip => {}
DetachedRender::Edit(mid) => {
let group = dstate
.upsert_tool_group(
mid,
GroupState {
page: 0,
entries,
notes: Vec::new(),
expanded: false,
started_at: Instant::now(),
settled: None,
},
)
.await;
let edit = EditMessage::new()
.content(render_content(&group))
.embeds(render_embeds(&group))
.components(render_components(&group, mid));
if let Some(Err(e)) = super::governor::edit_chrome(
&dstate.governor,
channel,
http,
MessageId::new(mid),
edit,
)
.await
{
tracing::warn!("Discord: detached row edit failed: {e}");
}
}
DetachedRender::Post => {
let group = GroupState {
page: 0,
entries,
notes: Vec::new(),
expanded: false,
started_at: Instant::now(),
settled: None,
};
let content = render_content(&group);
match super::governor::say(
&dstate.governor,
channel,
http,
super::governor::Surface::Send,
&content,
)
.await
{
Ok(sent) => {
let comps = 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()
.embeds(render_embeds(&group))
.components(comps),
)
.await
{
tracing::warn!("Discord: detached row component fixup failed: {e}");
}
dstate.upsert_tool_group(sent.id.get(), group).await;
*gmid.lock().await = Some(sent.id);
}
Err(e) => tracing::warn!("Discord: failed to post detached row block: {e}"),
}
}
}
}
impl DiscordState {
const TOOL_GROUP_CAP: usize = 20;
#[cfg(test)]
pub(crate) async fn memory_group_ids(&self) -> Vec<u64> {
let guard = self.tool_groups.lock().await;
guard.0.clone()
}
async fn persist_tool_group(&self, message_id: u64, group: &GroupState) {
let Some(pool) = crate::db::database::global_pool() else {
return;
};
let json = match serde_json::to_string(&super::tool_card_store::snapshot(group)) {
Ok(json) => json,
Err(e) => {
tracing::warn!("Discord: could not encode tool card {message_id}: {e}");
return;
}
};
let repo = crate::db::repository::DiscordToolCardRepository::new(pool.clone());
if let Err(e) = repo.set(message_id, &json).await {
tracing::warn!("Discord: could not save tool card {message_id}: {e}");
}
}
async fn restore_tool_group(&self, message_id: u64) -> Option<GroupState> {
let pool = crate::db::database::global_pool()?;
let repo = crate::db::repository::DiscordToolCardRepository::new(pool.clone());
let json = match repo.get(message_id).await {
Ok(Some(json)) => json,
Ok(None) => return None,
Err(e) => {
tracing::warn!("Discord: could not read tool card {message_id}: {e}");
return None;
}
};
let stored: super::tool_card_store::StoredCard = match serde_json::from_str(&json) {
Ok(stored) => stored,
Err(e) => {
tracing::warn!("Discord: stored tool card {message_id} is unreadable: {e}");
return None;
}
};
let group = super::tool_card_store::restore(stored);
let mut guard = self.tool_groups.lock().await;
let (order, map) = &mut *guard;
if !map.contains_key(&message_id) {
order.push(message_id);
while order.len() > Self::TOOL_GROUP_CAP {
let oldest = order.remove(0);
map.remove(&oldest);
}
map.insert(message_id, group);
}
map.get(&message_id).cloned()
}
pub(crate) async fn upsert_tool_group(
&self,
message_id: u64,
mut group: GroupState,
) -> GroupState {
{
let mut guard = self.tool_groups.lock().await;
let (order, map) = &mut *guard;
match map.get(&message_id) {
Some(existing) => {
group.expanded = existing.expanded;
group.page = existing.page;
group.notes = existing.notes.clone();
group.started_at = existing.started_at;
group.settled = existing.settled.clone();
}
None => {
order.push(message_id);
while order.len() > Self::TOOL_GROUP_CAP {
let oldest = order.remove(0);
map.remove(&oldest);
}
}
}
map.insert(message_id, group.clone());
}
self.persist_tool_group(message_id, &group).await;
group
}
pub(crate) async fn set_tool_group_page(
&self,
message_id: u64,
page: usize,
) -> Option<GroupState> {
let updated = self
.edit_tool_group(message_id, |group| group.page = page)
.await?;
self.persist_tool_group(message_id, &updated).await;
Some(updated)
}
pub(crate) async fn toggle_tool_group(&self, message_id: u64) -> Option<GroupState> {
let updated = self
.edit_tool_group(message_id, |group| group.expanded = !group.expanded)
.await?;
self.persist_tool_group(message_id, &updated).await;
Some(updated)
}
async fn edit_tool_group<F>(&self, message_id: u64, edit: F) -> Option<GroupState>
where
F: FnOnce(&mut GroupState),
{
{
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
if let Some(group) = map.get_mut(&message_id) {
edit(group);
return Some(group.clone());
}
}
let mut restored = self.restore_tool_group(message_id).await?;
edit(&mut restored);
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
map.insert(message_id, restored.clone());
Some(restored)
}
pub(crate) async fn append_note(&self, message_id: u64, note: String) -> Option<GroupState> {
let updated = {
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(&message_id)?;
group.notes.push(note);
if group.notes.len() > NOTE_CAP {
group.notes.remove(0);
}
group.clone()
};
self.persist_tool_group(message_id, &updated).await;
Some(updated)
}
pub(crate) async fn settle_tool_group(
&self,
message_id: u64,
ctx: Option<String>,
waiting: Option<String>,
outcome: Option<FlowOutcome>,
) -> Option<GroupState> {
let updated = {
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(&message_id)?;
let prev_ctx = group.settled.as_ref().and_then(|s| s.ctx.clone());
group.settled = Some(SettledStatus {
elapsed: group.started_at.elapsed(),
ctx: ctx.or(prev_ctx),
waiting,
outcome,
});
group.clone()
};
self.persist_tool_group(message_id, &updated).await;
Some(updated)
}
pub(crate) async fn refresh_waiting_line(
&self,
message_id: u64,
waiting: Option<String>,
) -> Option<GroupState> {
let updated = {
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(&message_id)?;
group.settled.as_mut()?.waiting = waiting;
group.clone()
};
self.persist_tool_group(message_id, &updated).await;
Some(updated)
}
pub(crate) async fn register_waiting_group(&self, session_id: Uuid, message_id: u64) {
self.waiting_groups
.lock()
.await
.insert(session_id, message_id);
}
pub(crate) async fn waiting_group_for(&self, session_id: Uuid) -> Option<u64> {
self.waiting_groups.lock().await.get(&session_id).copied()
}
pub(crate) async fn clear_waiting_group(&self, session_id: Uuid) -> Option<u64> {
self.waiting_groups.lock().await.remove(&session_id)
}
pub(crate) async fn tool_group_snapshot(&self, message_id: u64) -> Option<GroupState> {
{
let guard = self.tool_groups.lock().await;
let (_, map) = &*guard;
if let Some(group) = map.get(&message_id) {
return Some(group.clone());
}
}
self.restore_tool_group(message_id).await
}
pub(crate) async fn drop_note_if<F>(&self, message_id: u64, pred: F) -> Option<GroupState>
where
F: Fn(&str) -> bool,
{
let updated = {
let mut guard = self.tool_groups.lock().await;
let (_, map) = &mut *guard;
let group = map.get_mut(&message_id)?;
let idx = group.notes.iter().rposition(|n| pred(n))?;
group.notes.remove(idx);
group.clone()
};
self.persist_tool_group(message_id, &updated).await;
Some(updated)
}
}