use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use futures_util::future::BoxFuture;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, warn};
use crate::bridge::connection::hub_response_token_rejected;
use crate::ilink::types::{
BaseInfo, GetUpdatesRequest, GetUpdatesResponse, HubExt, SendMessageRequest,
SendMessageResponse,
};
pub(super) enum GetUpdatesOutcome {
Ok(GetUpdatesResponse),
TokenRejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum SendOutcome {
Sent,
Throttled { ret: i32, errmsg: Option<String> },
}
pub(super) fn sanitize_errmsg(s: Option<&str>) -> Option<String> {
const MAX_LEN: usize = 256;
sanitize_field(s, MAX_LEN)
}
pub(super) fn sanitize_field(s: Option<&str>, max_len: usize) -> Option<String> {
let raw = s?;
let cleaned: String = raw
.chars()
.filter(|c| !c.is_control())
.take(max_len)
.collect();
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
}
#[allow(dead_code)]
pub(super) fn classify_sendoutcome(parsed: Option<&SendMessageResponse>) -> SendOutcome {
match parsed {
None => SendOutcome::Sent,
Some(v) => match v.ret {
Some(0) => SendOutcome::Sent,
Some(-2) => SendOutcome::Throttled {
ret: -2,
errmsg: v.errmsg.clone(),
},
Some(_other) => SendOutcome::Sent, None => SendOutcome::Sent,
},
}
}
pub(super) fn parse_sendoutcome(text: &str) -> Result<SendOutcome, (i32, Option<String>)> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(SendOutcome::Sent);
}
match serde_json::from_str::<SendMessageResponse>(trimmed) {
Ok(v) => {
let ret = v.ret.unwrap_or(0);
if ret == -2 {
Ok(SendOutcome::Throttled {
ret: -2,
errmsg: v.errmsg,
})
} else if ret != 0 {
Err((ret, v.errmsg))
} else {
Ok(SendOutcome::Sent)
}
}
Err(_) => Ok(SendOutcome::Sent),
}
}
#[derive(Clone)]
pub(super) struct HubClient {
http: reqwest::Client,
hub_url: String,
token: String,
}
impl HubClient {
pub(super) fn new(hub_url: String, token: String) -> Result<Self> {
let hub_url = hub_url.trim_end_matches('/').to_string();
let http = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(90))
.pool_idle_timeout(Duration::from_secs(30))
.build()
.context("failed to build reqwest client")?;
Ok(Self {
http,
hub_url,
token,
})
}
pub(super) async fn getupdates(&self, buf: &mut String) -> Result<GetUpdatesOutcome> {
let body = GetUpdatesRequest {
get_updates_buf: buf.clone(),
base_info: Some(BaseInfo::default()),
timeout: None,
};
let url = format!("{}/ilink/bot/getupdates", self.hub_url);
let resp = self
.http
.post(url)
.header("Authorization", format!("Bearer {}", self.token.trim()))
.json(&body)
.send()
.await?;
let status = resp.status();
let out: GetUpdatesResponse = resp.json().await?;
if hub_response_token_rejected(status, out.ret) {
warn!(
status = %status,
errmsg = ?out.errmsg,
"hub rejected virtual token during getupdates"
);
return Ok(GetUpdatesOutcome::TokenRejected);
}
if !status.is_success() {
anyhow::bail!("getupdates HTTP {status}: {:?}", out.errmsg);
}
if let Some(ref newbuf) = out.get_updates_buf {
*buf = newbuf.clone();
}
Ok(GetUpdatesOutcome::Ok(out))
}
pub(super) async fn sendmessage(&self, req: SendMessageRequest) -> Result<SendOutcome> {
let url = format!("{}/ilink/bot/sendmessage", self.hub_url);
let resp = self
.http
.post(url)
.header("Authorization", format!("Bearer {}", self.token.trim()))
.json(&req)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let t = resp.text().await.unwrap_or_default();
anyhow::bail!("sendmessage HTTP {status}: {t}");
}
let text = resp.text().await?;
let body_len = text.len();
match parse_sendoutcome(&text) {
Ok(out) => {
if body_len > 0 && matches!(out, SendOutcome::Sent) {
if serde_json::from_str::<SendMessageResponse>(&text).is_err() {
warn!(
body_len,
"sendmessage response body failed to parse as JSON; treating as Sent (legacy fallback)"
);
}
}
Ok(out)
}
Err((other, errmsg)) => {
anyhow::bail!("sendmessage ret={other} errmsg={:?}", errmsg);
}
}
}
}
pub(super) trait ReplySender: Send + Sync + 'static {
fn send_reply(
&self,
ctx: &str,
text: &str,
from_user: &str,
session_name: &str,
) -> BoxFuture<'_, Result<SendOutcome>>;
fn send_request(&self, req: SendMessageRequest) -> BoxFuture<'_, Result<SendOutcome>>;
}
impl ReplySender for HubClient {
fn send_reply(
&self,
ctx: &str,
text: &str,
from_user: &str,
session_name: &str,
) -> BoxFuture<'_, Result<SendOutcome>> {
let mut req =
SendMessageRequest::reply_text(ctx.to_string(), text.to_string(), from_user, None);
let cleaned_session = sanitize_field(Some(session_name), 128);
if let Some(ref mut msg) = req.msg {
let ext = msg.ilink_hub_ext.get_or_insert_with(HubExt::default);
ext.session_name = cleaned_session;
}
Box::pin(self.sendmessage(req))
}
fn send_request(&self, req: SendMessageRequest) -> BoxFuture<'_, Result<SendOutcome>> {
Box::pin(self.sendmessage(req))
}
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_partial_forward_loop<S: ReplySender>(
sender: S,
mut partial_rx: watch::Receiver<Option<String>>,
ctx: String,
from_user: String,
session_name: String,
shutdown: CancellationToken,
backoff_fn: fn(u32) -> Duration,
max_total: Duration,
) {
let mut pending: Option<String> = None;
let mut attempt: u32 = 0;
let mut first_throttle_at: Option<Instant> = None;
loop {
if pending.is_none() {
let chunk = tokio::select! {
biased;
_ = shutdown.cancelled() => return,
result = partial_rx.changed() => match result {
Ok(()) => match partial_rx.borrow_and_update().clone() {
Some(c) => c,
None => continue,
},
Err(_) => return,
},
};
pending = Some(chunk);
attempt = 0;
} else {
let backoff = backoff_fn(attempt);
loop {
tokio::select! {
biased;
_ = shutdown.cancelled() => return,
_ = tokio::time::sleep(backoff) => break,
result = partial_rx.changed() => match result {
Ok(()) => {
if let Some(c) = partial_rx.borrow_and_update().clone() {
debug!(
pending_len = pending.as_ref().map(|s| s.len()).unwrap_or(0),
new_chunk_len = c.len(),
"overwriting buffered partial chunk with newer content during backoff"
);
pending = Some(c);
}
}
Err(_) => return,
},
}
}
}
let Some(chunk) = pending.as_ref() else {
tracing::warn!("partial forward loop: pending was None at phase 2 entry, skipping");
continue;
};
let send_fut = sender.send_reply(&ctx, chunk, &from_user, &session_name);
let send_result = tokio::select! {
biased;
_ = shutdown.cancelled() => return,
r = send_fut => r,
};
match send_result {
Ok(SendOutcome::Sent) => {
debug!(
pending_len = pending.as_ref().map(|s| s.len()).unwrap_or(0),
attempt, "partial reply delivered"
);
pending = None;
attempt = 0;
first_throttle_at = None;
}
Ok(SendOutcome::Throttled { ret, errmsg }) => {
let started = *first_throttle_at.get_or_insert_with(Instant::now);
let elapsed = started.elapsed();
if elapsed >= max_total {
error!(
ret,
attempt,
elapsed_secs = elapsed.as_secs(),
budget_secs = max_total.as_secs(),
pending_len = pending.as_ref().map(|s| s.len()).unwrap_or(0),
errmsg = sanitize_errmsg(errmsg.as_deref()).as_deref(),
"partial reply abandoned: retry budget exhausted under persistent throttle"
);
pending = None;
attempt = 0;
first_throttle_at = None;
} else {
attempt = attempt.saturating_add(1);
let wait = backoff_fn(attempt);
warn!(
ret,
attempt,
backoff_secs = wait.as_secs(),
elapsed_secs = elapsed.as_secs(),
pending_len = pending.as_ref().map(|s| s.len()).unwrap_or(0),
errmsg = sanitize_errmsg(errmsg.as_deref()).as_deref(),
"partial reply throttled; will retry with exponential backoff"
);
}
}
Err(e) => {
warn!(
error = %e,
attempt,
"partial reply send failed; dropping buffered chunk to avoid infinite retry"
);
pending = None;
attempt = 0;
first_throttle_at = None;
}
}
}
}
pub(super) async fn send_final_with_retry<S: ReplySender + ?Sized>(
sender: &S,
req: SendMessageRequest,
backoff_fn: fn(u32) -> Duration,
max_total: Duration,
shutdown: &CancellationToken,
what: &'static str,
) -> Result<()> {
let start = Instant::now();
let mut attempt: u32 = 0;
loop {
let send_result = tokio::select! {
biased;
_ = shutdown.cancelled() => return Ok(()),
r = sender.send_request(req.clone()) => r,
};
match send_result {
Ok(SendOutcome::Sent) => return Ok(()),
Ok(SendOutcome::Throttled { ret, errmsg }) => {
let elapsed = start.elapsed();
if elapsed >= max_total {
error!(
ret,
what,
attempt,
elapsed_secs = elapsed.as_secs(),
budget_secs = max_total.as_secs(),
errmsg = sanitize_errmsg(errmsg.as_deref()).as_deref(),
"final reply abandoned: retry budget exhausted under persistent throttle"
);
return Ok(());
}
attempt = attempt.saturating_add(1);
let wait = backoff_fn(attempt);
warn!(
ret,
what,
attempt,
backoff_secs = wait.as_secs(),
elapsed_secs = elapsed.as_secs(),
errmsg = sanitize_errmsg(errmsg.as_deref()).as_deref(),
"final reply throttled; retrying with exponential backoff"
);
tokio::select! {
biased;
_ = shutdown.cancelled() => return Ok(()),
_ = tokio::time::sleep(wait) => {}
}
}
Err(e) => {
let elapsed = start.elapsed();
if elapsed >= max_total {
error!(
what,
attempt,
elapsed_secs = elapsed.as_secs(),
budget_secs = max_total.as_secs(),
error = %e,
"final reply abandoned: retry budget exhausted under persistent transport error"
);
return Ok(());
}
attempt = attempt.saturating_add(1);
let wait = backoff_fn(attempt);
warn!(
what,
attempt,
backoff_secs = wait.as_secs(),
elapsed_secs = elapsed.as_secs(),
error = %e,
"final reply transport error; retrying with exponential backoff"
);
tokio::select! {
biased;
_ = shutdown.cancelled() => return Ok(()),
_ = tokio::time::sleep(wait) => {}
}
}
}
}
}