Files
featherChat/warzone/crates/warzone-server/src/routes/bot.rs
Siavash Sameni c37bd7934c v0.0.39: contacts online, message wrap, tab complete, multipart, OTPK
FC-P2-T6: /contacts shows online status (● online, ○ offline)
FC-P6-T6: Long messages word-wrap into multiple lines with aligned indent
FC-P6-T7: Tab completion for 33 slash commands (4 new tests)
FC-P8-T6: sendDocument accepts both JSON and multipart form data
OTPK: Auto-replenish on TUI startup when supply < 3 (generates 10 new)

135 tests passing (was 127)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:22:42 +04:00

1073 lines
38 KiB
Rust

//! Telegram Bot API compatibility layer.
//!
//! Bots register with a fingerprint and get a token.
//! They use `/bot<token>/getUpdates` and `/bot<token>/sendMessage`
//! to communicate with featherChat users.
//!
//! Supported endpoints (Telegram-compatible):
//! - `POST /bot/register` (featherChat-specific)
//! - `GET /bot/:token/getMe`
//! - `POST /bot/:token/getUpdates`
//! - `POST /bot/:token/sendMessage`
//! - `POST /bot/:token/answerCallbackQuery`
//! - `POST /bot/:token/editMessageText`
//! - `POST /bot/:token/setWebhook`
//! - `POST /bot/:token/deleteWebhook`
//! - `GET /bot/:token/getWebhookInfo`
//! - `POST /bot/:token/sendDocument`
use axum::{
extract::{Path, State},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use base64::Engine;
use crate::errors::AppResult;
use crate::state::AppState;
/// Build the bot API routes (nested under `/v1`).
pub fn routes() -> Router<AppState> {
Router::new()
.route("/bot/list", get(list_system_bots))
.route("/bot/register", post(register_bot))
.route("/bot/:token/getMe", get(get_me))
.route("/bot/:token/getUpdates", post(get_updates))
.route("/bot/:token/sendMessage", post(send_message))
.route("/bot/:token/answerCallbackQuery", post(answer_callback_query))
.route("/bot/:token/editMessageText", post(edit_message_text))
.route("/bot/:token/setWebhook", post(set_webhook))
.route("/bot/:token/deleteWebhook", post(delete_webhook))
.route("/bot/:token/getWebhookInfo", get(get_webhook_info))
.route("/bot/:token/sendDocument", post(send_document_flexible))
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// System bot list (public, no auth needed)
// ---------------------------------------------------------------------------
/// GET /v1/bot/list — returns system bots for welcome screen.
async fn list_system_bots(
State(state): State<AppState>,
) -> Json<serde_json::Value> {
let bots = state.db.tokens.get(b"system:bot_list")
.ok().flatten()
.and_then(|v| serde_json::from_slice::<Vec<serde_json::Value>>(&v).ok())
.unwrap_or_default();
Json(serde_json::json!({ "ok": true, "bots": bots }))
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Validate a bot token against the `tokens` sled tree.
/// Returns the stored bot info JSON if the token is valid.
/// Returns `None` if bots are disabled on this server instance.
fn validate_bot_token(state: &AppState, token: &str) -> Option<serde_json::Value> {
if !state.bots_enabled {
return None;
}
let key = format!("bot:{}", token);
let ivec = state.db.tokens.get(key.as_bytes()).ok()??;
serde_json::from_slice(&ivec).ok()
}
/// Resolve a `chat_id` that may be a string fingerprint, ETH address, or numeric ID.
fn resolve_chat_id(state: &AppState, chat_id: &serde_json::Value) -> Option<String> {
match chat_id {
serde_json::Value::String(s) => {
let clean: String = s
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect::<String>()
.to_lowercase();
if clean.len() >= 16 {
Some(clean)
} else if s.starts_with("0x") {
// ETH address -- resolve
state
.db
.eth_addresses
.get(s.to_lowercase().as_bytes())
.ok()?
.map(|v| String::from_utf8_lossy(&v).to_string())
} else {
Some(s.clone())
}
}
serde_json::Value::Number(n) => {
let num = n.as_i64().unwrap_or(0);
// Look up per-bot numeric ID reverse mapping
let numid_key = format!("numid:{}", num);
if let Some(fp_bytes) = state.db.tokens.get(numid_key.as_bytes()).ok().flatten() {
return Some(String::from_utf8_lossy(&fp_bytes).to_string());
}
// Fallback: scan all keys with global hash (legacy)
for item in state.db.keys.iter().flatten() {
let key_str = String::from_utf8_lossy(&item.0).to_string();
if !key_str.contains(':') && key_str.len() == 32 {
if crate::routes::resolve::fp_to_numeric_id(&key_str) == num {
return Some(key_str);
}
}
}
None
}
_ => None,
}
}
/// Get the next update_id for a bot and atomically increment the counter.
///
/// The counter is stored in the `tokens` tree under `bot_update_id:<bot_fp>`.
fn next_update_id(state: &AppState, bot_fp: &str) -> u64 {
let key = format!("bot_update_id:{}", bot_fp);
let current = state
.db
.tokens
.get(key.as_bytes())
.ok()
.flatten()
.and_then(|v| {
let bytes: [u8; 8] = v.as_ref().try_into().ok()?;
Some(u64::from_be_bytes(bytes))
})
.unwrap_or(1);
let next = current + 1;
let _ = state
.db
.tokens
.insert(key.as_bytes(), &next.to_be_bytes());
current
}
/// Store an update in the bot's persistent queue with an assigned update_id.
///
/// Key format: `bot_queue:<bot_fp>:<update_id_padded>` to ensure lexicographic ordering.
fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value) {
let uid = next_update_id(state, bot_fp);
let queue_key = format!("bot_queue:{}:{:020}", bot_fp, uid);
let mut enriched = update;
enriched["update_id"] = serde_json::json!(uid);
if let Ok(bytes) = serde_json::to_vec(&enriched) {
let _ = state.db.messages.insert(queue_key.as_bytes(), bytes);
}
}
// ---------------------------------------------------------------------------
// Webhook delivery (public -- called from state.rs deliver_or_queue)
// ---------------------------------------------------------------------------
/// Check if a fingerprint belongs to a bot with a webhook, and deliver the message.
///
/// Called from `AppState::deliver_or_queue` after queueing. Returns `true` if
/// the webhook accepted the update (HTTP 2xx), meaning the queued entry can be
/// removed.
pub async fn try_bot_webhook(state: &AppState, to_fp: &str, message: &[u8]) -> bool {
// 1. Check if this fingerprint is a bot
let token_key = format!("bot_fp:{}", to_fp);
let token = match state.db.tokens.get(token_key.as_bytes()) {
Ok(Some(v)) => String::from_utf8_lossy(&v).to_string(),
_ => return false,
};
// 2. Load bot info and check for webhook URL
let bot_info: serde_json::Value =
match state.db.tokens.get(format!("bot:{}", token).as_bytes()) {
Ok(Some(v)) => match serde_json::from_slice(&v) {
Ok(v) => v,
Err(_) => return false,
},
_ => return false,
};
let webhook_url = match bot_info.get("webhook_url").and_then(|v| v.as_str()) {
Some(url) if !url.is_empty() => url.to_string(),
_ => return false,
};
// 3. Build Telegram-style update from the raw message bytes
let update = if let Ok(wire) =
bincode::deserialize::<warzone_protocol::message::WireMessage>(message)
{
wire_message_to_update(state, &wire, message, &token)
} else if let Ok(bot_msg) = serde_json::from_slice::<serde_json::Value>(message) {
bot_json_to_update(state, &bot_msg, &token)
} else {
None
};
let mut update = match update {
Some(u) => u,
None => return false,
};
// Assign a real update_id so the webhook consumer can track ordering
let uid = next_update_id(state, to_fp);
update["update_id"] = serde_json::json!(uid);
// 4. POST to webhook URL with a short timeout
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_default();
match client
.post(&webhook_url)
.header("Content-Type", "application/json")
.json(&update)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
tracing::info!("Webhook delivered to {} for bot {}", webhook_url, to_fp);
true
}
Ok(resp) => {
tracing::warn!(
"Webhook {} returned {} for bot {}",
webhook_url,
resp.status(),
to_fp
);
false
}
Err(e) => {
tracing::warn!("Webhook {} failed for bot {}: {}", webhook_url, to_fp, e);
false
}
}
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct RegisterBotRequest {
name: String,
fingerprint: String,
#[serde(default)]
bundle: Option<Vec<u8>>, // bincode PreKeyBundle for E2E bots
#[serde(default)]
eth_address: Option<String>,
#[serde(default)]
e2e: Option<bool>, // true = E2E bot, false/None = plaintext bot
#[serde(default)]
owner: Option<String>, // fingerprint of the bot creator
#[serde(default)]
botfather_token: Option<String>,
}
/// Register a bot and receive a token.
///
/// `POST /v1/bot/register`
///
/// ```json
/// { "name": "mybot", "fingerprint": "aabbccdd..." }
/// ```
async fn register_bot(
State(state): State<AppState>,
Json(req): Json<RegisterBotRequest>,
) -> AppResult<Json<serde_json::Value>> {
if !state.bots_enabled {
return Ok(Json(serde_json::json!({"ok": false, "description": "Bot API is disabled on this server. Use a server with --enable-bots"})));
}
// Only BotFather can register bots
// Require botfather_token field matching the stored BotFather token
if let Some(ref bf_token) = req.botfather_token {
let botfather_fp = "00000000000000000b0ffa00e000000f";
let bf_key = format!("bot_fp:{}", botfather_fp);
let stored_token = state.db.tokens.get(bf_key.as_bytes())
.ok().flatten()
.map(|v| String::from_utf8_lossy(&v).to_string());
if stored_token.as_deref() != Some(bf_token.as_str()) {
return Ok(Json(serde_json::json!({"ok": false, "description": "invalid BotFather token"})));
}
} else {
return Ok(Json(serde_json::json!({"ok": false, "description": "bot registration requires BotFather authorization. Message @botfather to create a bot."})));
}
let fp = req
.fingerprint
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect::<String>()
.to_lowercase();
let random_bytes: [u8; 16] = rand::random();
let token = format!(
"{}:{}",
&fp[..fp.len().min(16)],
hex::encode(random_bytes),
);
let bot_info = serde_json::json!({
"name": req.name,
"fingerprint": fp,
"token": token,
"owner": req.owner.as_deref().unwrap_or(&fp),
"e2e": req.e2e.unwrap_or(false),
"created_at": chrono::Utc::now().timestamp(),
});
// Store bot info keyed by token.
let key = format!("bot:{}", token);
state
.db
.tokens
.insert(key.as_bytes(), serde_json::to_vec(&bot_info)?.as_slice())?;
// Reverse lookup: fingerprint -> token.
let fp_key = format!("bot_fp:{}", fp);
state
.db
.tokens
.insert(fp_key.as_bytes(), token.as_bytes())?;
// If E2E bot, register pre-key bundle (bot can receive encrypted messages)
if req.e2e.unwrap_or(false) {
if let Some(ref bundle_bytes) = req.bundle {
let _ = state.db.keys.insert(fp.as_bytes(), bundle_bytes.as_slice());
let device_key = format!("device:{}:bot", fp);
let _ = state.db.keys.insert(device_key.as_bytes(), bundle_bytes.as_slice());
tracing::info!("E2E bot: registered pre-key bundle for {}", fp);
}
}
// Store ETH address mapping if provided
if let Some(ref eth) = req.eth_address {
let eth_lower = eth.to_lowercase();
let _ = state.db.eth_addresses.insert(eth_lower.as_bytes(), fp.as_bytes());
let _ = state.db.eth_addresses.insert(format!("rev:{}", fp).as_bytes(), eth_lower.as_bytes());
}
tracing::info!(
"Bot registered: {} ({}) token={}... e2e={}",
req.name,
fp,
&token[..token.len().min(20)],
req.e2e.unwrap_or(false),
);
// Auto-register bot alias (name must end with Bot or _bot)
let bot_alias = if req.name.ends_with("Bot") || req.name.ends_with("_bot") || req.name.ends_with("bot") {
req.name.to_lowercase()
} else {
format!("{}_bot", req.name.to_lowercase())
};
let alias_key = format!("a:{}", bot_alias);
let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes());
let fp_key = format!("fp:{}", fp);
let _ = state.db.aliases.insert(fp_key.as_bytes(), bot_alias.as_bytes());
tracing::info!("Bot alias @{} registered for {}", bot_alias, fp);
Ok(Json(serde_json::json!({
"ok": true,
"result": {
"token": token,
"name": req.name,
"fingerprint": fp,
"alias": format!("@{}", bot_alias),
"e2e": req.e2e.unwrap_or(false),
}
})))
}
/// `GET /bot/:token/getMe` -- returns bot info (Telegram-compatible shape).
async fn get_me(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Json<serde_json::Value> {
match validate_bot_token(&state, &token) {
Some(info) => {
let fp = info["fingerprint"].as_str().unwrap_or("");
Json(serde_json::json!({
"ok": true,
"result": {
"id": crate::routes::resolve::fp_to_numeric_id_for_bot(fp, &token),
"is_bot": true,
"first_name": info["name"],
"username": info["name"],
}
}))
}
None => Json(serde_json::json!({
"ok": false,
"description": "invalid token",
})),
}
}
// ---------------------------------------------------------------------------
// getUpdates — with offset/limit/timeout support
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct GetUpdatesParams {
#[serde(default)]
offset: Option<i64>,
#[serde(default)]
limit: Option<usize>,
#[serde(default)]
timeout: Option<u64>,
}
/// `POST /bot/:token/getUpdates` -- long-poll for messages sent to this bot.
///
/// Migrates raw queue entries (from `queue:<bot_fp>:*`) into the persistent
/// bot update queue (`bot_queue:<bot_fp>:<update_id>`) on each call, then
/// returns updates filtered by `offset` and capped by `limit`.
///
/// When `offset` is provided, all updates with `update_id < offset` are
/// acknowledged (deleted), matching Telegram Bot API semantics.
async fn get_updates(
State(state): State<AppState>,
Path(token): Path<String>,
Json(params): Json<GetUpdatesParams>,
) -> Json<serde_json::Value> {
let bot_info = match validate_bot_token(&state, &token) {
Some(info) => info,
None => {
return Json(serde_json::json!({
"ok": false,
"description": "invalid token",
}))
}
};
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("");
let limit = params.limit.unwrap_or(100).min(100);
let timeout = params.timeout.unwrap_or(0);
// Step 1: Migrate raw queue entries into the persistent bot_queue.
migrate_raw_queue(&state, bot_fp, &token);
// Step 2: If offset is provided, delete all acknowledged updates (update_id < offset).
if let Some(offset) = params.offset {
let prefix = format!("bot_queue:{}:", bot_fp);
let mut to_delete = Vec::new();
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
let (key, value) = match item {
Ok(pair) => pair,
Err(_) => continue,
};
if let Ok(update) = serde_json::from_slice::<serde_json::Value>(&value) {
let uid = update["update_id"].as_i64().unwrap_or(0);
if uid < offset {
to_delete.push(key);
} else {
// Keys are ordered, so once we pass offset we can stop scanning
// for deletions.
break;
}
}
}
for key in &to_delete {
let _ = state.db.messages.remove(key);
}
}
// Step 3: Collect remaining updates up to `limit`.
let updates = collect_updates(&state, bot_fp, limit);
// Step 4: Long-poll if empty. Minimum 1s delay to prevent tight-loop abuse.
if updates.is_empty() {
let timeout = if timeout == 0 { 1 } else { timeout }; // force min 1s
let wait = std::cmp::min(timeout, 50);
// Poll in 1-second intervals so new messages are picked up promptly.
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait);
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// Check for newly arrived raw messages.
migrate_raw_queue(&state, bot_fp, &token);
let polled = collect_updates(&state, bot_fp, limit);
if !polled.is_empty() {
return Json(serde_json::json!({
"ok": true,
"result": polled,
}));
}
if tokio::time::Instant::now() >= deadline {
break;
}
}
}
Json(serde_json::json!({
"ok": true,
"result": updates,
}))
}
/// Migrate raw `queue:<bot_fp>:*` entries into `bot_queue:<bot_fp>:<update_id>`.
///
/// Each raw entry is converted into a Telegram-style Update JSON object, assigned
/// a persistent update_id, and stored. The original raw key is deleted.
fn migrate_raw_queue(state: &AppState, bot_fp: &str, bot_token: &str) {
let prefix = format!("queue:{}", bot_fp);
let mut keys_to_delete = Vec::new();
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
let (key, value) = match item {
Ok(pair) => pair,
Err(_) => continue,
};
let update = if let Ok(wire) =
bincode::deserialize::<warzone_protocol::message::WireMessage>(&value)
{
wire_message_to_update(state, &wire, &value, bot_token)
} else if let Ok(bot_msg) = serde_json::from_slice::<serde_json::Value>(&value) {
bot_json_to_update(state, &bot_msg, bot_token)
} else {
None
};
if let Some(upd) = update {
enqueue_bot_update(state, bot_fp, upd);
}
keys_to_delete.push(key);
}
for key in &keys_to_delete {
let _ = state.db.messages.remove(key);
}
}
/// Store a per-bot numeric ID → fingerprint reverse mapping.
fn store_numid_mapping(state: &AppState, numeric_id: i64, fingerprint: &str) {
let key = format!("numid:{}", numeric_id);
let _ = state.db.tokens.insert(key.as_bytes(), fingerprint.as_bytes());
}
/// Convert a `WireMessage` into a Telegram-style update JSON (without update_id).
fn wire_message_to_update(
state: &AppState,
wire: &warzone_protocol::message::WireMessage,
raw_bytes: &[u8],
bot_token: &str,
) -> Option<serde_json::Value> {
match wire {
warzone_protocol::message::WireMessage::Message {
id,
sender_fingerprint,
..
} => {
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes);
let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint);
Some(serde_json::json!({
"message": {
"message_id": id,
"from": {
"id": numeric,
"is_bot": false,
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
},
"chat": {
"id": numeric,
"type": "private",
},
"date": chrono::Utc::now().timestamp(),
"text": null,
"raw_encrypted": raw_b64,
}
}))
}
warzone_protocol::message::WireMessage::KeyExchange {
id,
sender_fingerprint,
..
} => {
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes);
let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint);
Some(serde_json::json!({
"message": {
"message_id": id,
"from": {
"id": numeric,
"is_bot": false,
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
},
"chat": {
"id": numeric,
"type": "private",
},
"date": chrono::Utc::now().timestamp(),
"text": null,
"raw_encrypted": raw_b64,
}
}))
}
warzone_protocol::message::WireMessage::CallSignal {
id,
sender_fingerprint,
signal_type,
payload,
..
} => {
let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint);
Some(serde_json::json!({
"message": {
"message_id": id,
"from": {
"id": numeric,
"is_bot": false,
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
},
"chat": {
"id": numeric,
"type": "private",
},
"date": chrono::Utc::now().timestamp(),
"text": format!("/call_{:?}", signal_type),
"call_signal": {
"type": format!("{:?}", signal_type),
"payload": payload,
},
}
}))
}
warzone_protocol::message::WireMessage::FileHeader {
id,
sender_fingerprint,
filename,
file_size,
..
} => {
let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint);
Some(serde_json::json!({
"message": {
"message_id": id,
"from": {
"id": numeric,
"is_bot": false,
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
},
"chat": {
"id": numeric,
"type": "private",
},
"date": chrono::Utc::now().timestamp(),
"document": {
"file_name": filename,
"file_size": file_size,
},
}
}))
}
// Skip receipts and other variants.
warzone_protocol::message::WireMessage::Receipt { .. } => None,
_ => None,
}
}
/// Convert a plaintext bot JSON message into a Telegram-style update (without update_id).
fn bot_json_to_update(state: &AppState, bot_msg: &serde_json::Value, bot_token: &str) -> Option<serde_json::Value> {
let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?;
match msg_type {
"bot_message" => {
let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or("");
let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); store_numid_mapping(state, numeric, from_fp);
Some(serde_json::json!({
"message": {
"message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"from": {
"id": numeric,
"is_bot": true,
},
"chat": {
"id": numeric,
"type": "private",
},
"date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0),
"text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""),
}
}))
}
"callback_query" => {
let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or("");
let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); store_numid_mapping(state, numeric, from_fp);
Some(serde_json::json!({
"callback_query": {
"id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"from": {
"id": numeric,
"is_bot": false,
},
"data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""),
"message": bot_msg.get("message"),
}
}))
}
_ => None,
}
}
/// Collect up to `limit` updates from `bot_queue:<bot_fp>:*`, preserving order.
fn collect_updates(state: &AppState, bot_fp: &str, limit: usize) -> Vec<serde_json::Value> {
let prefix = format!("bot_queue:{}:", bot_fp);
let mut updates = Vec::new();
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
let (_key, value) = match item {
Ok(pair) => pair,
Err(_) => continue,
};
if let Ok(update) = serde_json::from_slice::<serde_json::Value>(&value) {
updates.push(update);
if updates.len() >= limit {
break;
}
}
}
updates
}
// ---------------------------------------------------------------------------
// sendMessage — enhanced with parse_mode, reply_to, reply_markup
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct SendMessageRequest {
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
text: String,
#[serde(default)]
parse_mode: Option<String>,
#[serde(default)]
reply_to_message_id: Option<String>,
#[serde(default)]
reply_markup: Option<serde_json::Value>,
}
/// `POST /bot/:token/sendMessage` -- send a plaintext message to a user.
///
/// In v1, bot messages are **not** E2E-encrypted; they are delivered as
/// plain JSON envelopes through the normal routing layer.
async fn send_message(
State(state): State<AppState>,
Path(token): Path<String>,
Json(req): Json<SendMessageRequest>,
) -> Json<serde_json::Value> {
let bot_info = match validate_bot_token(&state, &token) {
Some(info) => info,
None => {
return Json(serde_json::json!({
"ok": false,
"description": "invalid token",
}))
}
};
let to_fp = match resolve_chat_id(&state, &req.chat_id) {
Some(fp) => fp,
None => {
return Json(serde_json::json!({"ok": false, "description": "chat_id not found"}))
}
};
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
let msg_id = uuid::Uuid::new_v4().to_string();
let bot_msg = serde_json::json!({
"type": "bot_message",
"id": msg_id,
"from": bot_fp,
"from_name": bot_info["name"],
"text": req.text,
"parse_mode": req.parse_mode,
"reply_to_message_id": req.reply_to_message_id,
"reply_markup": req.reply_markup,
"timestamp": chrono::Utc::now().timestamp(),
});
let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default();
let delivered = state.deliver_or_queue(&to_fp, &msg_bytes).await;
Json(serde_json::json!({
"ok": true,
"result": {
"message_id": msg_id,
"chat": { "id": to_fp, "type": "private" },
"text": req.text,
"date": chrono::Utc::now().timestamp(),
"delivered": delivered,
}
}))
}
// ---------------------------------------------------------------------------
// answerCallbackQuery
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct AnswerCallbackRequest {
callback_query_id: String,
#[serde(default)]
text: Option<String>,
#[serde(default)]
show_alert: Option<bool>,
}
/// `POST /bot/:token/answerCallbackQuery` -- acknowledge a callback query.
///
/// In v1 this is a no-op acknowledgement; no popup is delivered to the client.
async fn answer_callback_query(
State(state): State<AppState>,
Path(token): Path<String>,
Json(req): Json<AnswerCallbackRequest>,
) -> Json<serde_json::Value> {
if validate_bot_token(&state, &token).is_none() {
return Json(serde_json::json!({"ok": false, "description": "invalid token"}));
}
tracing::debug!(
"answerCallbackQuery id={} text={:?} alert={:?}",
req.callback_query_id,
req.text,
req.show_alert,
);
Json(serde_json::json!({"ok": true, "result": true}))
}
// ---------------------------------------------------------------------------
// editMessageText
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct EditMessageRequest {
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
message_id: String,
text: String,
#[serde(default)]
reply_markup: Option<serde_json::Value>,
}
/// `POST /bot/:token/editMessageText` -- edit a previously sent message.
async fn edit_message_text(
State(state): State<AppState>,
Path(token): Path<String>,
Json(req): Json<EditMessageRequest>,
) -> Json<serde_json::Value> {
let bot_info = match validate_bot_token(&state, &token) {
Some(i) => i,
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
};
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
let to_fp = match resolve_chat_id(&state, &req.chat_id) {
Some(fp) => fp,
None => {
return Json(serde_json::json!({"ok": false, "description": "chat_id not found"}))
}
};
let edit_msg = serde_json::json!({
"type": "bot_edit",
"id": req.message_id,
"from": bot_fp,
"text": req.text,
"reply_markup": req.reply_markup,
"timestamp": chrono::Utc::now().timestamp(),
});
let msg_bytes = serde_json::to_vec(&edit_msg).unwrap_or_default();
state.deliver_or_queue(&to_fp, &msg_bytes).await;
Json(serde_json::json!({
"ok": true,
"result": {
"message_id": req.message_id,
"chat": {"id": to_fp},
"text": req.text,
"date": chrono::Utc::now().timestamp(),
}
}))
}
// ---------------------------------------------------------------------------
// setWebhook / deleteWebhook / getWebhookInfo
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct SetWebhookRequest {
url: String,
}
/// `POST /bot/:token/setWebhook` -- register a webhook URL for push delivery.
async fn set_webhook(
State(state): State<AppState>,
Path(token): Path<String>,
Json(req): Json<SetWebhookRequest>,
) -> Json<serde_json::Value> {
let mut bot_info = match validate_bot_token(&state, &token) {
Some(i) => i,
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
};
bot_info["webhook_url"] = serde_json::json!(req.url);
let key = format!("bot:{}", token);
let _ = state
.db
.tokens
.insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default());
tracing::info!("Bot webhook set: {}", req.url);
Json(serde_json::json!({"ok": true, "result": true, "description": "Webhook was set"}))
}
/// `POST /bot/:token/deleteWebhook` -- remove a previously set webhook.
async fn delete_webhook(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Json<serde_json::Value> {
let mut bot_info = match validate_bot_token(&state, &token) {
Some(i) => i,
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
};
bot_info.as_object_mut().map(|o| o.remove("webhook_url"));
let key = format!("bot:{}", token);
let _ = state
.db
.tokens
.insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default());
Json(serde_json::json!({"ok": true, "result": true, "description": "Webhook was deleted"}))
}
/// `GET /bot/:token/getWebhookInfo` -- return current webhook configuration.
async fn get_webhook_info(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Json<serde_json::Value> {
match validate_bot_token(&state, &token) {
Some(info) => {
let url = info
.get("webhook_url")
.and_then(|v| v.as_str())
.unwrap_or("");
Json(serde_json::json!({
"ok": true,
"result": {
"url": url,
"has_custom_certificate": false,
"pending_update_count": 0,
}
}))
}
None => Json(serde_json::json!({"ok": false, "description": "invalid token"})),
}
}
// ---------------------------------------------------------------------------
// sendDocument — accepts both JSON and multipart/form-data
// ---------------------------------------------------------------------------
/// `POST /bot/:token/sendDocument` -- send a document reference to a user.
///
/// Accepts both `application/json` and `multipart/form-data` content types
/// so Telegram bot libraries that upload files via multipart work out of the box.
async fn send_document_flexible(
State(state): State<AppState>,
Path(token): Path<String>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Json<serde_json::Value> {
let bot_info = match validate_bot_token(&state, &token) {
Some(i) => i,
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
};
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
let bot_name = bot_info["name"].as_str().unwrap_or("bot");
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let (chat_id_val, document, caption) = if content_type.contains("multipart") {
// Parse multipart fields from raw bytes (simplified text-field extraction).
let body_str = String::from_utf8_lossy(&body);
let mut chat_id = String::new();
let mut doc = String::new();
let mut cap = String::new();
// Split on boundary markers (lines starting with --)
for part in body_str.split("------") {
if part.contains("name=\"chat_id\"") {
if let Some(val) = part.split("\r\n\r\n").nth(1) {
chat_id = val.trim().to_string();
}
}
if part.contains("name=\"document\"") {
if let Some(val) = part.split("\r\n\r\n").nth(1) {
doc = val.trim().to_string();
}
}
if part.contains("name=\"caption\"") {
if let Some(val) = part.split("\r\n\r\n").nth(1) {
cap = val.trim().to_string();
}
}
}
(
serde_json::Value::String(chat_id),
doc,
if cap.is_empty() { None } else { Some(cap) },
)
} else {
// JSON body
match serde_json::from_slice::<serde_json::Value>(&body) {
Ok(json) => {
let chat_id = json
.get("chat_id")
.cloned()
.unwrap_or(serde_json::Value::String(String::new()));
let doc = json
.get("document")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let cap = json
.get("caption")
.and_then(|v| v.as_str())
.map(String::from);
(chat_id, doc, cap)
}
Err(e) => {
return Json(
serde_json::json!({"ok": false, "description": format!("invalid body: {}", e)}),
)
}
}
};
let to_fp = match resolve_chat_id(&state, &chat_id_val) {
Some(fp) => fp,
None => {
return Json(serde_json::json!({"ok": false, "description": "invalid chat_id"}))
}
};
let msg_id = uuid::Uuid::new_v4().to_string();
let doc_msg = serde_json::json!({
"type": "bot_document",
"id": msg_id,
"from": bot_fp,
"from_name": bot_name,
"document": document,
"caption": caption,
"timestamp": chrono::Utc::now().timestamp(),
});
let msg_bytes = serde_json::to_vec(&doc_msg).unwrap_or_default();
let delivered = state.deliver_or_queue(&to_fp, &msg_bytes).await;
Json(serde_json::json!({
"ok": true,
"result": {
"message_id": msg_id,
"chat": {"id": to_fp},
"document": {"file_name": document},
"caption": caption,
"delivered": delivered,
}
}))
}