use super::TelegramState;
#[allow(unused_imports)]
use super::handler::*;
use super::send::{best_effort_delete, fire_chat_action};
use crate::a2a::handler::notify::CLI_SENDER_PREFIX;
use crate::brain::agent::service::background_tasks;
use crate::brain::agent::{AgentService, ProgressCallback, ProgressEvent};
use crate::config::Config;
use crate::db::ChannelMessageRepository;
use std::sync::Arc;
use teloxide::prelude::*;
use teloxide::types::ChatAction;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
pub(crate) fn build_enqueue_callback(
state: Arc<TelegramState>,
agent_holder: Arc<std::sync::Mutex<Option<std::sync::Weak<AgentService>>>>,
) -> crate::brain::agent::service::MessageEnqueueCallback {
Arc::new(move |session_id, msg| {
let state = state.clone();
let agent_holder = agent_holder.clone();
tokio::spawn(async move {
let Some(chat_id) = state.session_chat(session_id).await else {
tracing::warn!("[bg-resume] telegram: no chat for session {session_id}; dropping");
return;
};
if let crate::brain::agent::service::session_routes::ChannelOwnership::Occupied {
occupant,
} = state.channel_ownership_of(session_id)
{
tracing::warn!(
"[bg-resume] telegram: session {session_id} no longer owns chat {chat_id} — \
occupied by session {occupant}; refusing to wake it into the successor's \
conversation"
);
return;
}
let Some(bot) =
crate::channels::transport_ready::await_transport("telegram", session_id, || {
state.bot()
})
.await
else {
return;
};
let Some(agent) = agent_holder
.lock()
.ok()
.and_then(|g| g.as_ref().and_then(|w| w.upgrade()))
else {
tracing::warn!("[bg-resume] telegram: agent gone; dropping resume");
return;
};
let thread_id = match state.session_topic(session_id).await {
Some(topic) => super::session_resolve::delivery_thread_id(Some(topic)),
None => super::send::latest_thread_id_for_chat(chat_id).await,
};
if matches!(
msg.origin,
crate::brain::agent::PushOrigin::BackgroundTask
| crate::brain::agent::PushOrigin::SessionNotify
) {
let folded = if msg.origin == crate::brain::agent::PushOrigin::BackgroundTask {
match msg.bg_meta.clone() {
Some(meta) => {
fold_bg_ack_into_flow_card(
&bot, chat_id, &state, &agent, session_id, &meta,
)
.await
}
None => false,
}
} else {
false
};
let notify_folded = if state.is_turn_active(session_id)
&& msg.origin == crate::brain::agent::PushOrigin::SessionNotify
{
fold_notify_into_roll(
&state,
&bot,
session_id,
chat_id,
thread_id,
&msg.context_text,
)
.await
} else {
false
};
if !folded && !notify_folded {
let (wire, classic_html) = if let Some(meta) = msg.bg_meta.clone() {
build_bg_receipt_card(&meta)
} else {
let (sender, body) = split_bg_echo_parts(&msg.context_text);
match sender {
Some(NotifySender::Session(s)) => {
let label = sender_label(&state, &bot, s, chat_id).await;
build_notify_receipt_card(&label, &body).await
}
Some(NotifySender::CliTooling(label)) => {
build_notify_receipt_card(label, &body).await
}
None if msg.origin
== crate::brain::agent::PushOrigin::BackgroundTask =>
{
let title = background_task_title(&msg.display_text);
build_bg_echo_bubble(&body, &title)
}
None => build_bg_echo_bubble(&body, "⚙️ background task result"),
}
};
let rich_on = crate::config::Config::current()
.channels
.telegram
.rich_messages;
let sent_rich = match (&wire, rich_on) {
(BubbleWire::Html(html), true) => {
match super::rich::api::send_rich_html_id(
bot.api_url().as_str(),
bot.token(),
chat_id,
thread_id,
html,
None,
"bg-resume",
"-",
)
.await
{
Ok(_) => true,
Err(e) => {
tracing::warn!(
"[bg-resume] #38 rich HTML send failed, using HTML: {e}"
);
false
}
}
}
(BubbleWire::Html(_), false) => false,
(BubbleWire::Markdown(md), _) => {
match super::send::send_markdown_outbox(
&bot,
teloxide::types::ChatId(chat_id),
thread_id,
md,
"bg-resume",
"-",
None,
)
.await
{
Ok(_) => true,
Err(e) => {
tracing::warn!(
"[bg-resume] #1234 rich echo failed, using HTML: {e}"
);
false
}
}
}
};
if !sent_rich {
let mut echo = bot
.send_message(teloxide::types::ChatId(chat_id), classic_html.clone())
.parse_mode(teloxide::types::ParseMode::Html);
if let Some(tid) = thread_id {
echo = echo.message_thread_id(tid);
}
match echo.await {
Ok(_) => {}
Err(teloxide::RequestError::RetryAfter(secs)) => {
super::rate_limit::wait_out(
"bg-resume echo",
secs.duration(),
" on first delivery, retrying once",
)
.await;
let mut retry = bot
.send_message(teloxide::types::ChatId(chat_id), classic_html)
.parse_mode(teloxide::types::ParseMode::Html);
if let Some(tid) = thread_id {
retry = retry.message_thread_id(tid);
}
if let Err(e) = retry.await {
tracing::warn!(
"[bg-resume] #1221 echo bubble failed on 429 retry: {e}"
);
}
}
Err(e) => {
tracing::warn!("[bg-resume] #1221 echo bubble failed to send: {e}");
}
}
}
} }
let Some(_turn_guard) = state.try_begin_turn(session_id) else {
tracing::info!(
"[bg-resume] telegram: session {session_id} already streaming — queuing the \
result for the in-flight turn instead of opening a second block"
);
let mut msg = msg;
msg.context_text = format!(
"[queued while you were working — re-anchor to your current task after \
reading this]\n\n{}",
msg.context_text
);
state.enqueue_detached_result(session_id, msg);
return;
};
let thread_id = match state.session_topic(session_id).await {
Some(topic) => super::session_resolve::delivery_thread_id(Some(topic)),
None => super::send::latest_thread_id_for_chat(chat_id).await,
};
if let Err(e) = resume_session_inner(
bot,
teloxide::types::ChatId(chat_id),
thread_id,
session_id,
msg.context_text,
agent,
state,
true, )
.await
{
tracing::warn!("[bg-resume] telegram resume_session failed: {e}");
}
});
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn resume_session(
bot: Bot,
chat_id: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
session_id: Uuid,
prompt: String,
agent: Arc<AgentService>,
telegram_state: Arc<TelegramState>,
track_push_turn: bool,
) -> anyhow::Result<()> {
let _turn_guard = match telegram_state.try_begin_turn(session_id) {
Some(guard) => guard,
None => {
tracing::warn!(
"Telegram: resume_session {session_id} skipped — a turn is already active for this session"
);
return Ok(());
}
};
resume_session_inner(
bot,
chat_id,
thread_id,
session_id,
prompt,
agent,
telegram_state,
track_push_turn,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn resume_session_inner(
bot: Bot,
chat_id: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
session_id: Uuid,
prompt: String,
agent: Arc<AgentService>,
telegram_state: Arc<TelegramState>,
track_push_turn: bool,
) -> anyhow::Result<()> {
tracing::info!(
"Telegram: resume_session {} with full streaming pipeline",
session_id
);
let typing_cancel = CancellationToken::new();
let _typing_guard = TypingGuard(typing_cancel.clone());
super::typing::spawn_typing(
bot.clone(),
chat_id,
thread_id,
typing_cancel.clone(),
agent.background_manager(),
session_id,
);
let streaming = Arc::new(std::sync::Mutex::new(StreamingState {
is_dm: chat_id.0 > 0,
compacting: false,
pending_suggestions: None,
pending_trailer: None,
msg_id: None,
thinking: String::new(),
tool_msgs: Vec::new(),
display_queue: Vec::new(),
open_group_msg_id: None,
rich_transport_failures: 0,
flow_entries: Vec::new(),
flow_status: None,
flow_rich: false,
response: String::new(),
final_bubble: None,
dirty: false,
recreate: false,
header_preview: None,
sections: Default::default(),
retained_goal: None,
applied_plan_kb: Default::default(),
tool_round_count: 0,
tools_started_at: Some(std::time::Instant::now()),
turn_started_at: std::time::Instant::now(),
flow_outcome: None,
bg_indicator: None,
bg_count: None,
subagent_counts: Default::default(),
sent_intermediates: Vec::new(),
intermediate_msg_ids: Vec::new(),
voice_msg_ids: Vec::new(),
processing: true,
is_cli: agent.provider_for_session(session_id).cli_handles_tools(),
}));
let edit_cancel = CancellationToken::new();
let edit_loop_handle = super::stream_loop::spawn_edit_loop(
&bot,
chat_id,
None,
thread_id,
chat_id.0 > 0,
&streaming,
&edit_cancel,
&telegram_state,
&agent,
session_id,
);
let progress_cb: ProgressCallback = {
let st = streaming.clone();
let bot_typing = bot.clone();
let chat_typing = chat_id;
Arc::new(move |_sid, event| match event {
ProgressEvent::Compacting {
usage_pct,
predicted,
} => {
let bot = bot_typing.clone();
let chat = chat_typing;
tokio::spawn(async move {
fire_chat_action(&bot, chat, thread_id, ChatAction::Typing, "resume typing")
.await;
});
if let Ok(mut s) = st.lock() {
s.compacting = true;
s.header_preview = Some(COMPACTING_HEADER_TEXT.to_string());
s.display_queue
.push(DisplayItem::Intermediate(compacting_flow_line(
usage_pct, predicted,
)));
}
}
ProgressEvent::ReasoningChunk { text } => {
if let Ok(mut s) = st.lock() {
s.thinking.push_str(&text);
s.dirty = true;
}
}
ProgressEvent::StreamingChunk { text } => {
if let Ok(mut s) = st.lock() {
if !s.thinking.is_empty() {
s.thinking.clear();
}
s.response.push_str(&text);
s.dirty = true;
s.processing = false;
}
}
ProgressEvent::ToolStarted {
tool_name,
tool_input,
} => {
if let Ok(mut s) = st.lock() {
s.thinking.clear();
if s.tools_started_at.is_none() {
s.tools_started_at = Some(std::time::Instant::now());
}
let ctx = tool_context(&tool_name, &tool_input);
let raw_ctx = crate::utils::tool_status_source(&tool_name, &tool_input);
let idx = s.tool_msgs.len();
s.tool_msgs.push(ToolMsg {
msg_id: None,
name: tool_name,
context: ctx,
raw_context: raw_ctx,
completed: None,
dirty: true,
});
s.display_queue.push(DisplayItem::NewTool(idx));
}
}
ProgressEvent::ToolCompleted {
tool_name, success, ..
} => {
if let Ok(mut s) = st.lock() {
s.tool_round_count += 1;
if let Some(tool) = s
.tool_msgs
.iter_mut()
.rev()
.find(|t| t.name == tool_name && t.completed.is_none())
{
tool.completed = Some(success);
tool.dirty = true;
}
}
}
ProgressEvent::QueuedUserMessage { .. } => {
detach_flow_for_followup(&st);
}
ProgressEvent::IntermediateText { text, reasoning: _ } => {
if let Ok(mut s) = st.lock() {
s.thinking.clear();
s.response.clear();
if s.msg_id.is_some() {
s.recreate = true;
}
if !text.is_empty() {
s.display_queue.push(DisplayItem::Intermediate(text));
}
}
}
ProgressEvent::SelfHealingAlert { message } => {
if let Ok(mut s) = st.lock() {
s.display_queue
.push(DisplayItem::System(format!("🔧 {}", message)));
}
}
ProgressEvent::RetryAttempt {
attempt,
max,
reason,
} => {
if let Ok(mut s) = st.lock() {
s.display_queue.push(DisplayItem::System(format!(
"⏳ Retry {}/{} — {}",
attempt, max, reason
)));
}
}
ProgressEvent::ProviderSwitched {
to_name, to_model, ..
} => {
if let Ok(mut s) = st.lock() {
s.display_queue.push(DisplayItem::System(format!(
"🔄 Now using {}/{}",
to_name, to_model
)));
}
}
ProgressEvent::SuggestedOptions(options) => {
if let Ok(mut s) = st.lock() {
s.pending_suggestions = Some(options);
}
}
ProgressEvent::CompactionSummary {
before_pct,
after_pct,
elapsed,
..
} => {
if let Ok(mut s) = st.lock() {
s.compacting = false;
s.header_preview = None;
s.display_queue
.push(DisplayItem::Intermediate(compacted_flow_line(
before_pct, after_pct, elapsed,
)));
}
}
_ => {}
})
};
let cancel_token = CancellationToken::new();
telegram_state
.store_cancel_token(session_id, cancel_token.clone())
.await;
let chat_id_str = chat_id.0.to_string();
let result = if track_push_turn {
agent
.send_push_turn(
session_id,
prompt,
None,
Some(cancel_token.clone()),
None, Some(progress_cb),
"telegram",
Some(&chat_id_str),
None, )
.await
} else {
agent
.resume_interrupted_turn(
session_id,
prompt,
None,
Some(cancel_token.clone()),
None, Some(progress_cb),
"telegram",
Some(&chat_id_str),
)
.await
};
telegram_state.remove_cancel_token(session_id).await;
edit_cancel.cancel();
if let Err(e) = edit_loop_handle.await {
tracing::warn!(error = %e, "Telegram resume edit loop task panicked");
}
let (streaming_msg_id, remaining_display) = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
let display: Vec<DisplayItem> = std::mem::take(&mut s.display_queue);
(s.msg_id, display)
};
if cancel_token.is_cancelled() {
tracing::info!(
"Telegram: resume for session {} cancelled by new message",
session_id
);
if let Some(mid) = streaming_msg_id {
best_effort_delete(&bot, chat_id, mid, "streaming teardown").await;
}
return Ok(());
}
drain_remaining_display(
&bot,
chat_id,
thread_id,
&streaming,
remaining_display,
None,
)
.await;
let voice_config = Config::current().voice_config();
let channel_msg_repo = ChannelMessageRepository::new(agent.context().pool().clone());
let is_dm = chat_id.0 > 0;
let flow_outcome = match &result {
Ok(_) => FlowOutcome::Finished,
Err(e) => {
let es = e.to_string().to_lowercase();
if es.contains("timed out") || es.contains("timeout") || es.contains("deadline") {
FlowOutcome::TimedOut
} else {
FlowOutcome::Failed
}
}
};
if !super::handler::deliver_final_response(
&bot,
chat_id,
None,
thread_id,
&streaming,
session_id,
&agent,
&telegram_state,
&channel_msg_repo,
&voice_config,
false,
is_dm,
"unknown",
streaming_msg_id,
result,
)
.await?
{
return Ok(());
}
{
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.flow_outcome = Some(flow_outcome);
let (bg_indicator, bg_count) = super::handler::bg_indicator_for(&agent, session_id);
s.bg_indicator = bg_indicator;
s.bg_count = bg_count;
s.subagent_counts = super::handler::subagent_counts_for(&agent, session_id);
}
super::flow_chrome::refresh_sections(&streaming, &agent, session_id).await;
refresh_flow(&bot, chat_id, &streaming, super::governor::EditClass::Final).await;
if streaming
.lock()
.unwrap_or_else(|e| e.into_inner())
.open_group_msg_id
.is_some()
{
telegram_state
.register_flow_state(session_id, Arc::clone(&streaming))
.await;
}
let plan_kb = {
streaming
.lock()
.unwrap_or_else(|e| e.into_inner())
.sections
.plan_kb
};
super::plan_card::refresh_plan_card(
&bot,
chat_id,
thread_id,
&telegram_state,
&agent,
session_id,
plan_kb,
)
.await;
let suggestions = streaming
.lock()
.unwrap_or_else(|e| e.into_inner())
.pending_suggestions
.take();
if let Some(options) = suggestions {
let (merge_host, trailer) = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
(s.final_bubble.take(), s.pending_trailer.take())
};
super::suggest_options::render_suggestions(
&bot,
&telegram_state,
session_id,
chat_id,
thread_id,
options,
merge_host,
trailer,
)
.await;
}
Ok(())
}
const BG_ECHO_BODY_CAP_CHARS: usize = 3200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NotifySender<'a> {
Session(Uuid),
CliTooling(&'a str),
}
pub(crate) fn split_notify_header(context_text: &str) -> (Option<NotifySender<'_>>, &str) {
let trimmed = context_text.trim_start();
let Some(after_open) = trimmed.strip_prefix("[session-notify from=") else {
return (None, trimmed);
};
let Some(close) = after_open.find(']') else {
return (None, trimmed);
};
let rest = after_open[close + 1..].trim_start();
let candidate = &after_open[..close];
if let Some(label) = candidate.strip_prefix(CLI_SENDER_PREFIX) {
let label = label.trim();
if !label.is_empty() {
return (Some(NotifySender::CliTooling(label)), rest);
}
}
match Uuid::parse_str(candidate) {
Ok(sender) => (Some(NotifySender::Session(sender)), rest),
Err(_) => (None, trimmed),
}
}
#[cfg(test)]
pub(crate) fn strip_system_framing(text: &str) -> &str {
let trimmed = text.trim();
trimmed
.strip_prefix("[System:")
.and_then(|s| s.strip_suffix(']'))
.map(str::trim)
.unwrap_or(trimmed)
}
pub(crate) fn split_bg_echo_parts(context_text: &str) -> (Option<NotifySender<'_>>, String) {
let (sender, rest) = split_notify_header(context_text);
(sender, strip_push_scaffolding(rest))
}
fn strip_push_scaffolding(rest: &str) -> String {
let trimmed = rest.trim_start();
match trimmed.strip_prefix("[System:") {
Some(s) => match s.find(']') {
Some(end) => {
let inner = s[..end].trim();
let tail = s[end + 1..].trim();
if tail.is_empty() {
inner.to_owned()
} else {
format!("{inner}\n{tail}")
}
}
None => rest.to_owned(),
},
None => rest.to_owned(),
}
}
pub(crate) enum BubbleWire {
Html(String),
Markdown(String),
}
pub(crate) fn build_bg_echo_bubble(body: &str, title: &str) -> (BubbleWire, String) {
let truncated = body.chars().count() > BG_ECHO_BODY_CAP_CHARS;
let body = crate::utils::string::truncate_chars(body, BG_ECHO_BODY_CAP_CHARS);
let suffix = if truncated { " (truncated)" } else { "" };
let markdown = format!("{title}{suffix}\n\n{body}");
let html = format!(
"<blockquote expandable><b>{}{}</b>\n{}</blockquote>",
super::markdown::escape_html(title),
suffix,
super::rich::markdown_to_html(body),
);
(BubbleWire::Markdown(markdown), html)
}
fn receipt_fence(tail: &str) -> String {
let (mut longest, mut run) = (0usize, 0usize);
for ch in tail.chars() {
if ch == '`' {
run += 1;
longest = longest.max(run);
} else {
run = 0;
}
}
"`".repeat(if longest >= 3 { longest + 1 } else { 3 })
}
pub(crate) fn bg_ack_line(meta: &crate::brain::agent::BgTaskMeta) -> String {
let icon = if meta.success { "✅" } else { "❌" };
let stripped = meta.label.replace('`', "");
let label = stripped.trim();
let label = if label.is_empty() {
"background task"
} else {
label
};
let duration = background_tasks::format_elapsed(meta.elapsed_secs);
let preview = first_line_preview(&meta.tail);
if preview.is_empty() {
format!("{icon} `{label}` 🕒 {duration}")
} else {
format!("{icon} `{label}` 🕒 {duration} · {preview}")
}
}
pub(crate) fn apply_bg_ack_fold(
s: &mut crate::channels::telegram::flow::StreamingState,
line: String,
bg_indicator: Option<String>,
bg_count: Option<usize>,
) -> bool {
if s.open_group_msg_id.is_none() {
return false;
}
s.flow_entries
.push(crate::channels::telegram::flow::FlowEntry::System(line));
s.bg_indicator = bg_indicator;
s.bg_count = bg_count;
true
}
pub(crate) async fn fold_bg_ack_into_flow_card(
bot: &Bot,
chat_id: i64,
state: &Arc<TelegramState>,
agent: &Arc<AgentService>,
session_id: Uuid,
meta: &crate::brain::agent::BgTaskMeta,
) -> bool {
let Some(streaming) = state.flow_state_for(session_id).await else {
return false;
};
let line = bg_ack_line(meta);
let (bg_indicator, bg_count) = super::delivery::bg_indicator_for(agent, session_id);
let folded = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
apply_bg_ack_fold(&mut s, line, bg_indicator, bg_count)
};
if !folded {
return false;
}
super::flow::refresh_flow(
bot,
teloxide::types::ChatId(chat_id),
&streaming,
super::governor::EditClass::Status,
)
.await;
true
}
pub(crate) fn build_bg_receipt_card(
meta: &crate::brain::agent::BgTaskMeta,
) -> (BubbleWire, String) {
let icon = if meta.success { "✅" } else { "❌" };
let stripped = meta.label.replace('`', "");
let label = stripped.trim();
let label = if label.is_empty() {
"background task"
} else {
label
};
let duration = background_tasks::format_elapsed(meta.elapsed_secs);
let flat_title = format!("{icon} {label} 🕒 {duration}");
if meta.tail.trim().is_empty() {
let markdown = format!("{icon} `{label}` 🕒 {duration}");
let classic = format!(
"<b>{icon} {} 🕒 {duration}</b>",
super::markdown::escape_html(label)
);
return (BubbleWire::Markdown(markdown), classic);
}
let rich_html = format!(
"<details><summary><sub>{icon} <code>{}</code> 🕒 {duration}</sub></summary>\n\
<pre>{}</pre>\n</details>",
super::markdown::escape_html(label),
super::markdown::escape_html(&meta.tail)
);
let fence = receipt_fence(&meta.tail);
let fenced_body = format!("{fence}\n{tail}\n{fence}", tail = meta.tail);
let (_, classic_html) = build_bg_echo_bubble(&fenced_body, &flat_title);
(BubbleWire::Html(rich_html), classic_html)
}
fn first_line_preview(body: &str) -> String {
let line = body.lines().next().unwrap_or("").trim();
if line.chars().count() > 45 {
format!("{}…", crate::utils::string::truncate_chars(line, 45))
} else {
line.to_string()
}
}
pub(crate) async fn build_notify_receipt_card(
sender_label: &str,
body: &str,
) -> (BubbleWire, String) {
let sanitized = sender_label.replace('<', "‹").replace('>', "›");
let sender = sanitized.trim();
let sender = if sender.is_empty() {
"session notify"
} else {
sender
};
if body.trim().is_empty() {
let markdown = format!("📨 From **{sender}**");
let classic = format!("📨 From <b>{sender}</b>");
return (BubbleWire::Markdown(markdown), classic);
}
let preview = first_line_preview(body);
let truncated = body.chars().count() > BG_ECHO_BODY_CAP_CHARS;
let body = crate::utils::string::truncate_chars(body, BG_ECHO_BODY_CAP_CHARS);
let suffix = if truncated { " (truncated)" } else { "" };
let body_html = super::rich::markdown_to_html_mermaid_p(body).await;
let rich_html = format!(
"<details><summary><sub>📨 From <b>{sender}</b>: {}</sub></summary>\n\n\
{body_html}{suffix}\n\n</details>",
super::markdown::escape_html(&preview)
);
let flat_title = format!("📨 From {sender}: {preview}");
let (_, classic_html) = build_bg_echo_bubble(&format!("{body}{suffix}"), &flat_title);
(BubbleWire::Html(rich_html), classic_html)
}
pub(crate) const NOTIFY_ROLL_LINE_MAX: usize = 160;
fn strip_leading_notify_echo(first: &str) -> &str {
let Some(rest) = first.strip_prefix("📨 notify from") else {
return first;
};
match rest.find(':') {
Some(idx) => rest[idx + 1..].trim_start(),
None => first,
}
}
pub(crate) fn build_notify_roll_line(label: &str, body: &str) -> String {
let sanitized = label.replace('<', "‹").replace('>', "›");
let first = body.lines().next().map(str::trim).unwrap_or("");
let first = strip_leading_notify_echo(first);
let line = format!("📨 notify from {sanitized}: {first}");
if line.chars().count() > NOTIFY_ROLL_LINE_MAX {
let mut cut: String = line.chars().take(NOTIFY_ROLL_LINE_MAX).collect();
cut.push('…');
cut
} else {
line
}
}
pub(crate) fn notify_fingerprint(sender: &NotifySender<'_>, body: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
match sender {
NotifySender::Session(u) => {
0u8.hash(&mut hasher);
u.as_bytes().hash(&mut hasher);
}
NotifySender::CliTooling(label) => {
1u8.hash(&mut hasher);
label.hash(&mut hasher);
}
}
body.hash(&mut hasher);
hasher.finish()
}
pub(crate) async fn fold_notify_into_roll(
state: &TelegramState,
bot: &teloxide::Bot,
session_id: Uuid,
chat_id: i64,
thread_id: Option<teloxide::types::ThreadId>,
context_text: &str,
) -> bool {
let (sender, body) = split_bg_echo_parts(context_text);
let Some(sender) = sender else {
return false;
};
let Some(handle) = state.live_flow(session_id) else {
return false;
};
let roll_open = {
let s = handle.streaming.lock().unwrap_or_else(|e| e.into_inner());
s.open_group_msg_id
};
if roll_open.is_none() {
return false;
}
let fingerprint = notify_fingerprint(&sender, &body);
if state.note_notify_fold(session_id, fingerprint) {
tracing::debug!(
"[bg-resume] #61: duplicate notify fold suppressed for session {session_id}"
);
return true;
}
let label = match sender {
NotifySender::Session(s) => sender_label(state, bot, s, chat_id).await,
NotifySender::CliTooling(l) => l.to_string(),
};
let line = build_notify_roll_line(&label, &body);
super::flow::append_system_to_flow(
bot,
teloxide::types::ChatId(chat_id),
thread_id,
&handle.streaming,
&line,
)
.await;
tracing::info!(
"[bg-resume] #61: notify from session {session_id} folded into the live flow roll"
);
true
}
pub(crate) fn background_task_title(display_text: &str) -> String {
let t = display_text.trim();
if t.is_empty() {
"⚙️ background task result".to_owned()
} else {
crate::utils::string::truncate_chars(t, 120).to_owned()
}
}
pub(crate) async fn sender_label(
state: &TelegramState,
bot: &teloxide::Bot,
sender: Uuid,
recipient_chat: i64,
) -> String {
let sender_chat = state.session_chat(sender).await;
let sender_topic = state.session_topic(sender).await;
let api_url = bot.api_url().to_string();
let token = bot.token().to_owned();
let label = match sender_chat {
None => None,
Some(sc) if sc == recipient_chat => match sender_topic {
Some(tid) => local_topic_name(sc, tid).await,
None => None,
},
Some(sc) if sc > 0 => state.bot_username().await,
Some(sc) => {
let chat =
super::titles::chat_title(&api_url, &token, teloxide::types::ChatId(sc)).await;
match (chat, sender_topic) {
(Some(c), Some(tid)) => match local_topic_name(sc, tid).await {
Some(t) => Some(format!("{c} / {t}")),
None => Some(c),
},
(Some(c), None) => Some(c),
(None, _) => None,
}
}
};
label.unwrap_or_else(|| short_session_id(sender))
}
async fn local_topic_name(chat_id: i64, thread_id: i32) -> Option<String> {
let pool = crate::db::global_pool()?;
let repo = ChannelMessageRepository::new(pool.clone());
repo.latest_topic_name("telegram", &chat_id.to_string(), &thread_id.to_string())
.await
.ok()
.flatten()
}
pub(crate) fn short_session_id(uuid: Uuid) -> String {
uuid.simple().to_string()[..8].to_owned()
}
pub const WAKE_RECENT_SECS: i64 = 600;
pub async fn wake_recently_active(
pool: crate::db::Pool,
already_resumed: &std::collections::HashSet<Uuid>,
) -> usize {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let since_epoch = now.saturating_sub(WAKE_RECENT_SECS);
let repo = crate::db::SessionBindingRepository::new(pool);
let Ok(bindings) = repo.recent_for_channel("telegram", since_epoch).await else {
tracing::warn!(target: "telegram", "Boot wake could not read recent session bindings");
return 0;
};
let mut stranded: Vec<String> = Vec::new();
for b in bindings {
let Ok(sid) = Uuid::parse_str(&b.session_id) else {
continue;
};
if already_resumed.contains(&sid) {
continue;
}
stranded.push(short_session_id(sid));
}
let scheduled = stranded.len();
if scheduled > 0 {
tracing::info!(
target: "telegram",
"Boot wake pass (log-only, #34): recently-active sessions not resumed: [{}]",
stranded.join(",")
);
tracing::info!(
target: "telegram",
"Scheduled boot wake for {scheduled} recently-active session(s) (#1227)"
);
}
scheduled
}