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>,
}
#[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.name.clone()
} else {
format!("{} {ctx}", e.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.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(),
))),
]
}
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)
}
}