mod install;
mod migrate;
mod server;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::config::CONFIG;
use crate::util::UnwrapPoison;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Mode {
Cloud,
Local,
}
impl Mode {
pub(super) fn parse(value: &str) -> Option<Self> {
match value.trim() {
value if value.eq_ignore_ascii_case("local") => Some(Mode::Local),
value if value.eq_ignore_ascii_case("cloud") => Some(Mode::Cloud),
_ => None,
}
}
pub(super) fn from_value(value: Option<&str>) -> Self {
value.and_then(Self::parse).unwrap_or(Mode::Cloud)
}
pub(crate) fn as_str(self) -> &'static str {
match self {
Mode::Cloud => "cloud",
Mode::Local => "local",
}
}
}
fn configured_mode() -> Mode {
Mode::from_value(CONFIG.telegram_local_server().as_deref())
}
pub(crate) fn serving_mode() -> Mode {
serving_from(configured_mode(), migrate::current().as_ref())
}
fn serving_from(configured: Mode, switch: Option<&migrate::Journal>) -> Mode {
let Some(switch) = switch else {
return configured;
};
let Some(from) = switch.origin_mode() else {
return configured;
};
let left = match from {
Mode::Cloud => switch.cloud_logged_out,
Mode::Local => switch.local_left,
};
if left {
match from {
Mode::Cloud => Mode::Local,
Mode::Local => Mode::Cloud,
}
} else {
from
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Health {
Cloud,
Starting,
Running,
Unconfirmed,
Down,
}
#[derive(Clone, Debug)]
pub(crate) struct Status {
pub(crate) target: Mode,
pub(crate) serving: Mode,
pub(crate) health: Health,
pub(crate) version: Option<String>,
pub(crate) last_error: Option<String>,
pub(crate) port: Option<u16>,
pub(crate) migrating: bool,
pub(crate) blocked: Option<String>,
pub(crate) credentials_set: bool,
}
impl Status {
fn idle() -> Self {
Self {
target: Mode::Cloud,
serving: Mode::Cloud,
health: Health::Cloud,
version: None,
last_error: None,
port: None,
migrating: false,
blocked: None,
credentials_set: false,
}
}
}
static STATUS: OnceLock<std::sync::Mutex<Status>> = OnceLock::new();
fn status_cell() -> &'static std::sync::Mutex<Status> {
STATUS.get_or_init(|| std::sync::Mutex::new(Status::idle()))
}
pub(crate) fn status() -> Status {
let switch = migrate::current();
let mut snapshot = status_cell().lock().unwrap_poison().clone();
snapshot.credentials_set = credentials_set();
snapshot.migrating = switch.as_ref().is_some_and(|switch| !switch.finishing());
snapshot.target = switch
.as_ref()
.and_then(migrate::Journal::target_mode)
.unwrap_or_else(configured_mode);
snapshot.serving = serving_from(configured_mode(), switch.as_ref());
snapshot.blocked = switch.as_ref().and_then(|switch| switch.blocked.clone());
if let Some(reason) = snapshot.blocked.clone() {
snapshot.last_error = Some(reason);
}
if snapshot.serving == Mode::Cloud {
snapshot.health = Health::Cloud;
snapshot.port = None;
}
snapshot
}
fn set_status(update: impl FnOnce(&mut Status)) {
{
let mut guard = status_cell().lock().unwrap_poison();
update(&mut guard);
}
crate::runtime_events::publish(crate::runtime_events::RuntimeEvent::TelegramLocal);
}
pub(crate) fn local_endpoint() -> Option<String> {
let port = status_cell().lock().unwrap_poison().port?;
Some(format!("http://127.0.0.1:{port}"))
}
pub(crate) fn incoming_files_dir() -> Option<std::path::PathBuf> {
server::download_root()
}
fn credentials() -> (String, String) {
let api_id = CONFIG.telegram_api_id().unwrap_or_default();
let api_hash = CONFIG.telegram_api_hash().unwrap_or_default();
(
api_id.trim().to_string(),
api_hash.trim().to_ascii_lowercase(),
)
}
fn credentials_set() -> bool {
let (api_id, api_hash) = credentials();
validate_credentials(&api_id, &api_hash).is_ok()
}
pub(crate) fn credential_problems(
api_id: &str,
api_hash: &str,
) -> (Option<String>, Option<String>) {
(parse_api_id(api_id).err(), parse_api_hash(api_hash).err())
}
pub(crate) fn validate_credentials(api_id: &str, api_hash: &str) -> Result<(), String> {
parse_api_id(api_id)?;
parse_api_hash(api_hash)
}
fn parse_api_id(api_id: &str) -> Result<(), String> {
let api_id = api_id.trim();
if api_id.is_empty() {
return Err("api_id is missing — copy the number my.telegram.org shows".to_string());
}
if !api_id.chars().all(|c| c.is_ascii_digit()) {
return Err(
"api_id must be a number — my.telegram.org shows it as digits only".to_string(),
);
}
match api_id.parse::<u32>() {
Ok(0) => Err("api_id must be a number greater than zero".to_string()),
Ok(_) => Ok(()),
Err(_) => Err("api_id is not a number — copy it again from my.telegram.org".to_string()),
}
}
fn parse_api_hash(api_hash: &str) -> Result<(), String> {
let api_hash = api_hash.trim();
if api_hash.is_empty() {
return Err(
"api_hash is missing — copy the 32-character value from my.telegram.org".to_string(),
);
}
if api_hash.len() != 32 || !api_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!(
"api_hash must be 32 hexadecimal characters — the value pasted has {}",
api_hash.len()
));
}
Ok(())
}
pub(crate) enum Boot {
Start,
Running,
Waiting,
}
const LIVENESS_INTERVAL: Duration = Duration::from_mins(1);
const REACHABILITY_INTERVAL: Duration = Duration::from_mins(5);
const UPDATE_INTERVAL: Duration = Duration::from_hours(1);
const HOUSEKEEPING_INTERVAL: Duration = Duration::from_hours(1);
const RESTART_BACKOFF: [Duration; 4] = [
Duration::from_secs(5),
Duration::from_secs(30),
Duration::from_mins(2),
Duration::from_mins(10),
];
const SETTLED_RETRY: Duration = Duration::from_hours(1);
const BLOCKED_RETRY: Duration = Duration::from_hours(1);
const CONTACT_FRESHNESS: Duration = Duration::from_mins(15);
static LAST_CONTACT: OnceLock<std::sync::Mutex<Option<tokio::time::Instant>>> = OnceLock::new();
pub(crate) fn note_telegram_contact() {
let cell = LAST_CONTACT.get_or_init(|| std::sync::Mutex::new(None));
*cell.lock().unwrap_poison() = Some(tokio::time::Instant::now());
}
fn contacted_recently() -> bool {
LAST_CONTACT
.get()
.and_then(|cell| *cell.lock().unwrap_poison())
.is_some_and(|at| at.elapsed() < CONTACT_FRESHNESS)
}
static FAILURE_NOTIFIED: AtomicBool = AtomicBool::new(false);
static UPDATE_INSTALLED: AtomicBool = AtomicBool::new(false);
static UPDATE_RUNNING: AtomicBool = AtomicBool::new(false);
pub(crate) async fn prepare_for_boot() -> Boot {
server::adopt_or_reap().await;
if let Some(instance) = server::read_record()
&& server::alive(&instance).await
{
publish_endpoint(instance.port, instance.version.clone());
}
if migrate::pending() {
migrate::resume().await;
if crate::channel_registry().get("telegram").is_some() {
return Boot::Running;
}
}
if configured_mode() == Mode::Local
&& !migrate::pending()
&& let Err(reason) = ensure_local().await
{
report_failure(&reason);
}
if serving_mode() == Mode::Local && local_endpoint().is_none() {
Boot::Waiting
} else {
Boot::Start
}
}
async fn ensure_local() -> Result<(), String> {
if crate::config::CONFIG.telegram_bot_token().is_none() {
return Err("Telegram has no bot token configured, so there is no bot to serve".into());
}
let (api_id, api_hash) = credentials();
validate_credentials(&api_id, &api_hash)?;
set_status(|status| status.health = Health::Starting);
let fingerprint = server::credentials_fingerprint(&api_id, &api_hash);
let instance = server::ensure(&fingerprint).await?;
publish_endpoint(instance.port, instance.version.clone());
Ok(())
}
pub(super) fn publish_endpoint(port: u16, version: Option<String>) {
set_status(|status| {
status.port = Some(port);
status.version = version;
status.health = Health::Unconfirmed;
status.last_error = None;
});
FAILURE_NOTIFIED.store(false, Ordering::SeqCst);
}
pub(super) fn clear_endpoint() {
set_status(|status| {
status.port = None;
status.version = None;
});
}
fn report_failure(reason: &str) {
set_status(|status| {
status.health = Health::Down;
status.last_error = Some(reason.to_string());
});
tracing::warn!("telegram-bot-api: {reason}");
if !FAILURE_NOTIFIED.swap(true, Ordering::SeqCst) {
let text = format!(
"Telegram is unreachable: {reason}. MahBot keeps trying. \
If it does not come back, switch to the cloud Bot API in Settings."
);
tokio::spawn(async move { send_owner_notice(&text).await });
}
}
fn api_cause(reason: &str) -> &str {
reason
.split_once(crate::channels::telegram::REJECTED_TOKEN_PREFIX)
.map_or(reason, |(_, cause)| cause)
}
async fn send_owner_notice(text: &str) -> bool {
let Some(target) = crate::self_update::resolve_admin_telegram_target().await else {
tracing::info!("telegram-bot-api: no owner chat to notify");
return false;
};
match crate::channels::telegram::send_direct(&target, text.to_string(), None).await {
Ok(()) => true,
Err(e) => {
tracing::warn!(error = %e, "telegram-bot-api: the owner could not be notified");
false
}
}
}
pub(crate) fn credentials_rejected(reason: &str) -> bool {
let reason = reason.to_ascii_lowercase();
reason.contains("api-id") || reason.contains("api-hash")
}
pub(crate) async fn request_switch(target: Mode) -> anyhow::Result<()> {
if target == Mode::Local {
if CONFIG.telegram_bot_token().is_none() {
anyhow::bail!("Telegram is not configured yet: add a bot token first");
}
let (api_id, api_hash) = credentials();
validate_credentials(&api_id, &api_hash).map_err(anyhow::Error::msg)?;
}
let settled = configured_mode() == target && (!migrate::pending() || migrate::finishing());
if settled {
return Ok(());
}
migrate::start(target).await?;
Ok(())
}
pub(crate) async fn run_supervisor() {
let mut backoff = 0usize;
let mut retry_at: Option<tokio::time::Instant> = None;
let mut reachability_at = tokio::time::Instant::now();
let mut update_at = tokio::time::Instant::now();
let mut housekeeping_at = tokio::time::Instant::now();
let mut blocked_at = tokio::time::Instant::now();
loop {
let wait = if configured_mode() == Mode::Local && !migrate::pending() {
retry_at.map_or(LIVENESS_INTERVAL, |at| {
at.saturating_duration_since(tokio::time::Instant::now())
.min(LIVENESS_INTERVAL)
})
} else {
LIVENESS_INTERVAL
};
if !crate::shutdown::sleep_or_shutdown(wait).await {
break;
}
if migrate::pending() {
if !migrate::is_blocked() {
migrate::resume().await;
} else if tokio::time::Instant::now() >= blocked_at {
blocked_at = tokio::time::Instant::now() + BLOCKED_RETRY;
migrate::resume().await;
}
}
let switching = migrate::pending() && !migrate::is_blocked() && !migrate::finishing();
let waiting_on_pair = migrate::waiting_on_rejected_pair();
if serving_mode() == Mode::Local
&& !switching
&& UPDATE_INSTALLED.swap(false, Ordering::SeqCst)
{
apply_server_update().await;
}
if serving_mode() == Mode::Local {
if !switching && !waiting_on_pair {
let mut up = match server::read_record() {
Some(instance) => server::alive(&instance).await,
None => false,
};
if up {
backoff = 0;
retry_at = None;
} else if retry_at.is_none_or(|at| tokio::time::Instant::now() >= at) {
match ensure_local().await {
Ok(()) => {
backoff = 0;
retry_at = None;
up = true;
}
Err(reason) => {
report_failure(&reason);
let delay = RESTART_BACKOFF
.get(backoff)
.copied()
.unwrap_or(SETTLED_RETRY);
backoff = backoff.saturating_add(1);
retry_at = Some(tokio::time::Instant::now() + delay);
}
}
}
if up
&& local_endpoint().is_some()
&& crate::channels::telegram::listener_base() != local_endpoint()
{
start_listener_after_recovery();
}
}
} else if !switching && server::read_record().is_some() {
server::stop().await;
}
if tokio::time::Instant::now() >= reachability_at {
reachability_at = tokio::time::Instant::now() + REACHABILITY_INTERVAL;
check_reachability().await;
}
if serving_mode() == Mode::Local
&& !switching
&& !waiting_on_pair
&& tokio::time::Instant::now() >= update_at
{
update_at = tokio::time::Instant::now() + UPDATE_INTERVAL;
start_server_update();
}
if tokio::time::Instant::now() >= housekeeping_at {
housekeeping_at = tokio::time::Instant::now() + HOUSEKEEPING_INTERVAL;
server::sweep_state_files().await;
}
}
}
fn start_listener_after_recovery() {
tokio::spawn(async move {
if let Err(e) = crate::channels::telegram::restart_telegram_listener(
CONFIG.telegram_bot_token().as_deref(),
) {
report_failure(&format!("the bot could not be reconnected: {e}"));
}
});
}
async fn check_reachability() {
if serving_mode() != Mode::Local
|| (migrate::pending() && !migrate::is_blocked() && !migrate::finishing())
{
return;
}
let Some(port) = status().port else {
if migrate::is_blocked() {
set_status(|status| status.health = Health::Down);
}
return;
};
if !server::probe(port).await {
set_status(|status| {
status.health = Health::Down;
status.last_error = Some("the local Bot API server stopped answering".into());
});
return;
}
if let (Some(base), Some(token)) = (local_endpoint(), CONFIG.telegram_bot_token())
&& let Err(e) = crate::channels::telegram::TelegramChannel::probe_bot(&base, &token).await
{
let failure = e.to_string();
let reason = api_cause(&failure);
set_status(|status| {
status.health = Health::Unconfirmed;
status.last_error = Some(format!("the local server refused the bot: {reason}"));
});
return;
}
if contacted_recently() {
set_status(|status| {
status.health = Health::Running;
status.last_error = None;
});
FAILURE_NOTIFIED.store(false, Ordering::SeqCst);
} else {
set_status(|status| {
status.health = Health::Unconfirmed;
status.last_error = None;
});
}
}
fn start_server_update() {
if UPDATE_RUNNING.swap(true, Ordering::SeqCst) {
return;
}
tokio::spawn(async {
install_newer_build().await;
UPDATE_RUNNING.store(false, Ordering::SeqCst);
});
}
async fn install_newer_build() {
let Some(current) =
server::read_record().and_then(|record| record.version.as_deref().and_then(parse_version))
else {
tracing::debug!("telegram-bot-api: the running server reports no comparable version");
return;
};
match install::latest().await {
Ok(release) if current < release.version => {
let version = release.version.clone();
tracing::info!(%version, "telegram-bot-api: updating the server");
match install::install(release).await {
Ok(_installed) => UPDATE_INSTALLED.store(true, Ordering::SeqCst),
Err(reason) => tracing::warn!("telegram-bot-api: update failed: {reason}"),
}
}
Ok(_) => {}
Err(reason) => {
tracing::warn!("telegram-bot-api: the release stream could not be read: {reason}");
}
}
}
async fn apply_server_update() {
server::stop().await;
match ensure_local().await {
Ok(()) => {
set_status(|status| status.health = Health::Unconfirmed);
start_listener_after_recovery();
}
Err(reason) => report_failure(&reason),
}
}
fn parse_version(reported: &str) -> Option<semver::Version> {
let numeric = reported
.split_whitespace()
.find(|word| word.chars().next().is_some_and(|c| c.is_ascii_digit()))?;
crate::util::managed_bin::parse_short_version(numeric)
}
pub(crate) async fn stop_for_exit() {
if configured_mode() == Mode::Local || server::read_record().is_some() {
server::stop().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_malformed_pair_is_named_field_by_field() {
let (id, hash) = credential_problems("", "");
assert!(
id.as_deref()
.is_some_and(|problem| problem.contains("api_id is missing"))
);
assert!(
hash.as_deref()
.is_some_and(|problem| problem.contains("api_hash is missing"))
);
let (id, hash) = credential_problems("12ab", &"a".repeat(32));
assert!(
id.as_deref()
.is_some_and(|problem| problem.contains("must be a number"))
);
assert!(hash.is_none());
let (id, hash) = credential_problems("0", &"a".repeat(32));
assert!(
id.as_deref()
.is_some_and(|problem| problem.contains("greater than zero"))
);
assert!(hash.is_none());
let (id, hash) = credential_problems("1234567", "abc");
assert!(id.is_none());
assert!(
hash.as_deref()
.is_some_and(|problem| problem.contains("32 hexadecimal"))
);
assert!(
validate_credentials("", "")
.unwrap_err()
.contains("api_id is missing")
);
assert!(
validate_credentials("12ab", "abc")
.unwrap_err()
.contains("must be a number")
);
assert!(
validate_credentials("1234567", "abc")
.unwrap_err()
.contains("32 hexadecimal")
);
validate_credentials(" 1234567 ", "ABCDEF0123456789ABCDEF0123456789")
.expect("a well-formed pair is accepted, in either case");
}
#[test]
fn the_serving_mode_follows_the_side_the_bot_is_on() {
let switch =
|from: &str, target: &str, cloud_logged_out: bool, local_left: bool| migrate::Journal {
from: from.into(),
target: target.into(),
cloud_logged_out,
local_left,
..Default::default()
};
assert_eq!(serving_from(Mode::Cloud, None), Mode::Cloud);
assert_eq!(serving_from(Mode::Local, None), Mode::Local);
assert_eq!(
serving_from(Mode::Local, Some(&switch("cloud", "local", false, false))),
Mode::Cloud
);
assert_eq!(
serving_from(Mode::Local, Some(&switch("cloud", "local", true, false))),
Mode::Local
);
assert_eq!(
serving_from(Mode::Cloud, Some(&switch("local", "cloud", true, false))),
Mode::Local
);
assert_eq!(
serving_from(Mode::Cloud, Some(&switch("local", "cloud", true, true))),
Mode::Cloud
);
let unreadable = migrate::Journal {
target: "cloud".into(),
..Default::default()
};
assert_eq!(serving_from(Mode::Cloud, Some(&unreadable)), Mode::Cloud);
assert_eq!(serving_from(Mode::Local, Some(&unreadable)), Mode::Local);
}
#[test]
fn a_rejected_pair_is_recognised_by_what_the_server_says() {
assert!(credentials_rejected(
"Telegram rejected the bot token — Unauthorized: invalid api-id/api-hash"
));
assert!(!credentials_rejected(
"Telegram rejected the bot token — Unauthorized"
));
assert!(!credentials_rejected("connection refused"));
}
#[test]
fn the_cause_is_read_without_the_transports_wrapper() {
assert_eq!(
api_cause("Telegram rejected the bot token — Unauthorized"),
"Unauthorized"
);
assert_eq!(
api_cause("the Bot API could not be reached"),
"the Bot API could not be reached"
);
}
#[test]
fn a_reported_version_reads_with_or_without_a_patch() {
assert_eq!(
parse_version("Bot API 10.3"),
Some(semver::Version::new(10, 3, 0))
);
assert_eq!(
parse_version("Bot API 10.3.1"),
Some(semver::Version::new(10, 3, 1))
);
assert_eq!(parse_version("Telegram Bot API server"), None);
}
}