#![allow(dead_code)]
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use serde::de::DeserializeOwned;
use serde::Deserialize;
const DEFAULT_API_BASE: &str = "https://api.telegram.org";
const API_BASE_ENV: &str = "TEAMCTL_TG_API_BASE";
const POLL_INTERVAL: Duration = Duration::from_secs(2);
const BACKOFF_START: Duration = Duration::from_secs(1);
const BACKOFF_MAX: Duration = Duration::from_secs(30);
const LONG_POLL_SECS: u64 = 25;
#[derive(Deserialize)]
struct ApiResponse<T> {
ok: bool,
result: Option<T>,
description: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct User {
pub id: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ManagedBotUpdated {
pub user: User,
pub bot: User,
}
#[derive(Debug, Deserialize)]
struct Update {
update_id: i64,
managed_bot: Option<ManagedBotUpdated>,
}
pub struct ManagedBotClient {
http: reqwest::Client,
base: String,
token: String,
}
impl ManagedBotClient {
pub fn new(manager_token: String) -> Self {
let base = std::env::var(API_BASE_ENV)
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| DEFAULT_API_BASE.to_string());
Self {
http: reqwest::Client::new(),
base,
token: manager_token,
}
}
pub fn creation_link(manager_username: &str, suggested_username: &str) -> String {
format!("https://t.me/newbot/{manager_username}/{suggested_username}")
}
async fn call<T: DeserializeOwned>(
&self,
method: &str,
params: &serde_json::Value,
) -> Result<T> {
let url = format!("{}/bot{}/{}", self.base, self.token, method);
let body =
serde_json::to_vec(params).with_context(|| format!("serialize {method} request"))?;
let resp = self
.http
.post(&url)
.header("content-type", "application/json")
.body(body)
.send()
.await
.with_context(|| format!("{method} request failed"))?;
let status = resp.status();
let text = resp
.text()
.await
.with_context(|| format!("{method} read body"))?;
let envelope: ApiResponse<T> =
serde_json::from_str(&text).with_context(|| format!("{method} decode response"))?;
if !status.is_success() || !envelope.ok {
let desc = envelope.description.unwrap_or_else(|| text.clone());
bail!("{method} failed ({status}): {desc}");
}
envelope
.result
.ok_or_else(|| anyhow!("{method}: ok response carried no result"))
}
pub async fn get_managed_bot_token(&self, user_id: i64) -> Result<String> {
self.call(
"getManagedBotToken",
&serde_json::json!({ "user_id": user_id }),
)
.await
}
pub async fn poll_for_managed_bot(&self) -> Result<ManagedBotUpdated> {
let mut offset: i64 = 0;
let mut backoff = BACKOFF_START;
loop {
match self.poll_once(offset).await {
Ok((updates, next_offset)) => {
backoff = BACKOFF_START;
offset = next_offset;
for u in updates {
if let Some(managed) = u.managed_bot {
return Ok(managed);
}
}
tokio::time::sleep(POLL_INTERVAL).await;
}
Err(e) => {
tracing::warn!(error = %e, "managed-bot poll failed; backing off");
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff);
}
}
}
}
async fn poll_once(&self, offset: i64) -> Result<(Vec<Update>, i64)> {
let updates: Vec<Update> = self
.call(
"getUpdates",
&serde_json::json!({
"offset": offset,
"timeout": LONG_POLL_SECS,
"allowed_updates": ["managed_bot"],
}),
)
.await?;
let next = updates
.iter()
.map(|u| u.update_id + 1)
.max()
.unwrap_or(offset);
Ok((updates, next))
}
}
fn next_backoff(current: Duration) -> Duration {
(current * 2).min(BACKOFF_MAX)
}
pub fn token_from_env(var: &str) -> Result<String> {
let val = std::env::var(var).map_err(|_| anyhow!("env var {var} is not set"))?;
let trimmed = val.trim();
if trimmed.is_empty() {
bail!("env var {var} is empty");
}
Ok(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const TEST_TOKEN: &str = "TEST:TOKEN";
fn client(base: String) -> ManagedBotClient {
ManagedBotClient {
http: reqwest::Client::new(),
base,
token: TEST_TOKEN.to_string(),
}
}
async fn mock_ok(
server: &MockServer,
method_name: &str,
expect_body: serde_json::Value,
result: serde_json::Value,
) {
Mock::given(method("POST"))
.and(path(format!("/bot{TEST_TOKEN}/{method_name}")))
.and(body_json(expect_body))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "ok": true, "result": result })),
)
.mount(server)
.await;
}
async fn mock_err(server: &MockServer, method_name: &str, description: &str) {
Mock::given(method("POST"))
.and(path(format!("/bot{TEST_TOKEN}/{method_name}")))
.respond_with(
ResponseTemplate::new(400)
.set_body_json(serde_json::json!({ "ok": false, "description": description })),
)
.mount(server)
.await;
}
#[tokio::test]
async fn get_managed_bot_token_success() {
let server = MockServer::start().await;
mock_ok(
&server,
"getManagedBotToken",
serde_json::json!({ "user_id": 42 }),
serde_json::json!("123456:CHILD-TOKEN"),
)
.await;
let token = client(server.uri())
.get_managed_bot_token(42)
.await
.expect("token fetched");
assert_eq!(token, "123456:CHILD-TOKEN");
}
#[tokio::test]
async fn get_managed_bot_token_surfaces_api_error() {
let server = MockServer::start().await;
mock_err(&server, "getManagedBotToken", "Bad Request: bot not found").await;
let err = client(server.uri())
.get_managed_bot_token(42)
.await
.expect_err("api error surfaces");
assert!(
err.to_string().contains("bot not found"),
"error should carry the description: {err}"
);
}
#[tokio::test]
async fn poll_returns_first_managed_bot_update() {
let server = MockServer::start().await;
mock_ok(
&server,
"getUpdates",
serde_json::json!({
"offset": 0,
"timeout": 25,
"allowed_updates": ["managed_bot"]
}),
serde_json::json!([{
"update_id": 7,
"managed_bot": {
"user": { "id": 100, "first_name": "Operator" },
"bot": { "id": 999, "username": "child_bot", "first_name": "Child" }
}
}]),
)
.await;
let updated = client(server.uri())
.poll_for_managed_bot()
.await
.expect("managed_bot update");
assert_eq!(updated.bot.id, 999);
assert_eq!(updated.user.id, 100);
}
#[tokio::test]
async fn poll_once_surfaces_api_error() {
let server = MockServer::start().await;
mock_err(&server, "getUpdates", "Unauthorized").await;
let err = client(server.uri())
.poll_once(0)
.await
.expect_err("poll error surfaces");
assert!(err.to_string().contains("Unauthorized"), "{err}");
}
#[test]
fn next_backoff_doubles_then_caps() {
assert_eq!(next_backoff(Duration::from_secs(1)), Duration::from_secs(2));
assert_eq!(
next_backoff(Duration::from_secs(8)),
Duration::from_secs(16)
);
assert_eq!(
next_backoff(Duration::from_secs(16)),
Duration::from_secs(30)
);
assert_eq!(
next_backoff(Duration::from_secs(30)),
Duration::from_secs(30)
);
}
#[test]
fn creation_link_uses_t_me_newbot_form() {
assert_eq!(
ManagedBotClient::creation_link("teamctl_mgr_bot", "acme_sage_bot"),
"https://t.me/newbot/teamctl_mgr_bot/acme_sage_bot"
);
}
#[test]
fn token_from_env_resolves_trimmed() {
let var = "TEAMCTL_TEST_MANAGED_TOKEN_OK";
std::env::set_var(var, " abc:DEF ");
assert_eq!(token_from_env(var).unwrap(), "abc:DEF");
std::env::remove_var(var);
}
#[test]
fn token_from_env_rejects_missing_and_empty() {
let missing = "TEAMCTL_TEST_MANAGED_TOKEN_MISSING";
std::env::remove_var(missing);
assert!(token_from_env(missing).is_err());
let empty = "TEAMCTL_TEST_MANAGED_TOKEN_EMPTY";
std::env::set_var(empty, " ");
assert!(token_from_env(empty).is_err());
std::env::remove_var(empty);
}
}