use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use super::flow::{
FlowEntry, FlowOutcome, StreamingState, drain_display_queue, folded_duplicates_final,
humanize_duration, last_folded_text, pop_trailing_folded_texts, render_flow,
render_flow_rich_state,
};
use super::handler::{build_midturn_queued_message, thinking_status_excerpt};
use super::markdown::markdown_to_telegram_html;
use super::progress::apply_progress_event;
use crate::brain::agent::AgentService;
use crate::channels::telegram::TelegramState;
use crate::config::Config;
use crate::services::SessionService;
const GUEST_TEXT_LIMIT: usize = 4096;
const GUEST_RICH_TEXT_LIMIT: usize = 32768;
const GUEST_RESULT_ID: &str = "opencrabs-guest";
const TRUNCATION_MARKER: &str = "\n\n[truncated]";
const GUEST_PLACEHOLDER: &str = "⏳ Working on it…";
const GUEST_EDIT_INTERVAL: Duration = Duration::from_millis(1500);
const GUEST_FOLDED_NOTE: &str = "⏳ Added to the turn already running.";
const GUEST_FOLD_MAX_ROUNDS: usize = 3;
const MIN_ANSWER_ROOM: usize = 200;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GuestQuery {
pub query_id: String,
pub user_id: i64,
pub username: Option<String>,
pub chat_id: Option<i64>,
pub chat_title: Option<String>,
pub text: String,
}
pub(crate) fn parse_guest_query(update: &Value) -> Option<GuestQuery> {
let msg = update.get("guest_message")?;
let query_id = msg
.get("guest_query_id")
.and_then(Value::as_str)
.unwrap_or("");
if query_id.is_empty() {
return None;
}
let caller_user = msg.get("guest_bot_caller_user").or_else(|| msg.get("from"));
let user_id = caller_user
.and_then(|u| u.get("id"))
.and_then(Value::as_i64)?;
let username = caller_user
.and_then(|u| u.get("username"))
.and_then(Value::as_str)
.map(str::to_string);
let caller_chat = msg.get("guest_bot_caller_chat").or_else(|| msg.get("chat"));
let chat_id = caller_chat
.and_then(|c| c.get("id"))
.and_then(Value::as_i64);
let chat_title = caller_chat
.and_then(|c| c.get("title"))
.and_then(Value::as_str)
.map(str::to_string);
let text = msg
.get("text")
.or_else(|| msg.get("caption"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
Some(GuestQuery {
query_id: query_id.to_string(),
user_id,
username,
chat_id,
chat_title,
text,
})
}
pub(crate) fn strip_directives(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("<<") {
let after = &rest[start + 2..];
match after.find(">>") {
Some(end) if !after[..end].contains('\n') => {
out.push_str(&rest[..start]);
rest = &after[end + 2..];
}
_ => {
out.push_str(&rest[..start + 2]);
rest = after;
}
}
}
out.push_str(rest);
out
}
pub(crate) fn utf16_len(text: &str) -> usize {
text.chars().map(char::len_utf16).sum()
}
pub(crate) fn visible_len(html: &str) -> usize {
let mut count = 0usize;
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => count += ch.len_utf16(),
_ => {}
}
}
count
}
fn fit_text(text: &str, room: usize) -> String {
if utf16_len(text) <= room {
return text.to_string();
}
let marker = utf16_len(TRUNCATION_MARKER);
let keep = room.saturating_sub(marker);
let mut used = 0usize;
let mut out = String::new();
for ch in text.chars() {
let width = ch.len_utf16();
if used + width > keep {
break;
}
out.push(ch);
used += width;
}
if room >= marker {
out.push_str(TRUNCATION_MARKER);
}
out
}
fn clamp_guest_text(text: &str) -> String {
fit_text(text, GUEST_TEXT_LIMIT)
}
fn fit_rich_text(text: &str, room: usize) -> String {
if text.chars().count() <= room {
return text.to_string();
}
text.chars().take(room).collect()
}
fn clamp_guest_rich_text(text: &str) -> String {
fit_rich_text(text, GUEST_RICH_TEXT_LIMIT)
}
pub(crate) fn compose_guest_message(block: &str, answer_markdown: &str, rich: bool) -> String {
if rich {
let answer = clamp_guest_rich_text(answer_markdown.trim());
let room = GUEST_RICH_TEXT_LIMIT.saturating_sub(block.chars().count() + 2);
if block.is_empty() || room < MIN_ANSWER_ROOM {
return answer;
}
let fitted = fit_rich_text(answer_markdown.trim(), room);
let composed = if fitted.is_empty() {
block.to_string()
} else {
format!("{block}\n\n{fitted}")
};
if composed.chars().count() > GUEST_RICH_TEXT_LIMIT {
return answer;
}
return composed;
}
let answer = clamp_guest_text(answer_markdown.trim());
let room = GUEST_TEXT_LIMIT.saturating_sub(visible_len(block) + 2);
if block.is_empty() || room < MIN_ANSWER_ROOM {
return answer;
}
let fitted = fit_text(answer_markdown.trim(), room);
let composed = if fitted.is_empty() {
block.to_string()
} else {
format!("{block}\n\n{}", markdown_to_telegram_html(&fitted))
};
if visible_len(&composed) > GUEST_TEXT_LIMIT {
return answer;
}
composed
}
pub(crate) fn settle_guest_answer(
entries: &mut Vec<FlowEntry>,
content: &str,
options_pending: bool,
) -> String {
if content.trim().is_empty() {
let (host, trailer) = pop_trailing_folded_texts(entries, options_pending);
return match (host, trailer) {
(Some(host), Some(trailer)) => format!("{host}\n\n{trailer}"),
(Some(host), None) => host,
(None, _) => String::new(),
};
}
while last_folded_text(entries).is_some_and(|folded| folded_duplicates_final(folded, content)) {
let Some(pos) = entries
.iter()
.rposition(|e| matches!(e, FlowEntry::Text(_)))
else {
break;
};
entries.remove(pos);
}
if options_pending {
let mut trial = entries.clone();
let (host, trailer) = pop_trailing_folded_texts(&mut trial, true);
let reclaimed = [host, trailer]
.into_iter()
.flatten()
.any(|run| folded_duplicates_final(&run, content));
if reclaimed {
*entries = trial;
}
}
content.to_string()
}
fn guest_title(text: &str) -> String {
let first = text
.lines()
.find(|l| !l.trim().is_empty())
.map(str::trim)
.unwrap_or("Reply");
let mut title: String = first.chars().take(64).collect();
if title.is_empty() {
title.push_str("Reply");
}
title
}
pub(crate) fn answer_guest_query_body(query_id: &str, text: &str, rich: bool) -> Value {
let content = if rich {
json!({ "rich_message": { "markdown": clamp_guest_rich_text(text) } })
} else {
json!({ "message_text": clamp_guest_text(text) })
};
json!({
"guest_query_id": query_id,
"result": {
"type": "article",
"id": GUEST_RESULT_ID,
"title": guest_title(text),
"input_message_content": content,
}
})
}
pub(crate) fn prepare_guest_text(reply: &str) -> String {
let text = crate::utils::sanitize::strip_llm_artifacts(reply);
let stripped = strip_directives(&text).trim().to_string();
crate::utils::redact_secrets_scoped(&stripped, false)
}
pub(crate) fn guest_approval_callback() -> crate::brain::agent::ApprovalCallback {
Arc::new(|_info| {
Box::pin(async {
match crate::utils::check_approval_policy() {
Some(verdict) => Ok(verdict),
None => Ok((false, false)),
}
})
})
}
fn guest_progress_cb(state: &Arc<Mutex<StreamingState>>) -> crate::brain::agent::ProgressCallback {
let st = Arc::clone(state);
Arc::new(move |_session_id, event| apply_progress_event(&st, event))
}
pub(crate) struct GuestRunner {
token: Arc<String>,
agent: Arc<AgentService>,
session_svc: SessionService,
config_rx: tokio::sync::watch::Receiver<Config>,
state: Arc<TelegramState>,
sessions: Mutex<HashMap<(i64, i64), Uuid>>,
session_init: tokio::sync::Mutex<()>,
}
struct GuestOwnerGuard {
state: Arc<TelegramState>,
session_id: Uuid,
}
impl Drop for GuestOwnerGuard {
fn drop(&mut self) {
self.state.forget_guest_owner(self.session_id);
}
}
impl GuestRunner {
pub(crate) fn new(
token: Arc<String>,
agent: Arc<AgentService>,
session_svc: SessionService,
config_rx: tokio::sync::watch::Receiver<Config>,
state: Arc<TelegramState>,
) -> Arc<Self> {
Arc::new(Self {
token,
agent,
session_svc,
config_rx,
state,
sessions: Mutex::new(HashMap::new()),
session_init: tokio::sync::Mutex::new(()),
})
}
pub(crate) fn spawn(self: &Arc<Self>, query: GuestQuery) {
let me = Arc::clone(self);
tokio::spawn(async move { me.answer(query).await });
}
async fn session_for(&self, query: &GuestQuery) -> Uuid {
let key = (query.user_id, query.chat_id.unwrap_or(i64::MIN));
if let Some(id) = self
.sessions
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
.copied()
{
return id;
}
let _creating = self.session_init.lock().await;
if let Some(id) = self
.sessions
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
.copied()
{
return id;
}
let title = match (&query.chat_title, query.chat_id) {
(Some(t), Some(c)) => format!("Guest: {t} ({c})"),
(Some(t), None) => format!("Guest: {t}"),
(None, Some(c)) => format!("Guest chat {c}"),
(None, None) => "Guest chat".to_string(),
};
let session_id = match crate::channels::session_init::create_channel_session(
&self.session_svc,
Some(title),
None,
)
.await
{
Ok(s) => s.id,
Err(e) => {
tracing::warn!("Telegram guest: session create failed: {e}");
return Uuid::new_v4();
}
};
self.sessions
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key, session_id);
self.agent.mark_guest_session(session_id);
crate::memory::mark_session_shared(session_id);
session_id
}
async fn answer(self: &Arc<Self>, query: GuestQuery) {
let cfg = self.config_rx.borrow().clone();
let rich = cfg.channels.telegram.rich_messages;
let chat_key = query.chat_id.map(|c| c.to_string());
if !cfg
.channels
.telegram
.guest_allowed(&query.user_id.to_string(), chat_key.as_deref())
{
tracing::info!(
"Telegram guest: refusing query {} from user {} ({}) in chat {:?} \
(guest_queries={}); owner, admin, or a listing for that chat is required",
query.query_id,
query.user_id,
query.username.as_deref().unwrap_or("unknown"),
query.chat_id,
cfg.channels.telegram.guest_queries,
);
return;
}
if query.text.trim().is_empty() {
tracing::info!(
"Telegram guest: query {} from user {} carries no text, nothing to answer \
(guest turns are text only)",
query.query_id,
query.user_id
);
return;
}
tracing::info!(
"Telegram guest: answering query {} from user {} ({}) in chat {:?} (\"{}\")",
query.query_id,
query.user_id,
query.username.as_deref().unwrap_or("unknown"),
query.chat_id,
query.chat_title.as_deref().unwrap_or("unknown"),
);
let session_id = self.session_for(&query).await;
let _turn_guard = match self.state.try_begin_turn(session_id) {
Some(guard) => guard,
None => {
tracing::info!(
"Telegram guest: query {} arrived mid-turn on session {session_id} — folded \
into the running turn instead of starting a second one",
query.query_id
);
let queued = build_midturn_queued_message(None, &query.text, &query.text);
self.state.enqueue_reaction(session_id, queued);
if let Err(e) = self
.post_answer(&answer_guest_query_body(
&query.query_id,
GUEST_FOLDED_NOTE,
rich,
))
.await
{
tracing::warn!(
"Telegram guest: could not answer folded query {}: {e}",
query.query_id
);
}
return;
}
};
let state = Arc::new(Mutex::new(StreamingState::new_turn(
false,
self.agent
.provider_for_session(session_id)
.cli_handles_tools(),
)));
let cancel = CancellationToken::new();
self.state
.store_cancel_token(session_id, cancel.clone())
.await;
self.state.register_guest_owner(session_id, query.user_id);
let _owner = GuestOwnerGuard {
state: Arc::clone(&self.state),
session_id,
};
let opened = match self
.post_answer(&answer_guest_query_body(
&query.query_id,
GUEST_PLACEHOLDER,
rich,
))
.await
{
Ok(inline_message_id) => Some(inline_message_id),
Err(e) => {
tracing::warn!(
"Telegram guest: could not open the reply for query {} (the answer will be \
posted in one piece at the end): {e}",
query.query_id
);
None
}
};
let edit_target = opened.as_ref().filter(|id| !id.is_empty()).cloned();
let edit_handle = edit_target.as_ref().map(|inline_message_id| {
tokio::spawn(edit_tick_loop(
Arc::clone(self),
Arc::clone(&state),
inline_message_id.clone(),
cancel.clone(),
rich,
))
});
let input = format!(
"[Channel: Telegram guest query: you were tagged in \"{}\" (chat {}), a chat you \
are NOT a member of. Your text response is posted there, and while the turn runs \
the caller sees a status block listing the tools you use; that block is edited in \
place, so do not narrate your own progress, just do the work. The caller sees no \
tool output and has no access to that chat's history, so keep the answer short and \
self-contained. This turn serves someone who is not the owner, so your tools are \
scoped to {} and a file read stays inside the working directory; do not reach for \
anything else, it will be refused. Do NOT call telegram_send; this conversation has \
no chat id.]\n\n{}",
query.chat_title.as_deref().unwrap_or("a chat"),
chat_key.as_deref().unwrap_or("unknown"),
crate::brain::tools::scope::GUEST_ALLOWED_TOOLS.join(", "),
query.text,
);
let mut response = self
.agent
.send_message_with_tools_and_display(
session_id,
input,
Some(query.text.clone()),
None,
Some(cancel.clone()),
Some(guest_approval_callback()),
Some(guest_progress_cb(&state)),
"telegram",
None,
None,
)
.await;
for round in 1..=GUEST_FOLD_MAX_ROUNDS {
if response.is_err() {
break;
}
let Some(extra) = self.state.drain_reaction(session_id) else {
break;
};
tracing::info!(
"Telegram guest: a follow-up was still queued when session {session_id} finished \
its round — continuing the same turn (round {round})"
);
response = self
.agent
.send_message_with_tools_and_display(
session_id,
extra.context_text,
Some(extra.display_text),
None,
Some(cancel.clone()),
Some(guest_approval_callback()),
Some(guest_progress_cb(&state)),
"telegram",
None,
None,
)
.await;
}
let was_cancelled = cancel.is_cancelled();
cancel.cancel();
if let Some(handle) = edit_handle {
let _ = handle.await;
}
let response = match response {
Ok(response) => response,
Err(e) => {
tracing::warn!(
"Telegram guest: turn {} for query {} (session {session_id}): {e}",
if was_cancelled { "cancelled" } else { "failed" },
query.query_id
);
if let Some(inline_message_id) = edit_target.as_deref() {
let outcome = if was_cancelled {
FlowOutcome::Cancelled
} else {
FlowOutcome::Failed
};
let block = settle_block(&state, outcome, None, rich);
if let Err(e) = self.edit_answer(inline_message_id, &block, rich).await {
tracing::debug!(
"Telegram guest: could not settle the failed reply for query {}: {e}",
query.query_id
);
}
}
return;
}
};
let content = prepare_guest_text(&response.content);
let ctx_max = self.agent.context_limit_for_session(session_id);
let ctx = crate::utils::format_ctx_footer(
response.context_tokens,
ctx_max,
response.tokens_per_second,
);
let ctx = if !ctx.is_empty() && self.agent.pressure_warning_active(session_id) {
format!("{ctx} ❕")
} else {
ctx
};
let (message, reply) = settled_guest_message(&state, &content, ctx, rich);
if reply.is_empty() {
tracing::warn!(
"Telegram guest: query {} produced an empty reply, sending the block alone",
query.query_id
);
}
self.deliver(
&query.query_id,
opened.as_deref(),
edit_target.as_deref(),
&message,
&reply,
rich,
)
.await;
}
async fn deliver(
&self,
query_id: &str,
opened: Option<&str>,
edit_target: Option<&str>,
message: &str,
plain: &str,
rich: bool,
) {
match edit_target {
Some(inline_message_id) => {
let mut last: Option<String> = None;
for _ in 0..2 {
match self.edit_answer(inline_message_id, message, rich).await {
Ok(()) => {
tracing::info!(
"Telegram guest: answered query {query_id} by editing the open reply"
);
return;
}
Err(e @ EditError::RateLimited(_)) => {
let waited = super::rate_limit::wait_global_cooldown().await;
tracing::warn!(
"Telegram guest: final edit for query {query_id} rate-limited \
({e}); waited {}s and retrying the same reply",
waited.as_secs()
);
last = Some(e.to_string());
}
Err(e) => {
tracing::warn!(
"Telegram guest: final edit for query {query_id} failed ({e}); \
retrying once"
);
tokio::time::sleep(Duration::from_secs(2)).await;
last = Some(e.to_string());
}
}
}
tracing::error!(
"Telegram guest: could not deliver the answer for query {query_id} into the \
open reply ({}); the reply keeps its last live state",
last.unwrap_or_default()
);
}
None if opened.is_none() => self.post_once(query_id, plain, rich).await,
None => tracing::warn!(
"Telegram guest: query {query_id} has an open reply but no inline_message_id \
to edit it by; leaving the reply as posted"
),
}
}
async fn post_once(&self, query_id: &str, plain: &str, rich: bool) {
if plain.is_empty() {
tracing::warn!("Telegram guest: query {query_id} has no text to post");
return;
}
match self
.post_answer(&answer_guest_query_body(query_id, plain, rich))
.await
{
Ok(_) => tracing::info!("Telegram guest: answered query {query_id} in one piece"),
Err(e) => {
tracing::warn!("Telegram guest: answerGuestQuery failed for query {query_id}: {e}")
}
}
}
async fn post_answer(&self, body: &Value) -> Result<String, String> {
let url = format!(
"https://api.telegram.org/bot{}/answerGuestQuery",
self.token
);
let resp = reqwest::Client::new()
.post(&url)
.json(body)
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
let value: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
let ok = value.get("ok").and_then(Value::as_bool).unwrap_or(false);
if status.is_success() && ok {
return Ok(value
.get("result")
.and_then(|r| r.get("inline_message_id"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string());
}
if status.as_u16() == 429 {
let window =
super::rate_limit::parse_retry_after(&text).unwrap_or(Duration::from_secs(5));
super::rate_limit::record_global_429(window);
}
Err(format!(
"status {status}: {}",
crate::utils::truncate_str(&text, 300)
))
}
async fn edit_answer(
&self,
inline_message_id: &str,
text: &str,
rich: bool,
) -> Result<(), EditError> {
if rich {
match self
.post_edit(&edit_rich_body(inline_message_id, text))
.await
{
Ok(()) => return Ok(()),
Err(EditError::Transport(e)) => {
tracing::warn!(
"Telegram guest: rich edit hit a transport error ({e}); retrying the same body"
);
match self
.post_edit(&edit_rich_body(inline_message_id, text))
.await
{
Ok(()) => return Ok(()),
Err(EditError::Transport(e2)) => return Err(EditError::Transport(e2)),
Err(e2 @ EditError::RateLimited(_)) => return Err(e2),
Err(EditError::Refused(e2)) => tracing::warn!(
"Telegram guest: rich edit refused on retry ({e2}); falling back to the HTML edit"
),
}
}
Err(e @ EditError::RateLimited(_)) => return Err(e),
Err(EditError::Refused(e)) => tracing::warn!(
"Telegram guest: rich edit refused ({e}); falling back to the HTML edit"
),
}
let html = markdown_to_telegram_html(text);
return self
.post_edit(&edit_html_body(inline_message_id, &html))
.await;
}
self.post_edit(&edit_html_body(inline_message_id, text))
.await
}
async fn post_edit(&self, body: &Value) -> Result<(), EditError> {
let url = format!("https://api.telegram.org/bot{}/editMessageText", self.token);
let resp = reqwest::Client::new()
.post(&url)
.json(body)
.send()
.await
.map_err(|e| EditError::Transport(e.to_string()))?;
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
let outcome = classify_edit_reply(status, &text);
if matches!(outcome, Err(EditError::RateLimited(_))) {
let window =
super::rate_limit::parse_retry_after(&text).unwrap_or(Duration::from_secs(5));
super::rate_limit::record_global_429(window);
}
outcome
}
}
pub(crate) fn classify_edit_reply(status: u16, body: &str) -> Result<(), EditError> {
let value: Value = serde_json::from_str(body).unwrap_or(Value::Null);
if edit_response_ok(&value) {
return Ok(());
}
let message = format!("status {status}: {}", crate::utils::truncate_str(body, 300));
if status == 429 {
return Err(EditError::RateLimited(message));
}
Err(EditError::Refused(message))
}
#[derive(Debug)]
pub(crate) enum EditError {
Transport(String),
Refused(String),
RateLimited(String),
}
impl std::fmt::Display for EditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EditError::Transport(e) => write!(f, "transport: {e}"),
EditError::Refused(e) => write!(f, "refused: {e}"),
EditError::RateLimited(e) => write!(f, "rate limited: {e}"),
}
}
}
pub(crate) fn edit_rich_body(inline_message_id: &str, markdown: &str) -> Value {
json!({
"inline_message_id": inline_message_id,
"rich_message": { "markdown": markdown },
})
}
pub(crate) fn edit_html_body(inline_message_id: &str, html: &str) -> Value {
json!({
"inline_message_id": inline_message_id,
"text": html,
"parse_mode": "HTML",
})
}
pub(crate) fn edit_response_ok(body: &Value) -> bool {
if body.get("ok").and_then(Value::as_bool).unwrap_or(false) {
return true;
}
body.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.contains("message is not modified")
}
pub(crate) fn guest_sanitize(text: &str, system: bool) -> String {
if system {
return crate::utils::redact_secrets_scoped(text, false);
}
let text = crate::utils::sanitize::strip_llm_artifacts(text);
let text = crate::utils::redact_secrets_scoped(&text, false);
let (text, _images) = crate::utils::extract_img_markers(&text);
let (text, _reaction) = crate::utils::extract_react_marker(&text);
text
}
fn pump(state: &Arc<Mutex<StreamingState>>, rich: bool) -> String {
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
drain_display_queue(&mut s, guest_sanitize);
let elapsed = s.turn_started_at.elapsed().as_secs();
s.flow_status = (elapsed > 0).then(|| humanize_duration(elapsed));
s.header_preview = thinking_status_excerpt(&s.thinking);
if rich {
render_flow_rich_state(&s)
} else {
render_flow(&s)
}
}
pub(crate) fn settle_reply(state: &Arc<Mutex<StreamingState>>, content: &str) -> String {
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
drain_display_queue(&mut s, guest_sanitize);
let options_pending = s.pending_suggestions.is_some();
settle_guest_answer(&mut s.flow_entries, content, options_pending)
}
fn settle_block(
state: &Arc<Mutex<StreamingState>>,
outcome: FlowOutcome,
ctx: Option<String>,
rich: bool,
) -> String {
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
drain_display_queue(&mut s, guest_sanitize);
s.flow_outcome = Some(outcome);
s.tools_started_at = None;
if ctx.is_some() {
s.sections.ctx = ctx;
}
if rich {
render_flow_rich_state(&s)
} else {
render_flow(&s)
}
}
pub(crate) fn settled_guest_message(
state: &Arc<Mutex<StreamingState>>,
content: &str,
ctx: String,
rich: bool,
) -> (String, String) {
let reply = settle_reply(state, content);
let has_log = {
let s = state.lock().unwrap_or_else(|e| e.into_inner());
!s.flow_entries.is_empty()
};
let block = if has_log {
settle_block(state, FlowOutcome::Finished, Some(ctx), rich)
} else {
String::new()
};
(compose_guest_message(&block, &reply, rich), reply)
}
async fn edit_tick_loop(
me: Arc<GuestRunner>,
state: Arc<Mutex<StreamingState>>,
inline_message_id: String,
cancel: CancellationToken,
rich: bool,
) {
let mut last = String::new();
loop {
tokio::select! {
() = cancel.cancelled() => break,
() = tokio::time::sleep(GUEST_EDIT_INTERVAL) => {}
}
if super::rate_limit::is_global_cooldown_active() {
continue;
}
let body = pump(&state, rich);
if body.is_empty() || body == last {
continue;
}
match me.edit_answer(&inline_message_id, &body, rich).await {
Ok(()) => last = body,
Err(e) => tracing::debug!("Telegram guest: live edit skipped: {e}"),
}
}
}