use serde::{Deserialize, Serialize};
use super::{Health, Mode, api_cause, credentials, set_status};
enum Step {
Blocked(String),
Retry(String),
Draining(String),
}
#[derive(Default, Serialize, Deserialize)]
#[expect(clippy::struct_excessive_bools)]
pub(super) struct Journal {
#[serde(default)]
pub(super) from: String,
pub(super) target: String,
#[serde(default)]
pub(super) cloud_logout_started: bool,
#[serde(default)]
pub(super) cloud_logged_out: bool,
#[serde(default)]
pub(super) local_left: bool,
#[serde(default)]
pub(super) intake_cleared: bool,
#[serde(default)]
pub(super) blocked: Option<String>,
#[serde(default)]
pub(super) blocked_credentials: Option<String>,
#[serde(default)]
pub(super) notice_sent: bool,
#[serde(default)]
pub(super) notice: Option<String>,
#[serde(default)]
pub(super) notice_settled: bool,
#[serde(default)]
pub(super) notice_attempts: u32,
#[serde(default)]
pub(super) attempts: u32,
}
impl Journal {
pub(super) fn origin_mode(&self) -> Option<Mode> {
Mode::parse(&self.from)
}
pub(super) fn target_mode(&self) -> Option<Mode> {
Mode::parse(&self.target)
}
pub(super) fn block_may_clear(&self) -> bool {
let Some(reason) = self.blocked.as_deref() else {
return true;
};
if !super::credentials_rejected(reason) {
return true;
}
self.blocked_credentials.as_deref() != Some(current_pair_fingerprint().as_str())
}
pub(super) fn intake_must_be_purged(&self) -> bool {
if self.intake_cleared {
return false;
}
match self.origin_mode() {
Some(Mode::Local) => self.local_left,
_ => self.cloud_logged_out,
}
}
pub(super) fn finishing(&self) -> bool {
self.notice.is_some() || self.notice_settled
}
}
pub(super) fn current() -> Option<Journal> {
serde_json::from_str(crate::config::CONFIG.telegram_local_migration()?.trim()).ok()
}
pub(super) fn pending() -> bool {
current().is_some()
}
pub(super) fn is_blocked() -> bool {
current().is_some_and(|journal| journal.blocked.is_some())
}
pub(super) fn finishing() -> bool {
current().is_some_and(|journal| journal.finishing())
}
fn current_pair_fingerprint() -> String {
let (api_id, api_hash) = super::credentials();
super::server::credentials_fingerprint(&api_id, &api_hash)
}
pub(super) fn waiting_on_rejected_pair() -> bool {
current().is_some_and(|journal| journal.blocked.is_some() && !journal.block_may_clear())
}
async fn save(journal: &Journal) -> anyhow::Result<()> {
let body = serde_json::to_string(journal)?;
crate::config::persist_settled_string_field(
crate::config::CONFIG_KEY_TELEGRAM_LOCAL_MIGRATION,
&body,
)
.await?;
Ok(())
}
async fn clear() -> anyhow::Result<()> {
crate::config::persist_settled_string_field(
crate::config::CONFIG_KEY_TELEGRAM_LOCAL_MIGRATION,
"",
)
.await?;
Ok(())
}
fn switch_lock() -> &'static tokio::sync::Mutex<()> {
static SWITCH: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
SWITCH.get_or_init(|| tokio::sync::Mutex::new(()))
}
pub(super) async fn start(target: Mode) -> anyhow::Result<()> {
let _guard = switch_lock().lock().await;
let journal = requested(current(), super::serving_mode(), target);
save(&journal).await?;
if let Err(e) = crate::config::persist_settled_string_field(
crate::config::CONFIG_KEY_TELEGRAM_LOCAL_SERVER,
target.as_str(),
)
.await
{
tracing::warn!(
error = %e,
"Telegram: the switch mode could not be recorded; it will be written when the switch completes"
);
}
set_status(|status| {
status.health = Health::Starting;
status.last_error = None;
});
tracing::info!(target = target.as_str(), "Telegram: switching Bot API");
tokio::spawn(resume());
Ok(())
}
fn requested(existing: Option<Journal>, serving: Mode, target: Mode) -> Journal {
let mut journal = existing.unwrap_or_default();
let retry = journal.origin_mode().is_some() && journal.target_mode() == Some(target);
if !retry {
journal.from = serving.as_str().to_string();
journal.cloud_logout_started = false;
journal.cloud_logged_out = false;
journal.local_left = false;
journal.intake_cleared = false;
}
journal.target = target.as_str().to_string();
journal.blocked = None;
journal.blocked_credentials = None;
journal.attempts = 0;
journal.notice_sent = false;
journal.notice = None;
journal.notice_settled = false;
journal.notice_attempts = 0;
journal
}
const SWITCH_ATTEMPT_LIMIT: u32 = 15;
pub(super) async fn resume() {
let _guard = switch_lock().lock().await;
let Some(mut journal) = current() else {
return;
};
if journal.finishing() {
finish_notice(&mut journal).await;
return;
}
if journal.blocked.is_some() {
if !journal.block_may_clear() {
return;
}
journal.blocked = None;
journal.blocked_credentials = None;
journal.attempts = 0;
if let Err(e) = save(&journal).await {
tracing::warn!(error = %e, "Telegram: the switch journal could not be saved");
}
}
let Some(target) = journal.target_mode() else {
tracing::warn!("Telegram: the switch journal names no mode; leaving it alone");
return;
};
let result = match target {
Mode::Local => to_local(&mut journal).await,
Mode::Cloud => to_cloud(&mut journal).await,
};
match result {
Ok(()) => {
journal.blocked = None;
finish_notice(&mut journal).await;
}
Err(Step::Blocked(reason)) => block(&mut journal, &reason).await,
Err(Step::Draining(reason)) => {
tracing::info!("Telegram: the switch waits for the listener: {reason}");
}
Err(Step::Retry(reason)) => {
journal.attempts = journal.attempts.saturating_add(1);
if let Err(e) = save(&journal).await {
tracing::warn!(error = %e, "Telegram: the switch journal could not be saved");
}
set_status(|status| status.last_error = Some(reason.clone()));
if journal.attempts < SWITCH_ATTEMPT_LIMIT {
tracing::warn!("Telegram: the switch will be retried: {reason}");
} else {
block(&mut journal, &reason).await;
}
}
}
}
async fn block(journal: &mut Journal, reason: &str) {
journal.blocked = Some(reason.to_string());
journal.blocked_credentials =
super::credentials_rejected(reason).then(current_pair_fingerprint);
if let Err(e) = save(journal).await {
tracing::warn!(error = %e, "Telegram: the switch journal could not be saved");
}
set_status(|status| status.last_error = Some(reason.to_string()));
tracing::warn!("Telegram: the switch is waiting for the owner: {reason}");
if journal.notice_sent {
return;
}
journal.notice_sent = true;
if let Err(e) = save(journal).await {
tracing::warn!(error = %e, "Telegram: the notice flag could not be saved");
}
if super::send_owner_notice(&blocked_notice(reason)).await {
return;
}
journal.notice_sent = false;
if let Err(e) = save(journal).await {
tracing::warn!(error = %e, "Telegram: the notice flag could not be cleared");
}
}
fn blocked_notice(reason: &str) -> String {
let listening = crate::channel_registry().get("telegram").is_some();
let reachability = match (super::serving_mode(), listening) {
(Mode::Cloud, true) => {
"The bot is still served by Telegram's cloud Bot API and keeps working."
}
(Mode::Cloud, false) => {
"Nothing is polling for the bot right now; the switch keeps trying to bring it back."
}
(Mode::Local, true) => {
"The bot is served by the local Bot API server only: if that server cannot reach \
Telegram, the bot is not reachable until that is fixed."
}
(Mode::Local, false) => {
"The bot cannot be reached right now: nothing is polling for it until the cause \
above is fixed."
}
};
format!(
"⚠️ Telegram could not finish switching its Bot API: {reason}\n\
{reachability} It has not gone back to the cloud on its own. Settings → Telegram shows \
the state: correcting the entry there finishes the switch, and switching back to the \
cloud clears the local server."
)
}
fn cloud_logout_done(resumed: bool, cause: &str) -> bool {
resumed
&& api_cause(cause)
.to_ascii_lowercase()
.contains("unauthorized")
}
fn restore_cloud_listener() {
if crate::channel_registry().get("telegram").is_some() {
return;
}
let token = crate::config::CONFIG.telegram_bot_token();
if let Err(e) = crate::channels::telegram::restart_telegram_listener(token.as_deref()) {
tracing::warn!(error = %e, "Telegram: the cloud listener could not be restored");
}
}
async fn leave_cloud(journal: &mut Journal) -> Result<(), Step> {
let result = release_cloud(journal).await;
if matches!(result, Err(Step::Retry(_))) && !journal.cloud_logged_out {
restore_cloud_listener();
}
result
}
async fn release_cloud(journal: &mut Journal) -> Result<(), Step> {
crate::channels::telegram::stop_telegram_listener()
.await
.map_err(|e| Step::Draining(e.to_string()))?;
let Some(token) = crate::config::CONFIG.telegram_bot_token() else {
return Err(Step::Blocked("Telegram has no bot token configured".into()));
};
let resumed = journal.cloud_logout_started;
journal.cloud_logout_started = true;
save(journal)
.await
.map_err(|e| Step::Retry(e.to_string()))?;
match crate::channels::telegram::TelegramChannel::log_out(
crate::channels::telegram::CLOUD_API_BASE,
&token,
)
.await
{
Ok(()) => {}
Err(e) if cloud_logout_done(resumed, &e.to_string()) => {}
Err(e) => {
return Err(Step::Retry(format!(
"the bot could not leave the cloud: {e}"
)));
}
}
journal.cloud_logged_out = true;
save(journal)
.await
.map_err(|e| Step::Retry(e.to_string()))?;
Ok(())
}
async fn to_local(journal: &mut Journal) -> Result<(), Step> {
if crate::config::CONFIG.telegram_bot_token().is_none() {
return Err(Step::Blocked(
"Telegram has no bot token configured, so there is no bot to serve".into(),
));
}
let (api_id, api_hash) = credentials();
super::validate_credentials(&api_id, &api_hash).map_err(Step::Blocked)?;
let fingerprint = super::server::credentials_fingerprint(&api_id, &api_hash);
let instance = super::server::ensure(&fingerprint)
.await
.map_err(Step::Retry)?;
if !journal.cloud_logged_out {
leave_cloud(journal).await?;
}
super::publish_endpoint(instance.port, instance.version.clone());
if journal.intake_must_be_purged() {
crate::channels::telegram_intake::purge_all()
.await
.map_err(|e| Step::Retry(format!("the intake record could not be cleared: {e}")))?;
journal.intake_cleared = true;
save(journal)
.await
.map_err(|e| Step::Retry(e.to_string()))?;
}
set_status(|status| status.health = Health::Starting);
if let Err(e) = crate::channels::telegram::start_telegram_listener().await {
let reason = e.to_string();
if super::credentials_rejected(&reason) {
set_status(|status| status.health = Health::Down);
super::server::stop().await;
return Err(Step::Blocked(api_cause(&reason).to_string()));
}
return Err(Step::Retry(format!(
"the local server did not accept the bot: {reason}"
)));
}
set_status(|status| {
status.health = Health::Unconfirmed;
status.last_error = None;
});
journal.notice = Some(format!(
"✅ MahBot is available again — now through its own local Telegram Bot API server \
({}). Large files no longer meet Telegram's cloud limits: up to 2000 MB can be sent, \
and incoming files are no longer refused at 20 MB.",
instance.version.as_deref().unwrap_or("Bot API server")
));
save(journal)
.await
.map_err(|e| Step::Retry(e.to_string()))?;
Ok(())
}
async fn to_cloud(journal: &mut Journal) -> Result<(), Step> {
crate::channels::telegram::stop_telegram_listener()
.await
.map_err(|e| Step::Draining(e.to_string()))?;
if !journal.local_left {
if journal.origin_mode() == Some(Mode::Local)
&& let (Some(base), Some(token)) = (
super::local_endpoint(),
crate::config::CONFIG.telegram_bot_token(),
)
&& let Err(e) = crate::channels::telegram::TelegramChannel::log_out(&base, &token).await
{
tracing::warn!(error = %e, "Telegram: the local server did not release the bot");
}
journal.local_left = true;
save(journal)
.await
.map_err(|e| Step::Retry(e.to_string()))?;
}
super::server::remove_state().await;
if journal.intake_must_be_purged() {
crate::channels::telegram_intake::purge_all()
.await
.map_err(|e| Step::Retry(format!("the intake record could not be cleared: {e}")))?;
journal.intake_cleared = true;
save(journal)
.await
.map_err(|e| Step::Retry(e.to_string()))?;
}
if crate::config::CONFIG.telegram_bot_token().is_none() {
return Ok(());
}
set_status(|status| status.health = Health::Starting);
crate::channels::telegram::start_telegram_listener()
.await
.map_err(|e| {
Step::Retry(format!(
"the cloud Bot API has not taken the bot back yet: {e}"
))
})?;
set_status(|status| {
status.health = Health::Cloud;
status.last_error = None;
});
journal.notice = Some(
"✅ MahBot is back on Telegram's cloud Bot API. The local server and its data have been \
removed, so files are limited again by the cloud (20 MB to the bot, 50 MB from it)."
.to_string(),
);
save(journal)
.await
.map_err(|e| Step::Retry(e.to_string()))?;
Ok(())
}
const NOTICE_ATTEMPTS: u32 = 5;
async fn finish_notice(journal: &mut Journal) {
if let Some(text) = journal.notice.clone() {
if !super::send_owner_notice(&text).await {
journal.notice_attempts = journal.notice_attempts.saturating_add(1);
if journal.notice_attempts < NOTICE_ATTEMPTS {
if let Err(e) = save(journal).await {
tracing::warn!(error = %e, "Telegram: the switch journal could not be saved");
}
return;
}
tracing::warn!("Telegram: the switch notice could not be delivered; dropping it");
}
journal.notice = None;
journal.notice_settled = true;
if let Err(e) = save(journal).await {
tracing::warn!(error = %e, "Telegram: the switch journal could not be saved");
}
}
if let Some(target) = journal.target_mode()
&& super::configured_mode() != target
{
match crate::config::persist_settled_string_field(
crate::config::CONFIG_KEY_TELEGRAM_LOCAL_SERVER,
target.as_str(),
)
.await
{
Ok(_) => {}
Err(e) => {
tracing::warn!(error = %e, "Telegram: the switch mode could not be recorded");
return;
}
}
}
if let Err(e) = clear().await {
tracing::warn!(error = %e, "Telegram: the switch journal could not be cleared");
}
crate::runtime_events::publish(crate::runtime_events::RuntimeEvent::TelegramLocal);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_switch_walks_forward_blocks_retries_and_comes_back() {
let mut journal = requested(None, Mode::Cloud, Mode::Local);
assert_eq!(journal.origin_mode(), Some(Mode::Cloud));
assert_eq!(serving(&journal), Mode::Cloud);
assert!(!journal.intake_must_be_purged());
journal.cloud_logged_out = true;
assert_eq!(serving(&journal), Mode::Local);
assert!(journal.intake_must_be_purged());
journal.intake_cleared = true;
assert!(!journal.intake_must_be_purged());
journal.blocked = Some("Unauthorized: invalid api-id/api-hash".into());
journal.blocked_credentials = Some(current_pair_fingerprint());
assert!(!journal.block_may_clear());
assert_eq!(serving(&journal), Mode::Local);
let retried = requested(Some(journal), Mode::Local, Mode::Local);
assert_eq!(retried.origin_mode(), Some(Mode::Cloud));
assert!(retried.cloud_logged_out);
assert!(retried.blocked.is_none());
assert_eq!(serving(&retried), Mode::Local);
let mut back = requested(Some(retried), Mode::Local, Mode::Cloud);
assert_eq!(back.origin_mode(), Some(Mode::Local));
assert!(
!back.cloud_logout_started && !back.cloud_logged_out && !back.local_left,
"the other direction's steps are not this journey's progress"
);
assert!(
!back.intake_must_be_purged(),
"the record of the side being left is purged when the bot leaves it, not before"
);
assert_eq!(serving(&back), Mode::Local);
back.local_left = true;
assert_eq!(serving(&back), Mode::Cloud);
assert!(
back.intake_must_be_purged(),
"the local server's update ids must not stand in for the cloud's"
);
back.intake_cleared = true;
assert!(!back.intake_must_be_purged());
let retried_back = requested(Some(back), Mode::Cloud, Mode::Cloud);
assert_eq!(retried_back.origin_mode(), Some(Mode::Local));
assert_eq!(serving(&retried_back), Mode::Cloud);
let mut finished = retried_back;
finished.notice = Some("the local server serves the bot now".into());
let next = requested(Some(finished), Mode::Cloud, Mode::Local);
assert!(
next.notice.is_none(),
"a new journey is not owed the previous journey's message"
);
let mut delivered = requested(None, Mode::Local, Mode::Cloud);
delivered.notice_settled = true;
assert!(delivered.notice.is_none());
assert!(
!requested(Some(delivered), Mode::Local, Mode::Cloud).notice_settled,
"a new journey has its own message to deliver"
);
let forward_again = requested(None, Mode::Cloud, Mode::Local);
assert_eq!(forward_again.origin_mode(), Some(Mode::Cloud));
assert_eq!(serving(&forward_again), Mode::Cloud);
}
#[test]
fn a_revert_from_a_settled_local_mode_releases_and_purges_the_local_side() {
let mut journal = requested(None, Mode::Local, Mode::Cloud);
assert_eq!(journal.origin_mode(), Some(Mode::Local));
assert!(!journal.cloud_logged_out);
assert_eq!(serving(&journal), Mode::Local);
assert!(!journal.intake_must_be_purged());
journal.local_left = true;
assert_eq!(serving(&journal), Mode::Cloud);
assert!(journal.intake_must_be_purged());
}
fn serving(journal: &Journal) -> Mode {
super::super::serving_from(
journal.target_mode().expect("a journal names a target"),
Some(journal),
)
}
#[test]
fn only_a_resumed_attempt_reads_unauthorized_as_already_out() {
assert!(!cloud_logout_done(
false,
"Telegram rejected the bot token — Unauthorized"
));
assert!(cloud_logout_done(
true,
"Telegram rejected the bot token — Unauthorized"
));
assert!(!cloud_logout_done(true, "the Bot API could not be reached"));
}
}