use std::path::PathBuf;
use semver::Version;
const TAG_PREFIX: &str = "tgapi-v";
const RELEASES_URL_ENV: &str = "MAHBOT_TGAPI_RELEASES_URL";
const LIST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(20);
fn client() -> Result<&'static reqwest::Client, String> {
static CLIENT: std::sync::OnceLock<Result<reqwest::Client, String>> =
std::sync::OnceLock::new();
CLIENT
.get_or_init(|| {
crate::util::http::build_download_client(DOWNLOAD_TIMEOUT)
.map_err(|e| format!("the release client could not be built: {e}"))
})
.as_ref()
.map_err(Clone::clone)
}
const GITHUB_USER_AGENT: &str = concat!("mahbot/", env!("CARGO_PKG_VERSION"));
fn releases_url() -> String {
let repo = crate::self_update::RELEASE_REPO
.trim_start_matches("https://github.com/")
.trim_end_matches('/');
format!("https://api.github.com/repos/{repo}/releases")
}
const fn file_name() -> &'static str {
if cfg!(windows) {
"telegram-bot-api.exe"
} else {
"telegram-bot-api"
}
}
pub(super) fn binary_path() -> Option<PathBuf> {
Some(
crate::config::default_config_dir()
.ok()?
.join("telegram-bot-api")
.join("bin")
.join(file_name()),
)
}
pub(super) fn present() -> bool {
binary_path().is_some_and(|path| crate::util::is_executable(&path))
}
pub(super) struct Release {
tag: String,
pub(super) version: Version,
archive_url: String,
sums_url: Option<String>,
}
fn asset_platform() -> Result<(&'static str, &'static str), String> {
let (os, arch) = crate::util::managed_bin::host_os_arch()?;
let arch = if os == "windows" && arch == "aarch64" {
"x86_64"
} else {
arch
};
Ok((os, arch))
}
const LISTING_PAGE_SIZE: usize = 100;
const LISTING_PAGES: u32 = 10;
pub(super) async fn latest() -> Result<Release, String> {
let (os, arch) = asset_platform()?;
let base = std::env::var(RELEASES_URL_ENV).unwrap_or_else(|_| releases_url());
for page in 1..=LISTING_PAGES {
let separator = if base.contains('?') { '&' } else { '?' };
let listing = fetch_listing(format!(
"{base}{separator}per_page={LISTING_PAGE_SIZE}&page={page}"
))
.await?;
if let Some(release) = newest_for(&listing, os, arch) {
return Ok(release);
}
if listing.as_array().map_or(0, Vec::len) < LISTING_PAGE_SIZE {
break;
}
}
Err(format!("no server release is published for {os}-{arch}"))
}
async fn fetch_listing(url: String) -> Result<serde_json::Value, String> {
let response = client()?
.get(&url)
.header(reqwest::header::USER_AGENT, GITHUB_USER_AGENT)
.timeout(LIST_TIMEOUT)
.send()
.await
.map_err(|e| {
format!(
"the newest server release could not be looked up: {}",
crate::util::managed_bin::request_reason(&e)
)
})?;
if !response.status().is_success() {
return Err(format!(
"the newest server release could not be looked up: HTTP {}",
response.status()
));
}
response.json().await.map_err(|e| {
format!(
"the release listing could not be read: {}",
crate::util::managed_bin::request_reason(&e)
)
})
}
fn parse_version(text: &str) -> Option<Version> {
crate::util::managed_bin::parse_short_version(text.strip_prefix(TAG_PREFIX)?)
}
fn newest_for(listing: &serde_json::Value, os: &str, arch: &str) -> Option<Release> {
let extension = if os == "windows" { "zip" } else { "tar.gz" };
listing
.as_array()?
.iter()
.filter_map(|release| {
let tag = release["tag_name"].as_str()?;
let version = parse_version(tag)?;
let assets = release["assets"].as_array()?;
let asset = |suffix: &str| {
assets.iter().find_map(|asset| {
let name = asset["name"].as_str()?;
name.ends_with(suffix)
.then(|| asset["browser_download_url"].as_str().map(String::from))
.flatten()
})
};
let archive_url = asset(&format!("-{os}-{arch}.{extension}"))?;
Some(Release {
tag: tag.to_string(),
version,
archive_url,
sums_url: asset("SHA256SUMS"),
})
})
.max_by(|a, b| a.version.cmp(&b.version))
}
pub(super) async fn install(release: Release) -> Result<Version, String> {
use crate::util::http::{DownloadSizeCheck, download_verified};
let dest = binary_path().ok_or_else(|| "the install directory is unavailable".to_string())?;
let file = file_name();
let client = client()?;
let hash = match &release.sums_url {
Some(url) => {
let body = client
.get(url)
.send()
.await
.map_err(|e| {
format!(
"the checksum list could not be fetched: {}",
crate::util::managed_bin::request_reason(&e)
)
})?
.text()
.await
.map_err(|e| {
format!(
"the checksum list could not be read: {}",
crate::util::managed_bin::request_reason(&e)
)
})?;
let asset = release
.archive_url
.rsplit('/')
.next()
.unwrap_or_default()
.to_string();
body.lines()
.filter_map(crate::util::managed_bin::parse_sha256_sidecar)
.find(|(_, name)| name == &asset)
.map(|(hash, _)| hash)
.ok_or_else(|| "the release publishes no checksum for this artifact".to_string())?
}
None => return Err("the release publishes no checksum list".to_string()),
};
let work = tempfile::tempdir().map_err(|e| {
format!(
"the download's temporary directory could not be created ({})",
e.kind()
)
})?;
let archive = work
.path()
.join(release.archive_url.rsplit('/').next().unwrap_or("server"));
download_verified(
client,
&release.archive_url,
&archive,
&hash,
None,
DownloadSizeCheck::None,
|_, _| {},
)
.await
.map_err(|e| {
format!(
"the server release could not be downloaded: {}",
crate::util::managed_bin::download_reason(&e)
)
})?;
let fresh = if archive.extension().and_then(|e| e.to_str()) == Some("zip") {
crate::util::managed_bin::extract_single_file_zip(&archive, work.path(), file)?
} else {
crate::util::managed_bin::extract_single_file_tar_gz(&archive, work.path(), file)?
};
crate::util::managed_bin::set_executable(&fresh)?;
if super::server::reported_version_at(&fresh).await.is_none() {
return Err("the downloaded server binary could not run".to_string());
}
crate::util::managed_bin::place_extracted(&fresh, &dest)?;
tracing::info!(
version = %release.version,
tag = %release.tag,
"telegram-bot-api: installed"
);
Ok(release.version)
}
#[cfg(test)]
mod tests {
use super::*;
fn listing() -> serde_json::Value {
serde_json::json!([
{
"tag_name": "tgapi-v10.1",
"assets": [
{"name": "telegram-bot-api-10.1-linux-x86_64.tar.gz",
"browser_download_url": "https://example.test/10.1/linux"},
{"name": "SHA256SUMS", "browser_download_url": "https://example.test/10.1/sums"}
]
},
{
"tag_name": "tgapi-v10.3",
"assets": [
{"name": "telegram-bot-api-10.3-linux-x86_64.tar.gz",
"browser_download_url": "https://example.test/10.3/linux"},
{"name": "SHA256SUMS", "browser_download_url": "https://example.test/10.3/sums"}
]
},
{
"tag_name": "tgapi-v10.4",
"assets": [
{"name": "telegram-bot-api-10.4-windows-x86_64.zip",
"browser_download_url": "https://example.test/10.4/windows"},
{"name": "SHA256SUMS", "browser_download_url": "https://example.test/10.4/sums"}
]
},
{
"tag_name": "v0.8.0",
"assets": [
{"name": "mahbot-0.8.0-linux-x86_64.tar.gz",
"browser_download_url": "https://example.test/mahbot/linux"}
]
}
])
}
#[test]
fn the_newest_release_for_the_host_wins_and_the_product_channel_is_ignored() {
let release = newest_for(&listing(), "linux", "x86_64").expect("a release");
assert_eq!(release.version, Version::new(10, 3, 0));
assert_eq!(release.archive_url, "https://example.test/10.3/linux");
assert_eq!(
release.sums_url.as_deref(),
Some("https://example.test/10.3/sums")
);
}
#[test]
fn a_platform_without_an_artifact_is_not_served_by_another_one() {
assert!(newest_for(&listing(), "macos", "aarch64").is_none());
assert!(newest_for(&serde_json::json!({}), "linux", "x86_64").is_none());
}
}