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>
1073 lines
38 KiB
Rust
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,
|
|
}
|
|
}))
|
|
}
|