use std::sync::Arc;
use serenity::builder::{CreateMessage, EditMessage};
use serenity::model::id::{ChannelId, MessageId};
use uuid::Uuid;
use super::governor::{self, Surface};
use super::state::DiscordState;
use crate::db::repository::PlanCard;
use crate::tui::plan::{PlanDocument, PlanStatus, status_mark};
const ROW_TEXT_CAP: usize = 90;
const TITLE_CAP: usize = 120;
const CARD_CHAR_CAP: usize = 1900;
pub(crate) fn escape_markdown(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 8);
for c in text.chars() {
match c {
'*' | '_' | '~' | '`' | '>' | '<' | '|' | '@' => {
out.push('\\');
out.push(c);
}
other => out.push(other),
}
}
out
}
pub(crate) fn status_word(status: &PlanStatus) -> &'static str {
match status {
PlanStatus::Editing => "Editing",
PlanStatus::Active => "Active",
}
}
pub(crate) fn surface_for(finalizing: bool) -> Surface {
if finalizing {
Surface::Final
} else {
Surface::Chrome
}
}
pub(crate) fn should_paint(previous: Option<&str>, rendered: &str) -> bool {
previous.is_none_or(|prev| prev != rendered)
}
pub(crate) fn card_is_ours(stored_chat_id: i64, channel: u64) -> bool {
stored_chat_id == channel as i64
}
#[derive(Debug, Clone)]
pub(crate) enum PaintTarget {
Skip,
Live(PlanDocument),
Finalizing(PlanDocument),
}
pub(crate) fn decide_paint(
live: Option<PlanDocument>,
may_finalize: bool,
archived: Option<PlanDocument>,
) -> PaintTarget {
if let Some(plan) = live {
return PaintTarget::Live(plan);
}
if may_finalize && let Some(plan) = archived {
return PaintTarget::Finalizing(plan);
}
PaintTarget::Skip
}
pub(crate) fn untracks_the_card(error: &str) -> bool {
let text = error.to_lowercase();
text.contains("unknown message")
|| text.contains("unknown channel")
|| text.contains("10008")
|| text.contains("10003")
|| text.contains("404")
|| text.contains("403")
}
pub(crate) fn render_plan_card(plan: &PlanDocument) -> String {
let total = plan.tasks.len();
let done = plan
.tasks
.iter()
.filter(|t| matches!(t.status, crate::tui::plan::TaskStatus::Completed))
.count();
let mut out = String::new();
if !plan.title.trim().is_empty() {
out.push_str(&format!(
"**📋 {}**\n",
escape_markdown(crate::utils::truncate_str(plan.title.trim(), TITLE_CAP))
));
}
out.push_str(&format!(
"`{done}/{total} done` · {}\n\n",
status_word(&plan.status)
));
if total == 0 {
out.push_str("_No checklist yet._");
return out;
}
let mut rows = String::new();
let mut painted = 0usize;
for task in &plan.tasks {
let mut row = format!(
"{} {}",
status_mark(&task.status),
escape_markdown(crate::utils::truncate_str(task.title.trim(), ROW_TEXT_CAP))
);
row.push_str(&crate::tui::plan::quality_glyph_suffix(task));
if let Some(verdict) = task.verification.as_ref() {
row.push_str(&format!(" {}", verdict.badge()));
}
if out.chars().count() + rows.chars().count() + row.chars().count() + 1 > CARD_CHAR_CAP {
break;
}
rows.push_str(&row);
rows.push('\n');
painted += 1;
}
out.push_str(&rows);
if painted < total {
out.push_str(&format!("… +{} more", total - painted));
} else {
out.pop(); }
out
}
fn repository() -> Option<crate::db::repository::PlanCardRepository> {
let pool = crate::db::database::global_pool()?.clone();
Some(crate::db::repository::PlanCardRepository::new(pool))
}
pub(crate) async fn refresh_plan_card(
dstate: &DiscordState,
http: &serenity::http::Http,
channel: ChannelId,
session_id: Uuid,
finalizing: bool,
) {
let card_lock = dstate.plan_card_lock(session_id).await;
let _guard = card_lock.lock().await;
let repo = repository();
let tracked = dstate
.plan_card(session_id, channel.get(), repo.as_ref())
.await;
let live = crate::utils::plan_files::load_plan(session_id).await;
let may_finalize = finalizing && tracked.is_some();
let archived = if live.is_none() && may_finalize {
crate::utils::plan_files::latest_archived_plan(session_id).await
} else {
None
};
let plan = match decide_paint(live, may_finalize, archived) {
PaintTarget::Skip => return,
PaintTarget::Live(plan) | PaintTarget::Finalizing(plan) => plan,
};
let rendered = render_plan_card(&plan);
if !should_paint(tracked.as_ref().map(|(_, sig)| sig.as_str()), &rendered) {
return;
}
let surface = match &tracked {
None => Surface::Send,
Some(_) => surface_for(finalizing),
};
let target = match (tracked, surface) {
(None, _) => Paint::Post,
(Some((mid, _)), Surface::Final) => Paint::Final(mid),
(Some((mid, _)), _) => Paint::Repaint(mid),
};
let message_id = match target {
Paint::Post => {
let builder = CreateMessage::new().content(rendered.clone());
match governor::send_content(&dstate.governor, channel, http, Surface::Send, builder)
.await
{
Ok(sent) => sent.id.get(),
Err(e) => {
tracing::warn!("Discord: plan card post failed: {e}");
return;
}
}
}
Paint::Repaint(mid) => {
let edit = EditMessage::new().content(rendered.clone());
match governor::edit_chrome(&dstate.governor, channel, http, MessageId::new(mid), edit)
.await
{
None => return, Some(Err(e)) => {
release_tracker(dstate, session_id, &e).await;
return;
}
Some(Ok(_)) => mid,
}
}
Paint::Final(mid) => {
let edit = EditMessage::new().content(rendered.clone());
match governor::edit_content(
&dstate.governor,
channel,
http,
MessageId::new(mid),
Surface::Final,
edit,
)
.await
{
Ok(_) => mid,
Err(e) => {
release_tracker(dstate, session_id, &e).await;
return;
}
}
}
};
let row = PlanCard {
session_id: session_id.to_string(),
chat_id: channel.get() as i64,
thread_id: None,
message_id: message_id as i64,
signature: rendered.clone(),
};
if finalizing {
dstate.forget_plan_card(session_id, repo.as_ref()).await;
return;
}
dstate
.remember_plan_card(
session_id,
channel.get(),
message_id,
rendered,
row,
repo.as_ref(),
)
.await;
}
enum Paint {
Post,
Repaint(u64),
Final(u64),
}
async fn release_tracker(dstate: &DiscordState, session_id: Uuid, error: &serenity::Error) -> bool {
if !untracks_the_card(&error.to_string()) {
tracing::warn!("Discord: plan card edit refused, keeping the tracker: {error}");
return false;
}
tracing::warn!("Discord: plan card is gone, untracking it: {error}");
dstate
.forget_plan_card(session_id, repository().as_ref())
.await;
true
}
pub(crate) async fn refresh_for_channel(
dstate: &DiscordState,
http: &serenity::http::Http,
channel: ChannelId,
) {
let Some(session_id) = dstate.session_owner_by_channel(channel.get()).await else {
return;
};
refresh_plan_card(dstate, http, channel, session_id, false).await;
}
impl DiscordState {
pub(crate) async fn plan_card(
&self,
session_id: Uuid,
channel: u64,
repo: Option<&crate::db::repository::PlanCardRepository>,
) -> Option<(u64, String)> {
if let Some((card_channel, message_id, signature)) =
self.plan_cards.lock().await.get(&session_id).cloned()
{
return card_is_ours(card_channel as i64, channel).then_some((message_id, signature));
}
let repo = repo?;
let Ok(Some(row)) = repo.get(&session_id.to_string()).await else {
return None;
};
if !card_is_ours(row.chat_id, channel) {
return None;
}
tracing::info!(
"Discord: rehydrated plan card for session {} (message {})",
session_id,
row.message_id
);
let hit = (channel, row.message_id as u64, row.signature.clone());
self.plan_cards.lock().await.insert(session_id, hit);
Some((row.message_id as u64, row.signature))
}
pub(crate) async fn plan_card_lock(&self, session_id: Uuid) -> Arc<tokio::sync::Mutex<()>> {
let mut locks = self.plan_card_locks.lock().await;
locks
.entry(session_id)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
pub(crate) async fn remember_plan_card(
&self,
session_id: Uuid,
channel: u64,
message_id: u64,
signature: String,
row: PlanCard,
repo: Option<&crate::db::repository::PlanCardRepository>,
) {
self.plan_cards
.lock()
.await
.insert(session_id, (channel, message_id, signature));
if let Some(repo) = repo
&& let Err(e) = repo.set(row).await
{
tracing::warn!("Discord: plan card persistence failed for {session_id}: {e}");
}
}
pub(crate) async fn forget_plan_card(
&self,
session_id: Uuid,
repo: Option<&crate::db::repository::PlanCardRepository>,
) {
self.plan_cards.lock().await.remove(&session_id);
if let Some(repo) = repo
&& let Err(e) = repo.delete(&session_id.to_string()).await
{
tracing::warn!("Discord: plan card row delete failed for {session_id}: {e}");
}
}
}