use crate::channels::ReplyReference;
use crate::channels::reply::normalize_reply_text;
use crate::channels::telegram_intake::{Intake, TelegramIntakeStore, listener_claim};
use crate::pipeline::board::{Ticket, TicketPhase};
use crate::util::html::{decode_html_entities, escape_html, push_escaped};
use crate::util::media_target::{self, MarkerTarget, MediaTarget};
use crate::util::{
FILE_MAX_BYTES, MEDIA_MARKER_RE, MediaMarkerKind, UnwrapPoison, is_http_url, parse_media_marker,
};
use crate::{Channel, ChannelMessage, SendMessage};
use anyhow::Context;
use async_trait::async_trait;
use reqwest::multipart::{Form, Part};
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use strum::IntoEnumIterator;
const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096;
const TELEGRAM_CONTINUATION_OVERHEAD: usize = 30;
const MAX_ATTACHMENT_FILENAME_BYTES: usize = 180;
const MEDIA_TRANSFER_TIMEOUT: Duration = Duration::from_mins(5);
const CLEAR_COMMAND_DESC: &str = "Reset your session";
const IMAGE_MODELS_COMMAND_DESC: &str = "Select image generation model";
const VIDEO_MODELS_COMMAND_DESC: &str = "Select video model";
const BOARD_COMMAND_DESC: &str = "List tickets from all workspaces";
const ARCHIVE_COMMAND_DESC: &str = "Archive done & cancelled tickets (all workspaces)";
const UPDATE_COMMAND_DESC: &str = "Update MahBot to the latest version";
const ACTION_PREFIX: &str = "__act__";
#[must_use]
pub(crate) fn decode_action(content: &str) -> Option<(String, String)> {
let rest = content.strip_prefix(ACTION_PREFIX)?;
match rest.split_once('|') {
Some((action, payload)) => Some((action.to_string(), payload.to_string())),
None => Some((rest.to_string(), String::new())),
}
}
#[derive(Debug)]
pub enum ControlInput {
Action((String, String)),
Command(crate::BotCommand),
}
#[must_use]
pub fn control_input(msg: &ChannelMessage) -> Option<ControlInput> {
if let Some(action) = decode_action(&msg.content) {
return Some(ControlInput::Action(action));
}
if msg.channel == "telegram"
&& let Some(cmd) = crate::parse_bot_command(&msg.content)
{
return Some(ControlInput::Command(cmd));
}
None
}
#[must_use]
pub(crate) fn is_control_message(msg: &ChannelMessage) -> bool {
msg.callback_query_id.is_some() || control_input(msg).is_some()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct IncomingAttachment {
file_id: String,
file_name: Option<String>,
file_size: Option<u64>,
caption: Option<String>,
kind: IncomingAttachmentKind,
mime_type: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IncomingAttachmentKind {
Document,
Photo,
Video,
Audio,
Voice,
}
fn split_message_for_telegram(message: &str) -> Vec<String> {
if message.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH {
return vec![message.to_string()];
}
let mut chunks = Vec::new();
let mut remaining = message;
let chunk_limit = TELEGRAM_MAX_MESSAGE_LENGTH - TELEGRAM_CONTINUATION_OVERHEAD;
while !remaining.is_empty() {
let hard_split = remaining
.char_indices()
.nth(chunk_limit)
.map_or(remaining.len(), |(idx, _)| idx);
let mut chunk_end = if hard_split == remaining.len() {
hard_split
} else {
find_split_boundary(remaining, hard_split)
};
if let Some(adjusted) = extend_past_open_tag(remaining, chunk_end) {
chunk_end = adjusted.min(hard_split);
}
chunks.push(remaining[..chunk_end].to_string());
remaining = &remaining[chunk_end..];
}
chunks
}
fn wrap_chunk(chunk: &str, index: usize, total: usize) -> String {
if total > 1 {
if index == 0 {
format!("{chunk}\n\n(continues...)")
} else if index == total - 1 {
format!("(continued)\n\n{chunk}")
} else {
format!("(continued)\n\n{chunk}\n\n(continues...)")
}
} else {
chunk.to_string()
}
}
fn find_split_boundary(text: &str, hard_split: usize) -> usize {
let search_area = &text[..hard_split];
search_area
.rfind('\n')
.max(search_area.rfind(' '))
.map_or(hard_split, |p| p + 1)
}
fn tag_end(s: &str, from: usize) -> Option<usize> {
let mut in_quote = false;
let mut quote_char = '"';
for (i, c) in s[from..].char_indices() {
match c {
'"' | '\'' if !in_quote => {
in_quote = true;
quote_char = c;
}
'"' | '\'' if in_quote && c == quote_char => in_quote = false,
'>' if !in_quote => return Some(from + i),
_ => {}
}
}
None
}
fn extend_past_open_tag(text: &str, pos: usize) -> Option<usize> {
let open = text[..pos].rfind('<')?;
let gt = tag_end(text, open)?;
(gt >= pos).then_some(gt + 1)
}
fn extract_sender_user_name(message: &serde_json::Value) -> String {
message
.get("from")
.and_then(|from| from.get("username"))
.and_then(serde_json::Value::as_str)
.unwrap_or(crate::users::TELEGRAM_UNKNOWN_SENTINEL)
.to_string()
}
struct TelegramSender {
numeric_id: Option<String>,
nickname: Option<String>,
}
fn telegram_sender_person(source: &serde_json::Value) -> Option<TelegramSender> {
if source.get("sender_chat").is_some() {
return None;
}
let from = source.get("from")?;
if from.get("is_bot").and_then(serde_json::Value::as_bool) == Some(true) {
return None;
}
let numeric_id = from
.get("id")
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string());
if let Some(id) = &numeric_id
&& crate::users::is_telegram_service_id(id)
{
return None;
}
let nickname = from
.get("username")
.and_then(serde_json::Value::as_str)
.filter(|nickname| *nickname != crate::users::TELEGRAM_UNKNOWN_SENTINEL)
.map(String::from);
Some(TelegramSender {
numeric_id,
nickname,
})
}
struct MessageContext {
user_name: String,
chat_id: String,
message_id: i64,
reply_target: String,
reply_reference: Option<ReplyReference>,
forward_prefix: Option<String>,
forwarded: bool,
update_date: Option<i64>,
}
impl MessageContext {
fn into_channel_message(self, content: String, cq_id: Option<String>) -> ChannelMessage {
let content = match self.forward_prefix {
Some(prefix) => format!("{prefix}{content}"),
None => content,
};
ChannelMessage {
user_name: self.user_name,
reply_target: self.reply_target,
content,
channel: "telegram".to_string(),
workspace: String::new(),
optimistic_id: None,
callback_query_id: cq_id,
reply_reference: self.reply_reference,
chat_id: (!self.chat_id.is_empty()).then_some(self.chat_id),
message_id: (self.message_id != 0).then_some(self.message_id),
attachment_dirs: Vec::new(),
parts: Vec::new(),
update_date: self.update_date,
forwarded: self.forwarded,
update_ids: Vec::new(),
}
}
fn into_attachment_message(self, content: String, staging_dir: String) -> ChannelMessage {
ChannelMessage {
attachment_dirs: vec![staging_dir],
..self.into_channel_message(content, None)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TelegramAttachmentKind {
Image,
Document,
Video,
Audio,
}
impl From<MediaMarkerKind> for TelegramAttachmentKind {
fn from(kind: MediaMarkerKind) -> Self {
match kind {
MediaMarkerKind::Image => Self::Image,
MediaMarkerKind::Audio => Self::Audio,
MediaMarkerKind::Video => Self::Video,
MediaMarkerKind::File => Self::Document,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TelegramAttachment {
kind: TelegramAttachmentKind,
target: String,
}
#[derive(Debug, Clone, Copy)]
struct AttachmentMeta {
api_method: &'static str,
form_field: &'static str,
disable_content_type_detection: bool,
}
impl TelegramAttachmentKind {
const fn label(self) -> &'static str {
match self {
Self::Image => "Image",
Self::Document => "Document",
Self::Video => "Video",
Self::Audio => "Audio",
}
}
const fn meta(self) -> AttachmentMeta {
match self {
Self::Image => AttachmentMeta {
api_method: "sendPhoto",
form_field: "photo",
disable_content_type_detection: false,
},
Self::Document | Self::Video => AttachmentMeta {
api_method: "sendDocument",
form_field: "document",
disable_content_type_detection: true,
},
Self::Audio => AttachmentMeta {
api_method: "sendAudio",
form_field: "audio",
disable_content_type_detection: false,
},
}
}
const fn file_meta(self) -> AttachmentMeta {
match self {
Self::Image => Self::Document.meta(),
_ => self.meta(),
}
}
}
pub(crate) const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp"];
const IMAGE_MIME_PREFIXES: &[&str] = &["image/png", "image/jpeg", "image/jpg", "image/webp"];
fn sender_label_with_fallback(from: &serde_json::Value, fallback: &str) -> String {
from.get("username")
.and_then(serde_json::Value::as_str)
.map_or_else(
|| {
from.get("first_name")
.and_then(serde_json::Value::as_str)
.unwrap_or(fallback)
.to_string()
},
|u| format!("@{u}"),
)
}
#[must_use]
fn format_sender_label(from: &serde_json::Value) -> String {
sender_label_with_fallback(from, crate::users::TELEGRAM_UNKNOWN_SENTINEL)
}
#[must_use]
fn replied_to_sender_label(message: &serde_json::Value) -> String {
let label = if let Some(sender_chat) = message.get("sender_chat") {
sender_chat
.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or("channel")
.to_string()
} else if let Some(from) = message.get("from") {
if from
.get("is_bot")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
from.get("username")
.and_then(serde_json::Value::as_str)
.map_or_else(|| "bot".to_string(), |u| format!("@{u}"))
} else {
sender_label_with_fallback(from, "user")
}
} else {
"user".to_string()
};
label.replace(['<', '>'], "")
}
#[must_use]
fn media_kind_placeholder(reply_to: &serde_json::Value) -> String {
if reply_to.get("voice").is_some() || reply_to.get("audio").is_some() {
"[Voice message]".to_string()
} else if reply_to.get("photo").is_some() {
"[Photo]".to_string()
} else if reply_to.get("document").is_some() {
"[Document]".to_string()
} else if reply_to.get("video").is_some() {
"[Video]".to_string()
} else if reply_to.get("sticker").is_some() {
"[Sticker]".to_string()
} else {
"[Message]".to_string()
}
}
#[must_use]
fn reply_to_snippet(reply_to: &serde_json::Value) -> String {
if let Some(text) = reply_to.get("text").and_then(serde_json::Value::as_str)
&& !text.is_empty()
{
normalize_reply_text(text)
} else if let Some(caption) = reply_to.get("caption").and_then(serde_json::Value::as_str)
&& !caption.is_empty()
{
normalize_reply_text(caption)
} else {
media_kind_placeholder(reply_to)
}
}
#[must_use]
fn build_reply_reference(message: &serde_json::Value) -> Option<ReplyReference> {
let reply_to = message.get("reply_to_message")?;
if !reply_to.is_object() {
return None;
}
Some(ReplyReference {
author: replied_to_sender_label(reply_to),
snippet: reply_to_snippet(reply_to),
})
}
fn format_attachment_content(
kind: IncomingAttachmentKind,
local_path: &Path,
mime_type: Option<&str>,
) -> String {
let is_image = crate::util::has_extension(local_path, IMAGE_EXTENSIONS)
|| mime_type.is_some_and(|m| IMAGE_MIME_PREFIXES.iter().any(|p| m.starts_with(p)));
let is_video = crate::util::is_video_extension(local_path)
|| mime_type.is_some_and(|m| m.starts_with("video/"));
match kind {
IncomingAttachmentKind::Photo | IncomingAttachmentKind::Document if is_image => {
format!("[IMAGE:{}]", local_path.display())
}
IncomingAttachmentKind::Video | IncomingAttachmentKind::Document if is_video => {
format!("[VIDEO:{}]", local_path.display())
}
IncomingAttachmentKind::Audio | IncomingAttachmentKind::Voice => {
let kind = if cfg!(target_os = "macos") {
"AUDIO"
} else {
"FILE"
};
format!("[{kind}:{}]", local_path.display())
}
_ => {
let target = local_path.to_string_lossy().to_string();
if crate::util::with_block_in_place(|| {
crate::util::media_target::classify_media_image_target(&target)
}) == MediaTarget::LocalImage
{
format!("[IMAGE:{}]", local_path.display())
} else {
format!("[FILE:{}]", local_path.display())
}
}
}
}
fn normalize_video_filename(
kind: IncomingAttachmentKind,
filename: &str,
mime_type: Option<&str>,
) -> String {
let is_video =
kind == IncomingAttachmentKind::Video || mime_type.is_some_and(|m| m.starts_with("video/"));
if is_video && !crate::util::is_video_extension(std::path::Path::new(filename)) {
let Some(stem) = std::path::Path::new(filename)
.file_stem()
.and_then(|s| s.to_str())
else {
return filename.to_string();
};
let extension = match mime_type {
Some("video/webm") => "webm",
Some("video/quicktime") => "mov",
_ => "mp4",
};
format!("{stem}.{extension}")
} else {
filename.to_string()
}
}
fn sanitize_attachment_filename(name: &str, fallback: &str) -> String {
let mut cleaned = [name, fallback]
.into_iter()
.find_map(|name| {
let component = Path::new(name).file_name().and_then(|n| n.to_str())?;
Some(crate::util::neutralized_name(component))
})
.unwrap_or_else(|| "file".to_string());
if cleaned.starts_with('.') {
cleaned.insert(0, '_');
}
if cleaned.len() > MAX_ATTACHMENT_FILENAME_BYTES {
let stem = crate::util::name_stem(&cleaned);
let ext = &cleaned[stem.len()..];
let truncated = crate::util::truncate_bytes(
stem,
MAX_ATTACHMENT_FILENAME_BYTES.saturating_sub(ext.len()),
);
cleaned = if truncated.is_empty() {
crate::util::truncate_bytes(cleaned.as_str(), MAX_ATTACHMENT_FILENAME_BYTES).to_string()
} else {
format!("{truncated}{ext}")
};
}
cleaned
}
fn sanitize_remote_extension(remote_ext: &str) -> Option<String> {
let cleaned: String = remote_ext
.chars()
.filter(char::is_ascii_alphanumeric)
.collect();
(!cleaned.is_empty()).then_some(cleaned)
}
fn local_attachment_name(
attachment: &IncomingAttachment,
chat_id: &str,
message_id: i64,
remote_ext: Option<&str>,
) -> String {
let remote_ext = remote_ext.and_then(sanitize_remote_extension);
let ext = remote_ext.as_deref().unwrap_or(match attachment.kind {
IncomingAttachmentKind::Video => "mp4",
IncomingAttachmentKind::Document => "bin",
_ => "jpg",
});
let prefix = match attachment.kind {
IncomingAttachmentKind::Photo => "photo",
IncomingAttachmentKind::Video => "video",
IncomingAttachmentKind::Audio | IncomingAttachmentKind::Voice => "audio",
IncomingAttachmentKind::Document => "file",
};
let fallback = format!("{prefix}_{chat_id}_{message_id}.{ext}");
let chosen = normalize_video_filename(
attachment.kind,
attachment.file_name.as_deref().unwrap_or(&fallback),
attachment.mime_type.as_deref(),
);
sanitize_attachment_filename(&chosen, &fallback)
}
#[must_use]
fn attachment_rejection_content(
display_name: Option<&str>,
outcome: &str,
reason: &str,
caption: Option<&str>,
) -> String {
let note = match display_name {
Some(name) => format!("[File {name}: {outcome} โ {reason}]"),
None => format!("[File {outcome} โ {reason}]"),
};
match caption.filter(|c| !c.is_empty()) {
Some(caption) => format!("{caption}\n\n{note}"),
None => note,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttachmentRoute {
LocalFile,
ByAddress,
FetchThenUpload,
}
fn attachment_route(kind: TelegramAttachmentKind, target: &str) -> AttachmentRoute {
if !is_http_url(target) {
return AttachmentRoute::LocalFile;
}
if kind == TelegramAttachmentKind::Video {
return AttachmentRoute::FetchThenUpload;
}
AttachmentRoute::ByAddress
}
fn address_file_name(url: &str, fallback: &str) -> String {
let parsed = url::Url::parse(url).ok();
parsed
.as_ref()
.and_then(url::Url::path_segments)
.and_then(|mut segments| segments.next_back())
.filter(|name| !name.is_empty())
.map_or_else(
|| fallback.to_string(),
|name| sanitize_attachment_filename(name, fallback),
)
}
fn video_upload_name(url: &str, content_type: Option<&str>) -> String {
let content_type = content_type
.and_then(|value| value.split(';').next())
.map(str::trim);
normalize_video_filename(
IncomingAttachmentKind::Video,
&address_file_name(url, "video"),
content_type,
)
}
const OUTSIDE_WORKSPACE_REASON: &str = "it is outside your workspace.";
fn resolve_file_target(
path: &Path,
file_roots: &[PathBuf],
upload_max: u64,
) -> Result<PathBuf, String> {
let name = media_target::marker_name(&path.to_string_lossy());
let refuse = |reason: &str| Err(media_target::marker_refusal(&name, reason));
if file_roots.is_empty() {
return refuse(media_target::NO_WORKSPACE_REASON);
}
let roots: Vec<PathBuf> = file_roots
.iter()
.map(|root| std::fs::canonicalize(root).unwrap_or_else(|_| root.clone()))
.collect();
let Ok(canonical) = std::fs::canonicalize(path) else {
return refuse(media_target::FILE_NOT_FOUND_REASON);
};
if !crate::tools::path::is_path_under_roots(&canonical, &roots) {
return refuse(OUTSIDE_WORKSPACE_REASON);
}
let Ok(meta) = std::fs::metadata(&canonical) else {
return refuse(media_target::FILE_NOT_FOUND_REASON);
};
if meta.is_dir() {
return refuse(media_target::DIRECTORY_REASON);
}
if meta.len() > upload_max {
return refuse(
&UploadTooLarge {
size: meta.len(),
max: upload_max,
}
.to_string(),
);
}
Ok(crate::util::strip_verbatim_prefix(&canonical))
}
enum MarkerOutcome {
Literal,
Attach(String),
Refuse(String),
}
fn refused(target: &str, reason: &str) -> MarkerOutcome {
MarkerOutcome::Refuse(media_target::marker_refusal_for(target, reason))
}
fn marker_outcome(
marker_kind: MediaMarkerKind,
target: &str,
file_roots: &[PathBuf],
limits: TransferLimits,
) -> MarkerOutcome {
let path = match media_target::resolve_marker_target(target, file_roots) {
MarkerTarget::NotAReference => return MarkerOutcome::Literal,
MarkerTarget::Inline => {
return MarkerOutcome::Refuse(media_target::inline_marker_refusal(marker_kind));
}
MarkerTarget::Remote => {
return match media_target::remote_marker(marker_kind, target) {
media_target::RemoteMarker::Refused(notice) => MarkerOutcome::Refuse(notice),
media_target::RemoteMarker::Deliverable => {
MarkerOutcome::Attach(target.to_string())
}
};
}
MarkerTarget::Local(path) => path,
};
if marker_kind == MediaMarkerKind::File {
return match resolve_file_target(&path, file_roots, limits.upload) {
Ok(canonical) => MarkerOutcome::Attach(canonical.to_string_lossy().into_owned()),
Err(notice) => MarkerOutcome::Refuse(notice),
};
}
if let Some(reason) = media_target::local_refusal(marker_kind, &path) {
return refused(target, reason);
}
let resolved = path.to_string_lossy();
if marker_kind == MediaMarkerKind::Image
&& media_target::classify_media_image_target(&resolved) != MediaTarget::LocalImage
{
return refused(target, media_target::NOT_AN_IMAGE_REASON);
}
MarkerOutcome::Attach(resolved.into_owned())
}
fn parse_attachment_markers(
message: &str,
file_roots: &[PathBuf],
limits: TransferLimits,
) -> (String, Vec<TelegramAttachment>, Vec<String>) {
let mut attachments: Vec<TelegramAttachment> = Vec::new();
let mut refusals: Vec<String> = Vec::new();
let cleaned = MEDIA_MARKER_RE
.replace_all(message, |caps: ®ex::Captures| {
let (marker_kind, path) = parse_media_marker(caps);
match marker_outcome(marker_kind, path.trim(), file_roots, limits) {
MarkerOutcome::Literal => caps.get_match().as_str().to_string(),
MarkerOutcome::Attach(target) => {
attachments.push(TelegramAttachment {
kind: TelegramAttachmentKind::from(marker_kind),
target,
});
String::new()
}
MarkerOutcome::Refuse(notice) => {
refusals.push(notice);
String::new()
}
}
})
.to_string();
(cleaned.trim().to_string(), attachments, refusals)
}
pub(crate) const CLOUD_API_BASE: &str = "https://api.telegram.org";
fn active_base() -> Option<String> {
match crate::channels::telegram_local::serving_mode() {
crate::channels::telegram_local::Mode::Cloud => Some(CLOUD_API_BASE.to_string()),
crate::channels::telegram_local::Mode::Local => {
crate::channels::telegram_local::local_endpoint()
}
}
}
#[derive(Clone, Copy, Debug)]
struct TransferLimits {
download: Option<u64>,
upload: u64,
request: Option<Duration>,
}
const LOCAL_TRANSFER_BOUND: Duration = Duration::from_hours(24);
impl TransferLimits {
const CLOUD: Self = Self {
download: Some(TELEGRAM_MAX_FILE_DOWNLOAD_BYTES),
upload: FILE_MAX_BYTES,
request: Some(MEDIA_TRANSFER_TIMEOUT),
};
const LOCAL: Self = Self {
download: None,
upload: TELEGRAM_LOCAL_MAX_UPLOAD_BYTES,
request: None,
};
fn request_bound(self) -> Duration {
self.request.unwrap_or(LOCAL_TRANSFER_BOUND)
}
}
fn local_transfer_client() -> &'static reqwest::Client {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| crate::util::http::build_http_client(LOCAL_TRANSFER_BOUND))
}
fn fetch_client() -> &'static reqwest::Client {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| crate::util::http::build_no_redirect_http_client(LOCAL_TRANSFER_BOUND))
}
fn bot_api_url(api_base: &str, token: &str, method: &str) -> String {
format!("{api_base}/bot{token}/{method}")
}
pub(crate) const REJECTED_TOKEN_PREFIX: &str = "Telegram rejected the bot token โ ";
async fn get_me(
client: &reqwest::Client,
api_base: &str,
token: &str,
) -> anyhow::Result<serde_json::Value> {
let response = client
.get(bot_api_url(api_base, token, "getMe"))
.send()
.await
.context("Failed to reach Telegram API")?;
let status = response.status();
let data: serde_json::Value = match response.json().await {
Ok(data) => data,
Err(err) => anyhow::bail!("Failed to parse Telegram API response: {err}"),
};
if !status.is_success() || data["ok"].as_bool() != Some(true) {
let desc = data["description"].as_str().unwrap_or("unknown error");
anyhow::bail!("{REJECTED_TOKEN_PREFIX}{desc}");
}
Ok(data)
}
pub(crate) const TELEGRAM_MAX_FILE_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
const TELEGRAM_LOCAL_MAX_UPLOAD_BYTES: u64 = 2000 * 1024 * 1024;
fn mb(bytes: u64) -> u64 {
bytes / (1024 * 1024)
}
#[must_use]
fn declared_size_refusal(limits: TransferLimits, size: u64) -> Option<String> {
let download = limits.download?;
(size > download).then(|| telegram_download_limit_reason(download))
}
fn telegram_download_limit_reason(download: u64) -> String {
format!(
"Telegram only lets the bot download files up to {} MB",
mb(download)
)
}
const TELEGRAM_TRANSFER_REFUSED_REASON: &str =
"Telegram refused the transfer (it may exceed what the bot can download)";
const ATTACHMENT_DOWNLOAD_TIMEOUT_REASON: &str = "the download took too long and was stopped";
const ATTACHMENT_DOWNLOAD_FAILED_REASON: &str = "the connection to Telegram failed";
const LOCAL_FILE_MISSING_REASON: &str =
"the local Bot API server did not hand the file over โ it is not on this machine";
const LOCAL_FILE_OUTSIDE_REASON: &str =
"the local Bot API server handed over a file that is not its own";
const LOCAL_HANDOVER_WAIT: Duration = Duration::from_mins(30);
const LOCAL_HANDOVER_APPEAR: Duration = Duration::from_mins(1);
const TELEGRAM_LOOKUP_REFUSED_REASON: &str = "Telegram could not provide the file";
const NOT_RECEIVED: &str = "not received";
const NOT_STORED: &str = "could not be stored";
const ATTACHMENT_DIR_REFUSED_REASON: &str =
"the local temp directory for the file could not be created";
const ATTACHMENT_SAVE_REFUSED_REASON: &str = "the file could not be saved locally";
#[derive(Debug)]
struct UploadTooLarge {
size: u64,
max: u64,
}
fn upload_too_large(err: &anyhow::Error) -> Option<&UploadTooLarge> {
err.chain()
.find_map(|cause| cause.downcast_ref::<UploadTooLarge>())
}
impl std::fmt::Display for UploadTooLarge {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"it is {} MB, larger than the {} MB limit.",
self.size.div_ceil(1024 * 1024),
mb(self.max)
)
}
}
impl std::error::Error for UploadTooLarge {}
const ATTACHMENT_UPLOAD_FAILED_REASON: &str = "the upload failed.";
const ATTACHMENT_FETCH_FAILED_REASON: &str = "it could not be fetched or sent.";
#[cfg(not(target_os = "macos"))]
const VOICE_UNSUPPORTED_REASON: &str = "voice messages are not supported on this platform";
#[derive(Debug)]
enum LocalHandOver {
Missing,
Outside,
Incomplete { got: u64, expected: u64 },
}
impl LocalHandOver {
fn reason(&self) -> String {
match self {
Self::Missing => LOCAL_FILE_MISSING_REASON.to_string(),
Self::Outside => LOCAL_FILE_OUTSIDE_REASON.to_string(),
Self::Incomplete { got, expected } => local_file_incomplete_reason(*got, *expected),
}
}
}
impl std::fmt::Display for LocalHandOver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.reason())
}
}
impl std::error::Error for LocalHandOver {}
fn local_file_incomplete_reason(got: u64, expected: u64) -> String {
format!(
"the local Bot API server handed over {got} of {expected} bytes โ the transfer did not finish"
)
}
async fn wait_for_hand_over(path: &Path, expected: Option<u64>) -> anyhow::Result<()> {
let started = tokio::time::Instant::now();
let mut previous: Option<u64> = None;
loop {
let size = tokio::fs::metadata(path)
.await
.ok()
.filter(std::fs::Metadata::is_file)
.map(|meta| meta.len());
match (size, expected) {
(Some(size), Some(expected)) if size >= expected => return Ok(()),
(Some(size), None) if previous == Some(size) => return Ok(()),
_ => {}
}
let bound = if size.is_some() {
LOCAL_HANDOVER_WAIT
} else {
LOCAL_HANDOVER_APPEAR
};
if started.elapsed() >= bound {
return Err(match (size, expected) {
(Some(size), Some(expected)) => LocalHandOver::Incomplete {
got: size,
expected,
},
_ => LocalHandOver::Missing,
}
.into());
}
previous = size;
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
fn download_failure_reason(error: &anyhow::Error) -> String {
if let Some(e) = error
.chain()
.find_map(|cause| cause.downcast_ref::<reqwest::Error>())
{
let reason = if e.is_timeout() {
ATTACHMENT_DOWNLOAD_TIMEOUT_REASON
} else {
ATTACHMENT_DOWNLOAD_FAILED_REASON
};
return reason.to_string();
}
if error
.chain()
.any(|cause| cause.downcast_ref::<std::io::Error>().is_some())
{
return ATTACHMENT_SAVE_REFUSED_REASON.to_string();
}
error
.chain()
.find_map(|cause| cause.downcast_ref::<LocalHandOver>())
.map_or_else(
|| TELEGRAM_TRANSFER_REFUSED_REASON.to_string(),
LocalHandOver::reason,
)
}
enum ChatMenuState {
Registered(Option<String>),
InFlight,
}
pub struct TelegramChannel {
bot_token: String,
http_client: reqwest::Client,
cancel: std::sync::Arc<tokio_util::sync::CancellationToken>,
api_base: String,
local: bool,
intake_store: std::sync::OnceLock<TelegramIntakeStore>,
intake_claim: std::sync::Arc<str>,
listener_running: std::sync::Arc<std::sync::atomic::AtomicBool>,
listener_stopped: std::sync::Arc<tokio::sync::Notify>,
preparations: std::sync::Arc<crate::channels::telegram_group::Preparations>,
menu_cache: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, ChatMenuState>>>,
}
fn extract_chat_context(message: &serde_json::Value) -> Option<(String, String)> {
let chat_id = message.get("chat")?.get("id")?.as_i64()?.to_string();
let thread_id = message
.get("message_thread_id")
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string());
let reply_target = match &thread_id {
Some(tid) => format!("{chat_id}:{tid}"),
None => chat_id.clone(),
};
Some((chat_id, reply_target))
}
fn set_thread_id_on_json(body: &mut serde_json::Value, thread_id: Option<&str>) {
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::Value::String(tid.to_string());
}
}
fn parse_recipient(recipient: &str) -> (&str, Option<&str>) {
match recipient.split_once(':') {
Some((chat, thread)) => (chat, Some(thread)),
None => (recipient, None),
}
}
async fn resolve_authorized_sender(
sender_source: &serde_json::Value,
chat_source: &serde_json::Value,
) -> anyhow::Result<Option<(String, String, String)>> {
let Some(sender) = telegram_sender_person(sender_source) else {
return Ok(None);
};
let Some(matched) = crate::users::store()
.resolve_telegram_user(sender.numeric_id.as_deref(), sender.nickname.as_deref())
.await?
else {
return Ok(None);
};
let Some((chat_id, reply_target)) = extract_chat_context(chat_source) else {
return Ok(None);
};
let _ =
crate::users::update_channel_contact("telegram", &matched.identifier, &reply_target).await;
Ok(Some((matched.user_name, chat_id, reply_target)))
}
fn parse_markdown_link(text: &str, i: usize) -> Option<(&str, &str, usize)> {
let after_open = text.get(i..)?.strip_prefix('[')?;
let bracket_end = after_open.find(']')?;
let label = &after_open[..bracket_end];
let after_bracket = i + bracket_end + 2;
let after_url_open = text.get(after_bracket..)?.strip_prefix('(')?;
let url = &after_url_open[..after_url_open.find(')')?];
if !is_http_url(url) || is_marker_label(label) {
return None;
}
Some((label, url, after_bracket + url.len() + 2))
}
fn is_marker_label(label: &str) -> bool {
let Some((kind, _)) = label.split_once(':') else {
return false;
};
MediaMarkerKind::iter().any(|marker| marker.token().eq_ignore_ascii_case(kind))
}
const ANCHOR_PREFIX: &str = "<a href=\"";
fn render_if_link(text: &str) -> Option<String> {
let rendered = render_inline(text);
rendered.contains(ANCHOR_PREFIX).then_some(rendered)
}
fn try_format_inline(
text: &str,
i: &mut usize,
out: &mut String,
delim: &str,
tag: &str,
linkify: bool,
) -> bool {
if text[*i..].starts_with(delim) {
let content_start = *i + delim.len();
if let Some(end) = text[content_start..].find(delim)
&& end > 0
{
let inner = &text[content_start..content_start + end];
if linkify && let Some(rendered) = render_if_link(inner) {
out.push_str(&rendered);
} else {
let _ = write!(out, "<{tag}>{}</{tag}>", escape_html(inner));
}
*i += delim.len() * 2 + end;
return true;
}
}
false
}
fn render_inline(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let bytes = text.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if try_format_inline(text, &mut i, &mut out, "**", "b", true) {
continue;
}
if try_format_inline(text, &mut i, &mut out, "__", "b", true) {
continue;
}
if (i == 0 || bytes[i - 1] != b'*')
&& try_format_inline(text, &mut i, &mut out, "*", "i", true)
{
continue;
}
if (i == 0 || bytes[i - 1] != b'`')
&& try_format_inline(text, &mut i, &mut out, "`", "code", false)
{
continue;
}
if bytes[i] == b'['
&& let Some((label, url, next)) = parse_markdown_link(text, i)
{
let _ = write!(
out,
"{ANCHOR_PREFIX}{}\">{}</a>",
escape_html(url),
escape_html(label)
);
i = next;
continue;
}
if try_format_inline(text, &mut i, &mut out, "~~", "s", true) {
continue;
}
let ch = text[i..].chars().next().unwrap();
push_escaped(ch, &mut out);
i += ch.len_utf8();
}
out
}
fn markdown_to_telegram_html(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut in_code_block = false;
let mut code_buf = String::new();
for line in text.split('\n') {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
if in_code_block {
in_code_block = false;
let escaped = escape_html(code_buf.trim_end_matches('\n'));
let _ = writeln!(out, "<pre><code>{escaped}</code></pre>");
} else {
in_code_block = true;
}
code_buf.clear();
continue;
}
if in_code_block {
code_buf.push_str(line);
code_buf.push('\n');
continue;
}
if trimmed == "<blockquote>" || trimmed == "</blockquote>" {
out.push_str(trimmed);
out.push('\n');
continue;
}
let stripped = line.trim_start_matches('#');
let header_level = line.len() - stripped.len();
if header_level > 0 && stripped.starts_with(' ') {
let title = stripped.trim();
match render_if_link(title) {
Some(rendered) => {
let _ = writeln!(out, "{rendered}");
}
None => {
let _ = writeln!(out, "<b>{}</b>", escape_html(title));
}
}
continue;
}
out.push_str(&render_inline(line));
out.push('\n');
}
if in_code_block && !code_buf.is_empty() {
let _ = writeln!(
out,
"<pre><code>{}</code></pre>",
escape_html(code_buf.trim_end())
);
}
out.trim_end_matches('\n').to_string()
}
fn strip_html_tags(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut i = 0;
while let Some(open) = s[i..].find('<').map(|o| i + o) {
out.push_str(&s[i..open]);
let Some(gt) = tag_end(s, open) else {
return out;
};
i = gt + 1;
}
out.push_str(&s[i..]);
out
}
fn to_telegram_html(text: &str) -> String {
markdown_to_telegram_html(&decode_html_entities(text))
}
const FORWARD_ATTRIBUTION_PREFIX: &str = "[Forwarded from ";
struct Forward {
forwarded: bool,
prefix: Option<String>,
}
type ForwardLabel = fn(&serde_json::Value) -> Option<String>;
const LEGACY_FORWARD_FIELDS: [(&str, ForwardLabel); 3] = [
("forward_from_chat", |chat| {
Some(format!(
"channel: {}",
chat.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown channel")
))
}),
("forward_from", |user| Some(format_sender_label(user))),
("forward_sender_name", |name| {
name.as_str().map(String::from)
}),
];
impl TelegramChannel {
#[must_use]
fn new_with(bot_token: String, api_base: String, local: bool) -> Self {
Self {
bot_token,
api_base,
local,
http_client: crate::util::http::build_http_client(Duration::from_mins(1)),
cancel: std::sync::Arc::new(tokio_util::sync::CancellationToken::new()),
intake_store: std::sync::OnceLock::new(),
intake_claim: std::sync::Arc::from(listener_claim()),
listener_running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
listener_stopped: std::sync::Arc::new(tokio::sync::Notify::new()),
preparations: std::sync::Arc::new(crate::channels::telegram_group::Preparations::new()),
menu_cache: std::sync::Arc::new(
std::sync::Mutex::new(std::collections::HashMap::new()),
),
}
}
fn intake_store(&self) -> TelegramIntakeStore {
self.intake_store
.get_or_init(|| crate::channels::telegram_intake::store().clone())
.clone()
}
#[cfg(test)]
#[must_use]
fn for_test(bot_token: String, api_base: String, store: TelegramIntakeStore) -> Self {
Self {
intake_store: std::sync::OnceLock::from(store),
..Self::new_with(bot_token, api_base, false)
}
}
#[must_use]
fn from_config(bot_token: String) -> Option<Self> {
let local = crate::channels::telegram_local::serving_mode()
== crate::channels::telegram_local::Mode::Local;
Some(Self::new_with(bot_token, active_base()?, local))
}
fn transfer_client(&self) -> &reqwest::Client {
if self.local {
local_transfer_client()
} else {
&self.http_client
}
}
fn with_transfer_bound(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match self.limits().request {
Some(timeout) => request.timeout(timeout),
None => request,
}
}
fn limits(&self) -> TransferLimits {
if self.local {
TransferLimits::LOCAL
} else {
TransferLimits::CLOUD
}
}
fn running(&self) -> bool {
self.listener_running
.load(std::sync::atomic::Ordering::Acquire)
}
async fn wait_stopped(&self) {
let mut stopped = std::pin::pin!(self.listener_stopped.notified());
stopped.as_mut().enable();
if !self.running() {
return;
}
if tokio::time::timeout(LISTENER_STOP_WAIT, stopped)
.await
.is_err()
{
tracing::warn!("Telegram listener: the replaced listener did not stop in time");
}
}
async fn intake_pause(&self, duration: Duration) {
tokio::select! {
() = self.cancel.cancelled() => {}
_ = crate::shutdown::sleep_or_shutdown_or_drain(duration) => {}
}
}
pub async fn answer_callback_query(&self, callback_query_id: &str, text: Option<&str>) {
let mut body = serde_json::json!({
"callback_query_id": callback_query_id,
});
if let Some(txt) = text {
body["text"] = serde_json::Value::String(txt.to_string());
}
if let Err((status, error)) = self
.post_telegram_json("answerCallbackQuery", body, "answerCallbackQuery error")
.await
{
tracing::warn!(
callback_query_id = %callback_query_id,
status = %status,
error = %error,
"answerCallbackQuery failed"
);
}
}
async fn parse_callback_query(
&self,
cq: &serde_json::Value,
) -> anyhow::Result<Option<ChannelMessage>> {
let Some(data) = cq.get("data").and_then(serde_json::Value::as_str) else {
return Ok(None);
};
let Some(msg) = cq.get("message") else {
return Ok(None);
};
let callback_query_id = cq
.get("id")
.and_then(serde_json::Value::as_str)
.map(String::from);
let Some((user_name, chat_id, reply_target)) = resolve_authorized_sender(cq, msg).await?
else {
tracing::debug!(
"Telegram: ignoring callback query from unknown user '{}'",
extract_sender_user_name(cq)
);
return Ok(None);
};
let message_id = msg
.get("message_id")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let ctx = MessageContext {
user_name,
chat_id,
message_id,
reply_target,
reply_reference: None,
forward_prefix: None,
forwarded: false,
update_date: None,
};
Ok(Some(
ctx.into_channel_message(data.to_string(), callback_query_id),
))
}
fn extract_update_message_target(update: &serde_json::Value) -> Option<(String, i64)> {
let message = update.get("message")?;
let chat_id = extract_chat_context(message)?.0;
let message_id = message
.get("message_id")
.and_then(serde_json::Value::as_i64)?;
Some((chat_id, message_id))
}
async fn extract_message_context(
&self,
message: &serde_json::Value,
) -> anyhow::Result<Option<MessageContext>> {
let Some((user_name, chat_id, reply_target)) =
resolve_authorized_sender(message, message).await?
else {
tracing::debug!(
"Telegram: ignoring message from unknown user '{}'",
extract_sender_user_name(message)
);
return Ok(None);
};
let message_id = message
.get("message_id")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let forward = Self::forward(message);
Ok(Some(MessageContext {
user_name,
chat_id,
message_id,
reply_target,
reply_reference: build_reply_reference(message),
forward_prefix: forward.prefix,
forwarded: forward.forwarded,
update_date: message.get("date").and_then(serde_json::Value::as_i64),
}))
}
fn try_add_ack_reaction_nonblocking(&self, chat_id: String, message_id: i64) {
let client = self.http_client().clone();
let url = self.api_url("setMessageReaction");
let body = serde_json::json!({
"chat_id": &chat_id,
"message_id": message_id,
"reaction": [{"type": "emoji", "emoji": "๐"}]
});
tokio::spawn(async move {
let response = match client.post(&url).json(&body).send().await {
Ok(resp) => resp,
Err(err) => {
tracing::warn!(
"Telegram: failed to add ACK reaction to chat_id={chat_id}, message_id={message_id}: {err}"
);
return;
}
};
if !response.status().is_success() {
let status = response.status();
let err_body =
crate::util::http::read_error_body(response, "ACK reaction error").await;
tracing::warn!(
"Telegram: add ACK reaction failed for chat_id={chat_id}, message_id={message_id}: status={status}, body={err_body}"
);
}
});
}
const fn http_client(&self) -> &reqwest::Client {
&self.http_client
}
fn api_url(&self, method: &str) -> String {
bot_api_url(&self.api_base, &self.bot_token, method)
}
fn cancel_own(&self) {
self.cancel.cancel();
}
pub async fn set_my_commands(&self) {
let body = serde_json::json!({
"commands": [
{"command": "clear", "description": CLEAR_COMMAND_DESC},
]
});
post_set_my_commands(self.http_client(), &self.api_url("setMyCommands"), &body).await;
}
fn spawn_menu_refresh(&self, chat_id: &str) {
{
let cache = self.menu_cache.lock().unwrap_poison();
if matches!(cache.get(chat_id), Some(ChatMenuState::InFlight)) {
return;
}
}
let bot_token = self.bot_token.clone();
let api_base = self.api_base.clone();
let http_client = self.http_client.clone();
let cache = std::sync::Arc::clone(&self.menu_cache);
let chat_id = chat_id.to_string();
tokio::spawn(async move {
let Some(user_name) =
crate::users::resolve_user_by_reply_target("telegram", &chat_id).await
else {
return;
};
let entries = user_command_entries(&user_name).await;
let payload = serde_json::json!({
"commands": entries
.iter()
.map(|(cmd, desc)| serde_json::json!({ "command": cmd, "description": desc }))
.collect::<Vec<_>>(),
});
let payload_str = payload.to_string();
let should_send = {
let mut cache = cache.lock().unwrap_poison();
match cache.get(&chat_id) {
Some(ChatMenuState::Registered(last))
if last.as_deref() == Some(&payload_str) =>
{
false
}
Some(ChatMenuState::InFlight) => false,
_ => {
cache.insert(chat_id.clone(), ChatMenuState::InFlight);
true
}
}
};
if !should_send {
return;
}
let url = bot_api_url(&api_base, &bot_token, "setMyCommands");
let body = serde_json::json!({
"scope": {
"type": "chat",
"chat_id": chat_id.parse::<i64>().map_or_else(
|_| serde_json::Value::String(chat_id.clone()),
serde_json::Value::from,
),
},
"commands": payload["commands"].clone(),
});
let ok = post_set_my_commands(&http_client, &url, &body).await;
let mut cache = cache.lock().unwrap_poison();
cache.insert(
chat_id,
if ok {
ChatMenuState::Registered(Some(payload_str))
} else {
ChatMenuState::Registered(None)
},
);
});
}
pub async fn validate_token(token: &str) -> anyhow::Result<()> {
if token.trim().is_empty() {
anyhow::bail!("Telegram bot token is empty");
}
let Some(base) = active_base() else {
tracing::info!(
"Telegram: the local server has no endpoint yet, so the token is kept unchecked"
);
return Ok(());
};
let client = crate::util::http::build_http_client(std::time::Duration::from_secs(10));
get_me(&client, &base, token).await.map(|_| ())
}
pub(crate) async fn probe_bot(api_base: &str, token: &str) -> anyhow::Result<i64> {
let client = crate::util::http::build_http_client(std::time::Duration::from_secs(10));
let data = get_me(&client, api_base, token).await?;
data["result"]["id"]
.as_i64()
.ok_or_else(|| anyhow::anyhow!("the Bot API named no bot account"))
}
pub(crate) async fn log_out(api_base: &str, token: &str) -> anyhow::Result<()> {
let client = crate::util::http::build_http_client(std::time::Duration::from_secs(30));
let response = client
.post(bot_api_url(api_base, token, "logOut"))
.send()
.await
.context("the Bot API could not be reached")?;
let data: serde_json::Value = response.json().await.unwrap_or_default();
if data["ok"].as_bool() == Some(true) {
return Ok(());
}
let description = data["description"].as_str().unwrap_or("no reason given");
if description.to_ascii_lowercase().contains("logged out") {
return Ok(());
}
anyhow::bail!("{description}")
}
fn handle_non_parseable_message(update: &serde_json::Value) {
let Some(message) = update.get("message") else {
return;
};
let text = message
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or("<non-text content>");
tracing::debug!("Telegram: message not parseable (unsupported type), skipping: {text}");
}
async fn get_file_path(&self, file_id: &str) -> anyhow::Result<(String, Option<u64>)> {
let url = self.api_url("getFile");
let request = self.with_transfer_bound(
self.transfer_client()
.get(&url)
.query(&[("file_id", file_id)]),
);
let resp = request
.send()
.await
.context("Failed to call Telegram getFile")?;
let data: serde_json::Value = resp.json().await?;
let result = data
.get("result")
.context("Telegram getFile: missing result in response")?;
let path = result
.get("file_path")
.and_then(serde_json::Value::as_str)
.map(String::from)
.context("Telegram getFile: missing file_path in response")?;
let size = result.get("file_size").and_then(serde_json::Value::as_u64);
Ok((path, size))
}
async fn download_file(
&self,
file_path: &str,
dest: &Path,
expected: Option<u64>,
) -> anyhow::Result<()> {
if self.local {
let Some(root) = crate::channels::telegram_local::incoming_files_dir() else {
return Err(LocalHandOver::Missing.into());
};
let source = Path::new(file_path);
if !source.is_absolute() {
return Err(LocalHandOver::Missing.into());
}
let mut roots = vec![root.clone()];
if let Ok(canonical) = tokio::fs::canonicalize(&root).await {
roots.push(canonical);
}
if !crate::tools::path::is_path_under_roots(source, &roots) {
return Err(LocalHandOver::Outside.into());
}
wait_for_hand_over(source, expected).await?;
let source = tokio::fs::canonicalize(source)
.await
.map_err(|_| anyhow::Error::from(LocalHandOver::Missing))?;
if !crate::tools::path::is_path_under_roots(&source, &roots) {
return Err(LocalHandOver::Outside.into());
}
let copied = tokio::fs::copy(&source, dest).await?;
if let Some(expected) = expected
&& copied < expected
{
let _ = tokio::fs::remove_file(dest).await;
return Err(LocalHandOver::Incomplete {
got: copied,
expected,
}
.into());
}
return Ok(());
}
let url = format!("{}/file/bot{}/{file_path}", self.api_base, self.bot_token);
let request = self.with_transfer_bound(self.transfer_client().get(&url));
let resp = request
.send()
.await
.context("Failed to download Telegram file")?;
if !resp.status().is_success() {
anyhow::bail!("Telegram file download failed: {}", resp.status());
}
stream_capped(resp, dest, self.limits().download, |_, max| {
anyhow::anyhow!("{}", telegram_download_limit_reason(max))
})
.await
}
fn parse_attachment_metadata(message: &serde_json::Value) -> Option<IncomingAttachment> {
for (key, kind) in [
("document", IncomingAttachmentKind::Document),
("video", IncomingAttachmentKind::Video),
("video_note", IncomingAttachmentKind::Video),
("animation", IncomingAttachmentKind::Video),
("audio", IncomingAttachmentKind::Audio),
("voice", IncomingAttachmentKind::Voice),
] {
if let Some(v) = message.get(key) {
return Self::build_attachment(v, message, kind);
}
}
if let Some(photos) = message.get("photo").and_then(serde_json::Value::as_array) {
let best = photos.last()?;
return Self::build_attachment(best, message, IncomingAttachmentKind::Photo);
}
None
}
fn build_attachment(
sub_obj: &serde_json::Value,
message: &serde_json::Value,
kind: IncomingAttachmentKind,
) -> Option<IncomingAttachment> {
let file_id = sub_obj.get("file_id")?.as_str()?.to_string();
let file_name = sub_obj
.get("file_name")
.and_then(serde_json::Value::as_str)
.map(String::from);
let file_size = sub_obj.get("file_size").and_then(serde_json::Value::as_u64);
let caption = message
.get("caption")
.and_then(serde_json::Value::as_str)
.map(String::from);
let mime_type = sub_obj
.get("mime_type")
.and_then(serde_json::Value::as_str)
.map(String::from);
Some(IncomingAttachment {
file_id,
file_name,
file_size,
caption,
kind,
mime_type,
})
}
async fn try_parse_attachment_message(
&self,
update: &serde_json::Value,
) -> anyhow::Result<Option<ChannelMessage>> {
let Some(message) = update.get("message") else {
return Ok(None);
};
let Some(ctx) = self.extract_message_context(message).await? else {
return Ok(None);
};
let Some(attachment) = Self::parse_attachment_metadata(message) else {
return Ok(None);
};
let _preparing = self.preparations.begin(&ctx.user_name, &ctx.reply_target);
#[cfg(not(target_os = "macos"))]
if attachment.kind == IncomingAttachmentKind::Voice {
return Ok(Some(
self.reject_voice_message(ctx, attachment.caption.as_deref())
.await,
));
}
if let Some(size) = attachment.file_size
&& let Some(reason) = declared_size_refusal(self.limits(), size)
{
tracing::info!("Rejecting attachment: declared size {size} bytes ({reason})");
return Ok(Some(
self.reject_attachment(ctx, &attachment, None, NOT_RECEIVED, &reason)
.await,
));
}
let (tg_file_path, server_size) = match self.get_file_path(&attachment.file_id).await {
Ok(answer) => answer,
Err(e) => {
tracing::warn!("Failed to get attachment file path: {e}");
return Ok(Some(
self.reject_attachment(
ctx,
&attachment,
None,
NOT_RECEIVED,
TELEGRAM_LOOKUP_REFUSED_REASON,
)
.await,
));
}
};
let expected = attachment.file_size.or(server_size);
let remote_ext = Path::new(&tg_file_path)
.extension()
.and_then(|e| e.to_str());
let staging_dir = crate::util::telegram_staging_dir_name(&ctx.chat_id, ctx.message_id);
let save_dir = crate::util::telegram_files_root().join(&staging_dir);
if let Err(e) = tokio::fs::create_dir_all(&save_dir).await {
tracing::warn!("Failed to create telegram attachment directory: {e}");
return Ok(Some(
self.reject_attachment(
ctx,
&attachment,
remote_ext,
NOT_STORED,
ATTACHMENT_DIR_REFUSED_REASON,
)
.await,
));
}
let local_filename =
local_attachment_name(&attachment, &ctx.chat_id, ctx.message_id, remote_ext);
let local_path = save_dir.join(&local_filename);
if let Err(e) = self
.download_file(&tg_file_path, &local_path, expected)
.await
{
tracing::warn!("Failed to download attachment: {e}");
let _ = tokio::fs::remove_dir_all(&save_dir).await;
let reason = download_failure_reason(&e);
let outcome = if reason == ATTACHMENT_SAVE_REFUSED_REASON {
NOT_STORED
} else {
NOT_RECEIVED
};
return Ok(Some(
self.reject_attachment(ctx, &attachment, remote_ext, outcome, &reason)
.await,
));
}
let mut content = format_attachment_content(
attachment.kind,
&local_path,
attachment.mime_type.as_deref(),
);
if let Some(caption) = &attachment.caption
&& !caption.is_empty()
{
let _ = write!(content, "\n\n{caption}");
}
Ok(Some(ctx.into_attachment_message(content, staging_dir)))
}
async fn reject_attachment(
&self,
ctx: MessageContext,
attachment: &IncomingAttachment,
remote_ext: Option<&str>,
outcome: &str,
reason: &str,
) -> ChannelMessage {
let display_name =
local_attachment_name(attachment, &ctx.chat_id, ctx.message_id, remote_ext);
let notice = format!("Could not process the file \"{display_name}\": {reason}.");
let content = attachment_rejection_content(
Some(&display_name),
outcome,
reason,
attachment.caption.as_deref(),
);
self.refuse_with_notice(ctx, ¬ice, content).await
}
async fn refuse_with_notice(
&self,
ctx: MessageContext,
notice: &str,
content: String,
) -> ChannelMessage {
let (_, thread_id) = parse_recipient(&ctx.reply_target);
if let Err(e) = self
.send_text_chunks(notice, &ctx.chat_id, thread_id, None)
.await
{
tracing::warn!(%notice, "Failed to send refusal notice: {e}");
}
ctx.into_channel_message(content, None)
}
#[cfg(not(target_os = "macos"))]
async fn reject_voice_message(
&self,
ctx: MessageContext,
caption: Option<&str>,
) -> ChannelMessage {
let notice = format!("Could not process the voice message: {VOICE_UNSUPPORTED_REASON}.");
let content =
attachment_rejection_content(None, NOT_RECEIVED, VOICE_UNSUPPORTED_REASON, caption);
self.refuse_with_notice(ctx, ¬ice, content).await
}
fn forward(message: &serde_json::Value) -> Forward {
let mut forwarded = false;
let mut label = None;
if let Some(origin) = message.get("forward_origin") {
forwarded = true;
label = Self::forward_origin_label(origin);
}
for (field, render) in LEGACY_FORWARD_FIELDS {
let Some(value) = message.get(field) else {
continue;
};
forwarded = true;
if label.is_none() {
label = render(value);
}
}
Forward {
forwarded,
prefix: label.map(|label| format!("{FORWARD_ATTRIBUTION_PREFIX}{label}] ")),
}
}
fn forward_origin_label(origin: &serde_json::Value) -> Option<String> {
origin
.get("type")
.and_then(serde_json::Value::as_str)
.and_then(|kind| match kind {
"user" => origin.get("sender_user").map(format_sender_label),
"hidden_user" => origin
.get("sender_user_name")
.and_then(serde_json::Value::as_str)
.map(String::from),
"chat" | "channel" => origin
.get("sender_chat")
.and_then(|chat| chat.get("title").and_then(serde_json::Value::as_str))
.map(|title| {
let kind = if kind == "channel" { "channel: " } else { "" };
format!("{kind}{title}")
}),
_ => None,
})
}
async fn parse_update_message(
&self,
update: &serde_json::Value,
) -> anyhow::Result<Option<ChannelMessage>> {
let Some(message) = update.get("message") else {
return Ok(None);
};
let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else {
return Ok(None);
};
let Some(ctx) = self.extract_message_context(message).await? else {
return Ok(None);
};
let text = if text.starts_with('/') {
text.split('@').next().unwrap_or(text)
} else {
text
};
let content = text.to_string();
Ok(Some(ctx.into_channel_message(content, None)))
}
async fn post_telegram_json(
&self,
method: &str,
body: serde_json::Value,
err_label: &str,
) -> Result<reqwest::Response, (reqwest::StatusCode, String)> {
let resp = self
.http_client()
.post(self.api_url(method))
.json(&body)
.send()
.await
.map_err(|e| (reqwest::StatusCode::BAD_GATEWAY, e.to_string()))?;
let status = resp.status();
if status.is_success() {
Ok(resp)
} else {
let err_body = crate::util::http::read_error_body(resp, err_label).await;
Err((status, err_body))
}
}
async fn send_message_get_id(
&self,
chat_id: &str,
thread_id: Option<&str>,
text: &str,
parse_mode: Option<&str>,
reply_markup: Option<serde_json::Value>,
) -> Result<Option<i64>, (reqwest::StatusCode, String)> {
let mut body = serde_json::json!({
"chat_id": chat_id,
"text": text,
});
if let Some(mode) = parse_mode {
body["parse_mode"] = serde_json::Value::String(mode.to_string());
}
set_thread_id_on_json(&mut body, thread_id);
if let Some(markup) = reply_markup {
body["reply_markup"] = markup;
}
body["link_preview_options"] = serde_json::json!({ "is_disabled": true });
let resp = self
.post_telegram_json("sendMessage", body, "sendMessage error")
.await?;
let status = resp.status();
let id = match resp.json::<serde_json::Value>().await {
Ok(body) => body["result"]["message_id"].as_i64(),
Err(e) => {
tracing::warn!(
status = ?status,
error = %e,
"sendMessage: unparseable response body โ treating as delivered"
);
None
}
};
Ok(id)
}
async fn send_single_message(
&self,
chat_id: &str,
thread_id: Option<&str>,
text: &str,
parse_mode: Option<&str>,
reply_markup: Option<serde_json::Value>,
) -> Result<(), (reqwest::StatusCode, String)> {
self.send_message_get_id(chat_id, thread_id, text, parse_mode, reply_markup)
.await
.map(|_| ())
}
async fn send_text_chunks(
&self,
message: &str,
chat_id: &str,
thread_id: Option<&str>,
reply_markup: Option<serde_json::Value>,
) -> anyhow::Result<()> {
let html = to_telegram_html(message);
let chunks = split_message_for_telegram(&html);
for (index, chunk) in chunks.iter().enumerate() {
let text = wrap_chunk(chunk, index, chunks.len());
let chunk_reply_markup = if index == chunks.len() - 1 {
reply_markup.clone()
} else {
None
};
if let Err((html_status, html_err)) = self
.send_single_message(
chat_id,
thread_id,
&text,
Some("HTML"),
chunk_reply_markup.clone(),
)
.await
{
tracing::info!(
status = ?html_status,
"Telegram sendMessage with HTML parse_mode failed; retrying without parse_mode"
);
let clean_text = strip_html_tags(&text);
self.send_single_message(chat_id, thread_id, &clean_text, None, chunk_reply_markup)
.await
.map_err(|(plain_status, plain_err)| {
anyhow::anyhow!(
"Telegram sendMessage failed (html {html_status}: {html_err}; plain {plain_status}: {plain_err})"
)
})?;
}
if index < chunks.len() - 1 {
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
Ok(())
}
pub async fn edit_reply_markup(
&self,
chat_id: &str,
message_id: i64,
reply_markup: &serde_json::Value,
) -> anyhow::Result<()> {
self.post_telegram_json(
"editMessageReplyMarkup",
serde_json::json!({
"chat_id": chat_id,
"message_id": message_id,
"reply_markup": reply_markup,
}),
"editMessageReplyMarkup error",
)
.await
.map(|_| ())
.map_err(|(status, desc)| {
anyhow::anyhow!("editMessageReplyMarkup failed ({status}): {desc}")
})
}
async fn send_attachment(
&self,
chat_id: &str,
thread_id: Option<&str>,
attachment: &TelegramAttachment,
) -> anyhow::Result<()> {
let kind = attachment.kind;
let target = attachment.target.trim();
let route = attachment_route(kind, target);
let (file, bytes, source_url) = match route {
AttachmentRoute::ByAddress => {
self.send_media_by_url(chat_id, thread_id, kind, target)
.await?;
(address_file_name(target, "file"), None, Some(target))
}
AttachmentRoute::FetchThenUpload => {
let fetched = self.fetch_remote_upload(target).await?;
let size = self
.send_media_file(chat_id, thread_id, kind, &fetched.path)
.await?;
(fetched.name, Some(size), Some(target))
}
AttachmentRoute::LocalFile => {
let path = Path::new(target);
if !path.is_file() {
anyhow::bail!(
"Telegram attachment target is not an existing regular file: {target}"
);
}
let size = self.send_media_file(chat_id, thread_id, kind, path).await?;
(target.to_string(), Some(size), None)
}
};
record_attachment_delivery(chat_id, kind, route, &file, bytes, source_url);
Ok(())
}
async fn send_media(
&self,
api_method: &'static str,
request: reqwest::RequestBuilder,
) -> anyhow::Result<()> {
let resp = request.send().await?;
let status = resp.status();
let text = resp.text().await?;
if !status.is_success() {
anyhow::bail!("Telegram {api_method} failed: {text}");
}
Ok(())
}
async fn send_media_file(
&self,
chat_id: &str,
thread_id: Option<&str>,
kind: TelegramAttachmentKind,
file_path: &Path,
) -> anyhow::Result<u64> {
let meta = kind.file_meta();
let limits = self.limits();
let size = tokio::fs::metadata(file_path).await?.len();
if size > limits.upload {
return Err(anyhow::Error::new(UploadTooLarge {
size,
max: limits.upload,
}));
}
let part = Part::file(file_path).await?;
let mut form = Form::new()
.text("chat_id", chat_id.to_string())
.part(meta.form_field, part);
if meta.disable_content_type_detection {
form = form.text("disable_content_type_detection", "true");
}
if let Some(tid) = thread_id {
form = form.text("message_thread_id", tid.to_string());
}
let request = self.with_transfer_bound(
self.transfer_client()
.post(self.api_url(meta.api_method))
.multipart(form),
);
self.send_media(meta.api_method, request).await?;
Ok(size)
}
async fn send_media_by_url(
&self,
chat_id: &str,
thread_id: Option<&str>,
kind: TelegramAttachmentKind,
url: &str,
) -> anyhow::Result<()> {
let meta = kind.meta();
let mut body = serde_json::json!({ "chat_id": chat_id });
body[meta.form_field] = serde_json::Value::String(url.to_string());
set_thread_id_on_json(&mut body, thread_id);
let request = self.with_transfer_bound(
self.transfer_client()
.post(self.api_url(meta.api_method))
.json(&body),
);
self.send_media(meta.api_method, request).await
}
async fn fetch_remote_upload(&self, url: &str) -> anyhow::Result<FetchedUpload> {
let bound = self.limits().request_bound();
tokio::time::timeout(bound, async {
let max = self.limits().upload;
let response = crate::util::http::get_guarded(fetch_client(), url).await?;
if !response.status().is_success() {
anyhow::bail!("the address answered {}", response.status());
}
let fetched_url = response.url().as_str().to_string();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let dir = tempfile::Builder::new()
.prefix("telegram-upload-")
.tempdir()
.context("the temp directory for the fetched file could not be created")?;
let name = video_upload_name(&fetched_url, content_type.as_deref());
let path = dir.path().join(&name);
stream_capped(response, &path, Some(max), |size, max| {
UploadTooLarge { size, max }.into()
})
.await?;
Ok::<_, anyhow::Error>(FetchedUpload {
path,
name,
_dir: dir,
})
})
.await
.with_context(|| {
format!(
"fetching {} did not finish within {bound:?}",
crate::util::http::redact_address(url)
)
})?
}
}
struct FetchedUpload {
path: PathBuf,
name: String,
_dir: tempfile::TempDir,
}
async fn stream_capped(
response: reqwest::Response,
dest: &Path,
ceiling: Option<u64>,
too_large: impl Fn(u64, u64) -> anyhow::Error,
) -> anyhow::Result<()> {
use futures_util::StreamExt as _;
use tokio::io::AsyncWriteExt as _;
if let (Some(max), Some(declared)) = (ceiling, response.content_length())
&& declared > max
{
return Err(too_large(declared, max));
}
let mut file = tokio::fs::File::create(dest).await?;
let mut stream = response.bytes_stream();
let mut written: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk
.map_err(reqwest::Error::without_url)
.context("the transfer was cut off")?;
written += chunk.len() as u64;
if let Some(max) = ceiling
&& written > max
{
return Err(too_large(written, max));
}
file.write_all(&chunk).await?;
}
file.flush().await?;
Ok(())
}
fn record_attachment_delivery(
chat_id: &str,
kind: TelegramAttachmentKind,
route: AttachmentRoute,
file: &str,
bytes: Option<u64>,
source_url: Option<&str>,
) {
let (method, delivery) = match route {
AttachmentRoute::ByAddress => (kind.meta().api_method, "by address"),
AttachmentRoute::LocalFile | AttachmentRoute::FetchThenUpload => {
(kind.file_meta().api_method, "uploaded attachment")
}
};
let file = crate::util::http::redact_address(file);
let source_url = source_url.map(crate::util::http::redact_address);
tracing::warn!(
chat_id,
kind = kind.label(),
file,
method,
delivery,
bytes = ?bytes,
source_url = ?source_url,
"Telegram: attachment delivered"
);
}
enum PollOutcome {
Updates(Vec<serde_json::Value>),
Conflict,
Error(String),
Transport,
}
impl TelegramChannel {
async fn poll_get_updates(&self, offset: i64, timeout: u64, ok_default: bool) -> PollOutcome {
let url = self.api_url("getUpdates");
let body = serde_json::json!({
"offset": offset,
"timeout": timeout,
"allowed_updates": ["message", "callback_query"]
});
let resp = match self.http_client().post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
tracing::info!("Telegram poll error: {e}");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
return PollOutcome::Transport;
}
};
let data: serde_json::Value = match resp.json().await {
Ok(d) => d,
Err(e) => {
tracing::warn!("Telegram parse error: {e}");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
return PollOutcome::Transport;
}
};
let ok = data
.get("ok")
.and_then(serde_json::Value::as_bool)
.unwrap_or(ok_default);
if ok {
if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) {
return PollOutcome::Updates(results.clone());
}
return PollOutcome::Updates(Vec::new());
}
let error_code = data
.get("error_code")
.and_then(serde_json::Value::as_i64)
.unwrap_or_default();
if error_code == 409 {
PollOutcome::Conflict
} else {
let desc = data
.get("description")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown Telegram API error");
PollOutcome::Error(desc.to_string())
}
}
async fn open_intake(&self) -> Option<IntakeSlot> {
loop {
if self.cancel.is_cancelled() || crate::shutdown::is_draining() {
tracing::info!("Telegram intake stopping during its startup handshake");
return None;
}
let account_id = match self.bot_account_id().await {
Ok(account_id) => account_id,
Err(err) => {
tracing::warn!("Startup handshake: getMe failed: {err}; retrying in 5s");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
};
match self.poll_get_updates(0, 0, false).await {
PollOutcome::Updates(updates) => {
return Some(IntakeSlot {
account_id,
updates,
});
}
PollOutcome::Conflict => {
tracing::debug!("Startup handshake: slot busy (409), retrying in 5s");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
PollOutcome::Error(desc) => {
tracing::warn!("Startup handshake: API error: {desc}; retrying in 5s");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
PollOutcome::Transport => {} }
}
}
async fn bot_account_id(&self) -> anyhow::Result<String> {
let data = get_me(self.http_client(), &self.api_base, &self.bot_token).await?;
let id = data["result"]["id"]
.as_i64()
.ok_or_else(|| anyhow::anyhow!("getMe response carries no bot id"))?;
Ok(id.to_string())
}
async fn parse_incoming_update(
&self,
update: &serde_json::Value,
) -> anyhow::Result<Option<ChannelMessage>> {
let msg = if let Some(msg) = self.parse_update_message(update).await? {
msg
} else if let Some(msg) = self.try_parse_attachment_message(update).await? {
msg
} else {
Self::handle_non_parseable_message(update);
return Ok(None);
};
if let Some((reaction_chat_id, reaction_message_id)) =
Self::extract_update_message_target(update)
{
self.try_add_ack_reaction_nonblocking(reaction_chat_id, reaction_message_id);
}
Ok(Some(msg))
}
async fn hold_up(
&self,
intake: &Intake,
offset: i64,
updates: Vec<serde_json::Value>,
origin: BatchOrigin,
) -> (Polled, Option<HeldBatch>) {
if updates.is_empty() {
return (
Polled {
position: offset,
reoffered: false,
},
None,
);
}
if self.local {
crate::channels::telegram_local::note_telegram_contact();
}
let plan = poll_plan(&updates);
let mut fresh = HashSet::new();
let mut unrecorded: Option<i64> = None;
let mut record_error: Option<anyhow::Error> = None;
let mut failed_units = 0usize;
for (at, placement) in plan.iter().enumerate() {
let unit: Vec<&serde_json::Value> = match placement {
PollPlacement::Skip => continue,
PollPlacement::Album(members) => {
members.iter().map(|&member| &updates[member]).collect()
}
PollPlacement::Single | PollPlacement::Callback => vec![&updates[at]],
};
let first = unit
.first()
.and_then(|update| update["update_id"].as_i64())
.unwrap_or(offset);
match intake.hold(&unit).await {
Ok(recorded) => fresh.extend(recorded),
Err(err) => {
unrecorded = Some(unrecorded.map_or(first, |smallest| smallest.min(first)));
failed_units += 1;
record_error.get_or_insert(err);
}
}
}
if let Some(err) = record_error {
tracing::warn!(
first_unrecorded = ?unrecorded,
units = failed_units,
%err,
"Telegram intake: could not record part of a batch"
);
}
let position = unrecorded.unwrap_or_else(|| {
updates
.iter()
.filter_map(|update| update["update_id"].as_i64())
.max()
.map_or(offset, |newest| newest + 1)
});
if unrecorded.is_some() {
self.intake_pause(RECORD_FAILURE_BACKOFF).await;
}
let polled = Polled {
position,
reoffered: unrecorded.is_some(),
};
(
polled,
Some(HeldBatch {
updates,
plan,
origin,
fresh,
}),
)
}
async fn process_updates(
&self,
tx: &tokio::sync::mpsc::Sender<ChannelMessage>,
intake: &Intake,
fresh: &mut HashSet<i64>,
origin: BatchOrigin,
updates: Vec<serde_json::Value>,
plan: &[PollPlacement],
) -> bool {
for (update, placement) in updates.iter().zip(plan) {
match placement {
PollPlacement::Callback => {
let Some(update_id) = Self::fresh_update(update, fresh) else {
continue;
};
if !self
.handle_callback_press(tx, intake, origin, update_id, update)
.await
{
return false;
}
}
PollPlacement::Skip => {}
PollPlacement::Single => {
let Some(update_id) = Self::fresh_update(update, fresh) else {
continue;
};
if let Some(msg) = self.take_event(intake, origin, update_id, update).await
&& tx.send(msg).await.is_err()
{
return false;
}
}
PollPlacement::Album(members) => {
let mut album = None;
for &member in members {
let update = &updates[member];
let Some(update_id) = Self::fresh_update(update, fresh) else {
continue;
};
if let Some(msg) = self.take_event(intake, origin, update_id, update).await
{
album = Some(merge_album_member(album, msg));
}
}
if let Some(album) = album
&& tx.send(album).await.is_err()
{
return false;
}
}
}
}
true
}
fn fresh_update(update: &serde_json::Value, fresh: &mut HashSet<i64>) -> Option<i64> {
let id = update["update_id"].as_i64()?;
fresh.remove(&id).then_some(id)
}
async fn settle_or_hold(
intake: &Intake,
update_id: i64,
parsed: anyhow::Result<Option<ChannelMessage>>,
) -> Option<ChannelMessage> {
let mut msg = match parsed {
Ok(Some(msg)) => msg,
Ok(None) => {
intake.settle(&[update_id]).await;
return None;
}
Err(err) => {
tracing::warn!(
update_id,
%err,
"Telegram intake: could not decide an event; it stays held for a later run"
);
return None;
}
};
msg.update_ids.push(update_id);
Some(msg)
}
async fn take_event(
&self,
intake: &Intake,
origin: BatchOrigin,
update_id: i64,
update: &serde_json::Value,
) -> Option<ChannelMessage> {
let msg = Self::settle_or_hold(intake, update_id, self.parse_incoming_update(update).await)
.await?;
if origin == BatchOrigin::Stale && is_control_message(&msg) {
self.refuse_unacted_control(intake, &msg).await;
return None;
}
Some(msg)
}
async fn refuse_unacted_control(&self, intake: &Intake, msg: &ChannelMessage) {
let (chat_id, thread_id) = parse_recipient(&msg.reply_target);
if let Err(err) = self
.send_text_chunks(UNACTED_CONTROL_NOTICE, chat_id, thread_id, None)
.await
{
tracing::warn!(%err, "Telegram: could not report an unacted command to its user");
return;
}
intake.settle(&msg.update_ids).await;
}
async fn handle_callback_press(
&self,
tx: &tokio::sync::mpsc::Sender<ChannelMessage>,
intake: &Intake,
origin: BatchOrigin,
update_id: i64,
update: &serde_json::Value,
) -> bool {
let cq = &update["callback_query"];
let cq_id = cq["id"].as_str().map(ToString::to_string);
let cq_data = cq["data"].as_str().unwrap_or("");
if origin == BatchOrigin::Fresh
&& !cq_data.starts_with(ACTION_PREFIX)
&& let Some(ref id) = cq_id
{
self.answer_callback_query(id, None).await;
}
let Some(msg) =
Self::settle_or_hold(intake, update_id, self.parse_callback_query(cq).await).await
else {
return true;
};
if origin == BatchOrigin::Stale {
self.refuse_unacted_control(intake, &msg).await;
return true;
}
tx.send(msg).await.is_ok()
}
async fn recover(
&self,
intake: &Intake,
parts_tx: &tokio::sync::mpsc::Sender<ChannelMessage>,
) -> bool {
let mut attempt = 0;
let updates = loop {
match intake.take_over().await {
Ok(updates) => break updates,
Err(err) => {
attempt += 1;
if attempt == TAKE_OVER_ATTEMPTS {
tracing::warn!(
%err,
"Telegram intake: could not take up what the record holds; \
what it holds stays for the next run"
);
return true;
}
tracing::warn!(%err, "Telegram intake: could not take up what the record holds; retrying");
self.intake_pause(TAKE_OVER_RETRY).await;
}
}
};
if updates.is_empty() {
return true;
}
tracing::info!(
account_id = %intake.account_id,
held = updates.len(),
"Telegram intake: taking up events the record holds"
);
let mut fresh: HashSet<i64> = updates
.iter()
.filter_map(|update| update["update_id"].as_i64())
.collect();
let plan = poll_plan(&updates);
self.process_updates(
parts_tx,
intake,
&mut fresh,
BatchOrigin::Stale,
updates,
&plan,
)
.await
}
async fn age_out_record(&self) {
match self.intake_store().age_out().await {
Ok(0) => {}
Ok(dropped) => tracing::debug!(
dropped,
"Telegram intake: aged rows out of the intake's record"
),
Err(err) => tracing::warn!(%err, "Telegram intake: could not age the record"),
}
}
async fn intake_loop(
&self,
batch_tx: &tokio::sync::mpsc::Sender<HeldBatch>,
intake: &Intake,
handshake: Vec<serde_json::Value>,
) {
self.age_out_record().await;
let mut aged_at = Instant::now();
let page_full = handshake.len() >= UPDATES_PAGE;
let (taken, batch) = self.hold_up(intake, 0, handshake, BatchOrigin::Stale).await;
let mut offset = taken.position;
let mut backlog_open = page_full || taken.reoffered;
if let Some(batch) = batch
&& batch_tx.send(batch).await.is_err()
{
return;
}
let shutdown_token = crate::shutdown::shutdown_token();
let per_channel_cancel = self.cancel.clone();
loop {
tokio::select! {
() = shutdown_token.cancelled() => {
tracing::info!("Telegram channel shutting down (global shutdown)");
break;
}
() = per_channel_cancel.cancelled() => {
tracing::info!("Telegram channel shutting down (token hot-reload)");
break;
}
() = crate::shutdown::drain_wait() => {
tracing::info!("Telegram channel stopping the intake (daemon draining)");
break;
}
poll_result = self.poll_get_updates(offset, 30, true) => {
if aged_at.elapsed() >= AGE_OUT_INTERVAL {
self.age_out_record().await;
aged_at = Instant::now();
}
let updates = match poll_result {
PollOutcome::Updates(updates) => updates,
PollOutcome::Conflict => {
tracing::warn!(
"Telegram polling conflict (409). \
Ensure only one `mahbot` process is using this bot token."
);
tokio::time::sleep(std::time::Duration::from_secs(35)).await;
continue;
}
PollOutcome::Error(desc) => {
tracing::info!("Telegram getUpdates API error: {desc}");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
PollOutcome::Transport => continue,
};
let page_full = updates.len() >= UPDATES_PAGE;
let (taken, batch) = self
.hold_up(intake, offset, updates, batch_origin(backlog_open))
.await;
offset = taken.position;
backlog_open = (backlog_open && page_full) || taken.reoffered;
if let Some(batch) = batch
&& batch_tx.send(batch).await.is_err()
{
tracing::info!("Telegram listener stopping: the batch processor is gone");
return;
}
}
}
}
}
}
struct HeldBatch {
updates: Vec<serde_json::Value>,
plan: Vec<PollPlacement>,
origin: BatchOrigin,
fresh: HashSet<i64>,
}
const BATCH_QUEUE: usize = 32;
struct IntakeSlot {
account_id: String,
updates: Vec<serde_json::Value>,
}
fn merge_album_member(album: Option<ChannelMessage>, member: ChannelMessage) -> ChannelMessage {
match album {
Some(mut album) => {
album.content.push('\n');
album.content.push_str(&member.content);
album.attachment_dirs.extend(member.attachment_dirs);
album.update_ids.extend(member.update_ids);
album.forwarded |= member.forwarded;
album
}
None => member,
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BatchOrigin {
Fresh,
Stale,
}
struct Polled {
position: i64,
reoffered: bool,
}
fn batch_origin(unknown_age: bool) -> BatchOrigin {
if unknown_age {
BatchOrigin::Stale
} else {
BatchOrigin::Fresh
}
}
pub(crate) struct ListenerFrame {
running: std::sync::Arc<std::sync::atomic::AtomicBool>,
stopped: std::sync::Arc<tokio::sync::Notify>,
}
impl ListenerFrame {
pub(crate) fn enter(
running: &std::sync::Arc<std::sync::atomic::AtomicBool>,
stopped: &std::sync::Arc<tokio::sync::Notify>,
) -> Self {
running.store(true, std::sync::atomic::Ordering::Release);
Self {
running: std::sync::Arc::clone(running),
stopped: std::sync::Arc::clone(stopped),
}
}
}
impl Drop for ListenerFrame {
fn drop(&mut self) {
self.running
.store(false, std::sync::atomic::Ordering::Release);
self.stopped.notify_waiters();
}
}
const RECORD_FAILURE_BACKOFF: Duration = Duration::from_secs(5);
const AGE_OUT_INTERVAL: Duration = Duration::from_hours(1);
const UPDATES_PAGE: usize = 100;
const LISTENER_STOP_WAIT: Duration = Duration::from_secs(30);
const TAKE_OVER_ATTEMPTS: u32 = 3;
const TAKE_OVER_RETRY: Duration = Duration::from_secs(1);
const UNACTED_CONTROL_NOTICE: &str = "MahBot did not act on that. Please repeat it.";
#[derive(Debug, PartialEq, Eq)]
enum PollPlacement {
Single,
Album(Vec<usize>),
Skip,
Callback,
}
fn poll_plan(updates: &[serde_json::Value]) -> Vec<PollPlacement> {
let mut plan: Vec<PollPlacement> = updates
.iter()
.map(|update| match update.get("callback_query") {
Some(_) => PollPlacement::Callback,
None => PollPlacement::Single,
})
.collect();
let mut albums: HashMap<&str, Vec<usize>> = HashMap::new();
for (at, update) in updates.iter().enumerate() {
if plan[at] == PollPlacement::Callback {
continue;
}
if let Some(group) = update
.get("message")
.and_then(|message| message.get("media_group_id"))
.and_then(serde_json::Value::as_str)
{
albums.entry(group).or_default().push(at);
}
}
for members in albums.into_values() {
let (first, rest) = members.split_first().expect("an album has a member");
let first = *first;
for &later in rest {
plan[later] = PollPlacement::Skip;
}
plan[first] = PollPlacement::Album(members);
}
plan
}
#[async_trait]
impl Channel for TelegramChannel {
fn name(&self) -> &'static str {
"telegram"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let content = message.content.trim();
if content.is_empty() {
tracing::warn!("TelegramChannel: attempted to send empty message โ skipping");
return Ok(()); }
let (chat_id, thread_id) = parse_recipient(&message.recipient);
self.spawn_menu_refresh(chat_id);
let (text_without_markers, attachments, refusals) =
crate::util::with_block_in_place(|| {
parse_attachment_markers(content, &message.file_roots, self.limits())
});
if attachments.is_empty() && refusals.is_empty() {
return self
.send_text_chunks(content, chat_id, thread_id, message.reply_markup.clone())
.await;
}
let body = if refusals.is_empty() {
text_without_markers
} else {
let notices = refusals.join("\n\n");
if text_without_markers.is_empty() {
notices
} else {
format!("{text_without_markers}\n\n{notices}")
}
};
if !body.is_empty() {
self.send_text_chunks(&body, chat_id, thread_id, message.reply_markup.clone())
.await?;
}
let mut first_error: Option<anyhow::Error> = None;
for attachment in &attachments {
if let Err(e) = self.send_attachment(chat_id, thread_id, attachment).await {
tracing::warn!(
error = %e,
file = %crate::util::http::redact_address(&attachment.target),
"Telegram: attachment delivery failed"
);
let name = media_target::marker_name(&attachment.target);
let notice = match upload_too_large(&e) {
Some(too_large) => media_target::marker_refusal(&name, &too_large.to_string()),
None if is_http_url(&attachment.target) => {
media_target::marker_refusal(&name, ATTACHMENT_FETCH_FAILED_REASON)
}
None => media_target::marker_refusal(&name, ATTACHMENT_UPLOAD_FAILED_REASON),
};
if let Err(notice_err) = self
.send_text_chunks(¬ice, chat_id, thread_id, None)
.await
{
tracing::warn!(
error = %notice_err,
"Telegram: failed to report attachment failure to the user"
);
}
first_error.get_or_insert(e);
}
}
match first_error {
Some(e) => Err(e),
None => Ok(()),
}
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
let frame = ListenerFrame::enter(&self.listener_running, &self.listener_stopped);
if crate::shutdown::is_draining() {
tracing::info!("Telegram channel not starting: the daemon is draining");
return Ok(());
}
tracing::info!("Telegram channel listening for messages...");
let Some(slot) = self.open_intake().await else {
return Ok(());
};
tracing::debug!("Startup handshake succeeded; entering main long-poll loop.");
let intake = Intake::new(
self.intake_store(),
self.intake_claim.clone(),
&slot.account_id,
);
let (parts_tx, parts_rx) =
tokio::sync::mpsc::channel(crate::channels::telegram_group::QUEUE_CAPACITY);
let collector = crate::channels::telegram_group::spawn_collector(
parts_rx,
tx,
intake.clone(),
std::sync::Arc::clone(&self.preparations),
frame,
);
let (batch_tx, mut batch_rx) = tokio::sync::mpsc::channel::<HeldBatch>(BATCH_QUEUE);
let fetch_intake = intake.clone();
let fetch = async move {
self.intake_loop(&batch_tx, &fetch_intake, slot.updates)
.await;
};
let process_intake = intake.clone();
let process = async move {
if self.recover(&process_intake, &parts_tx).await {
while let Some(HeldBatch {
updates,
plan,
origin,
mut fresh,
}) = batch_rx.recv().await
{
if crate::shutdown::aborting() {
tracing::info!(
"Telegram listener stopping: recorded batches left for the next run"
);
break;
}
if !self
.process_updates(
&parts_tx,
&process_intake,
&mut fresh,
origin,
updates,
&plan,
)
.await
{
tracing::warn!(
"Telegram listener stopping: the message pipeline is closed"
);
break;
}
}
} else {
tracing::warn!("Telegram listener stopping: the message pipeline is closed");
}
drop(parts_tx);
};
tokio::pin!(fetch, process);
tokio::select! {
() = &mut fetch => { process.await; }
() = &mut process => {
self.cancel.cancel();
fetch.await;
}
}
if let Err(err) = collector.await {
tracing::error!(
%err,
"Telegram intake: the burst collector failed; what it still held stays in the record"
);
}
Ok(())
}
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
let url = self.api_url("sendChatAction");
let (chat_id, thread_id) = parse_recipient(recipient);
let mut body = serde_json::json!({
"chat_id": chat_id,
"action": "typing"
});
set_thread_id_on_json(&mut body, thread_id);
self.http_client().post(&url).json(&body).send().await?;
Ok(())
}
}
pub(crate) fn listener_base() -> Option<String> {
let channel = crate::channel_registry().get("telegram")?;
channel
.as_any()
.downcast_ref::<TelegramChannel>()
.map(|channel| channel.api_base.clone())
}
pub(crate) async fn start_telegram_listener() -> anyhow::Result<()> {
let token = crate::config::CONFIG
.telegram_bot_token()
.ok_or_else(|| anyhow::anyhow!("Telegram has no bot token configured"))?;
TelegramChannel::validate_token(&token).await?;
restart_telegram_listener(Some(&token))
}
pub(crate) async fn stop_telegram_listener() -> anyhow::Result<()> {
let registry = crate::channel_registry();
let frames = registry.replaced("telegram");
match registry.get("telegram") {
Some(current) => {
retire_channel(registry, std::sync::Arc::clone(¤t)).await;
}
None => stop_listeners(&frames, None).await,
}
let still_running = frames
.snapshot()
.iter()
.filter_map(|frame| frame.as_any().downcast_ref::<TelegramChannel>())
.any(TelegramChannel::running);
if still_running {
return Err(anyhow::anyhow!(
"the Telegram listener is still finishing the batch it held"
));
}
Ok(())
}
pub async fn run_configured_channel(tx: tokio::sync::mpsc::Sender<ChannelMessage>) {
use crate::channels::telegram_local::Boot;
let Some(token) = crate::config::CONFIG.telegram_bot_token() else {
tracing::info!("No Telegram bot token configured โ running in dashboard-only mode");
return;
};
match crate::channels::telegram_local::prepare_for_boot().await {
Boot::Start => {}
Boot::Running => {
tracing::info!("Telegram channel already started by the resumed switch");
return;
}
Boot::Waiting => {
tracing::warn!(
"Telegram: the local Bot API server is not ready โ the bot connects once it is"
);
return;
}
}
let Some(channel) = TelegramChannel::from_config(token) else {
tracing::warn!("Telegram: the configured mode has no Bot API endpoint yet");
return;
};
let channel = std::sync::Arc::new(channel);
tokio::spawn({
let tc = std::sync::Arc::clone(&channel);
async move {
tc.set_my_commands().await;
}
});
let channel: std::sync::Arc<dyn Channel> = channel;
crate::channel_registry().register(std::sync::Arc::clone(&channel));
if let Err(e) = channel.listen(tx).await {
tracing::error!(error = %e, "Telegram listener error");
}
}
async fn stop_listeners(
frames: &crate::ReplacedChannels,
starting: Option<&std::sync::Arc<dyn Channel>>,
) {
for frame in frames.snapshot() {
if starting.is_some_and(|starting| std::sync::Arc::ptr_eq(starting, &frame)) {
continue;
}
if let Some(telegram) = frame.as_any().downcast_ref::<TelegramChannel>() {
telegram.cancel_own();
telegram.wait_stopped().await;
if telegram.running() {
continue;
}
}
frames.retire(&frame);
}
}
pub fn restart_telegram_listener(new_token: Option<&str>) -> anyhow::Result<()> {
let registry = crate::channel_registry();
if let Some(token) = new_token.filter(|t| !t.trim().is_empty()) {
let Some(tx) = crate::MESSAGE_TX.get().cloned() else {
anyhow::bail!(
"the message pipeline is not running โ the Telegram listener cannot start"
);
};
let telegram_arc = std::sync::Arc::new(
TelegramChannel::from_config(token.to_string()).ok_or_else(|| {
anyhow::anyhow!(
"the local Telegram Bot API server has no endpoint yet โ the listener cannot start"
)
})?,
);
tokio::spawn({
let tc = std::sync::Arc::clone(&telegram_arc);
async move {
tc.set_my_commands().await;
}
});
let new_channel: std::sync::Arc<dyn Channel> = telegram_arc;
let frames = registry.replaced("telegram");
registry.replace(std::sync::Arc::clone(&new_channel));
tokio::spawn(hand_over_listener(frames, new_channel, tx));
tracing::info!("Telegram bot listener restarting with new token");
} else {
let old_channel = registry.get("telegram");
tokio::spawn(async move { retire_listener(registry, old_channel).await });
}
Ok(())
}
async fn retire_channel(
registry: &crate::ChannelRegistry,
channel: std::sync::Arc<dyn Channel>,
) -> bool {
let frames = registry.replaced("telegram");
frames.record(std::sync::Arc::clone(&channel));
stop_listeners(&frames, None).await;
registry.unregister_if(&channel)
}
async fn retire_listener(
registry: &crate::ChannelRegistry,
old: Option<std::sync::Arc<dyn Channel>>,
) {
let Some(old) = old else {
return;
};
if retire_channel(registry, old).await {
tracing::info!("Telegram bot token cleared โ listener stopped");
} else {
tracing::info!("Telegram bot token cleared โ a channel registered since is live");
}
}
async fn hand_over_listener(
frames: std::sync::Arc<crate::ReplacedChannels>,
new: std::sync::Arc<dyn Channel>,
tx: tokio::sync::mpsc::Sender<ChannelMessage>,
) {
stop_listeners(&frames, Some(&new)).await;
if let Err(e) = new.listen(tx).await {
tracing::error!(error = %e, "Telegram listener error after hot-reload");
}
}
pub async fn send_direct(
recipient: &str,
content: String,
reply_markup: Option<serde_json::Value>,
) -> anyhow::Result<()> {
let reply = SendMessage {
content,
recipient: recipient.to_string(),
reply_markup,
file_roots: Vec::new(),
};
if let Some(channel) = crate::channel_registry().get("telegram") {
return channel.send(&reply).await;
}
let token = crate::config::CONFIG
.telegram_bot_token()
.ok_or_else(|| anyhow::anyhow!("Telegram channel not found in registry"))?;
let channel = TelegramChannel::from_config(token)
.ok_or_else(|| anyhow::anyhow!("the Bot API this mode serves has no endpoint"))?;
channel.send(&reply).await
}
pub async fn send_reply(recipient: &str, content: &str) {
let _ = send_direct(recipient, content.to_string(), None).await;
}
pub async fn mirror_gui_message_to_telegram(msg: &ChannelMessage) {
if msg.channel != "gui" && msg.channel != "voice" && msg.channel != crate::cli::CHANNEL {
return;
}
let trimmed = msg.content.trim();
if trimmed.is_empty() {
return;
}
let Some(channel) = crate::channel_registry().get("telegram") else {
return;
};
let bindings = match crate::users::store()
.get_user_channels(&msg.user_name)
.await
{
Ok(b) => b,
Err(e) => {
tracing::warn!(
user = %msg.user_name,
error = %e,
"Failed to look up user channels for message mirror"
);
return;
}
};
let telegram_bindings: Vec<_> = bindings
.into_iter()
.filter(|b| b.channel == "telegram")
.collect();
if telegram_bindings.is_empty() {
return; }
let content = MEDIA_MARKER_RE.replace_all(trimmed, "").to_string();
let content = content.trim().to_string();
let quoted = if let Some(reply) = &msg.reply_reference {
let header = format!("โฉ {}: {}", reply.author, reply.snippet);
if content.is_empty() {
format!("<blockquote>\n{header}\n</blockquote>")
} else {
format!("<blockquote>\n{header}\n{content}\n</blockquote>")
}
} else {
if content.is_empty() {
return; }
format!("<blockquote>\n{content}\n</blockquote>")
};
for binding in &telegram_bindings {
let Some(reply_target) = &binding.reply_target else {
continue; };
let reply = SendMessage {
content: quoted.clone(),
recipient: reply_target.clone(),
reply_markup: None,
file_roots: Vec::new(),
};
if let Err(e) = channel.send(&reply).await {
tracing::error!(
user = %msg.user_name,
recipient = %reply_target,
error = %e,
"Failed to mirror local message to Telegram"
);
}
}
}
async fn post_set_my_commands(
client: &reqwest::Client,
url: &str,
body: &serde_json::Value,
) -> bool {
match client.post(url).json(body).send().await {
Ok(resp) if resp.status().is_success() => {
tracing::debug!("Telegram bot commands registered successfully");
true
}
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
tracing::warn!(
status = %status,
body = %body,
"Telegram setMyCommands returned unsuccessful status"
);
false
}
Err(e) => {
tracing::warn!(error = %e, "Failed to call Telegram setMyCommands");
false
}
}
}
fn phase_emoji(phase: TicketPhase) -> &'static str {
match phase {
TicketPhase::Backlog => "๐ฅ",
TicketPhase::Analysis => "๐",
TicketPhase::Planning => "๐งญ",
TicketPhase::Queued => "โณ",
TicketPhase::InDevelopment => "๐จ",
TicketPhase::InSanitation => "๐งน",
TicketPhase::Verification => "๐ฏ",
TicketPhase::Done => "โ
",
TicketPhase::Cancelled => "๐ซ",
TicketPhase::Failed => "โ",
}
}
#[must_use]
pub fn format_board_line(phase: &TicketPhase, id: &str, title: &str) -> String {
format!("{} `{}` {}", phase_emoji(*phase), id, title)
}
#[must_use]
pub fn board_listing_text(tickets: &[&Ticket]) -> String {
if tickets.is_empty() {
return "All workspaces โ no tickets".to_string();
}
let listing = tickets
.iter()
.map(|t| format_board_line(&t.phase, &t.id, &t.title))
.collect::<Vec<_>>()
.join("\n");
let noun = if tickets.len() == 1 {
"ticket"
} else {
"tickets"
};
format!("All workspaces โ {} {noun}\n{listing}", tickets.len())
}
#[must_use]
pub async fn user_command_entries(user_name: &str) -> Vec<(String, String)> {
let mut entries: Vec<(String, String)> = Vec::new();
if crate::users::is_admin(user_name).await {
entries.push(("board".to_string(), BOARD_COMMAND_DESC.to_string()));
entries.push(("archive".to_string(), ARCHIVE_COMMAND_DESC.to_string()));
if crate::self_update::should_show_update(crate::self_update::update_availability()) {
entries.push(("update".to_string(), UPDATE_COMMAND_DESC.to_string()));
}
}
entries.push((
"image_models".to_string(),
IMAGE_MODELS_COMMAND_DESC.to_string(),
));
entries.push((
"video_models".to_string(),
VIDEO_MODELS_COMMAND_DESC.to_string(),
));
entries.push(("clear".to_string(), CLEAR_COMMAND_DESC.to_string()));
entries
}
#[cfg(test)]
#[path = "telegram_tests.rs"]
mod tests;