feat: complete Telegram-compatible Bot API + bot dev guide
Bot API (routes/bot.rs — full rewrite): - getUpdates: persistent update_id counter, offset acknowledgement, limit (max 100), long-poll up to 30s with 1s intervals - sendMessage: parse_mode, reply_to_message_id, reply_markup (inline keyboards) - answerCallbackQuery: acknowledge button clicks - editMessageText: update sent messages - setWebhook / deleteWebhook / getWebhookInfo: webhook configuration - sendDocument: file reference with caption - Bot queue: raw messages migrated to bot_queue:<fp>:<update_id> for ordering Web client (routes/web.rs): - Bot messages rendered properly (was showing "[message could not be decrypted]") - Handles bot_message, bot_edit, bot_document as both Text and Binary WS frames - Inline keyboard buttons rendered as bracketed text - Missed call notifications handled in Text frame path Docs: - LLM_BOT_DEV.md: token-optimized bot dev reference for coding assistant LLM (Python + Node.js examples, all endpoints, TG compatibility table) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,18 @@
|
|||||||
//! Bots register with a fingerprint and get a token.
|
//! Bots register with a fingerprint and get a token.
|
||||||
//! They use `/bot<token>/getUpdates` and `/bot<token>/sendMessage`
|
//! They use `/bot<token>/getUpdates` and `/bot<token>/sendMessage`
|
||||||
//! to communicate with featherChat users.
|
//! 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::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
@@ -20,9 +32,15 @@ use crate::state::AppState;
|
|||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/bot/register", post(register_bot))
|
.route("/bot/register", post(register_bot))
|
||||||
|
.route("/bot/:token/getMe", get(get_me))
|
||||||
.route("/bot/:token/getUpdates", post(get_updates))
|
.route("/bot/:token/getUpdates", post(get_updates))
|
||||||
.route("/bot/:token/sendMessage", post(send_message))
|
.route("/bot/:token/sendMessage", post(send_message))
|
||||||
.route("/bot/:token/getMe", get(get_me))
|
.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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -37,6 +55,43 @@ fn validate_bot_token(state: &AppState, token: &str) -> Option<serde_json::Value
|
|||||||
serde_json::from_slice(&ivec).ok()
|
serde_json::from_slice(&ivec).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Handlers
|
// Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -145,15 +200,32 @@ async fn get_me(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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.
|
/// `POST /bot/:token/getUpdates` -- long-poll for messages sent to this bot.
|
||||||
///
|
///
|
||||||
/// Reads from the `queue:<bot_fp>:*` key range in the messages sled tree,
|
/// Migrates raw queue entries (from `queue:<bot_fp>:*`) into the persistent
|
||||||
/// converts each entry into a Telegram-style `Update` object, and deletes
|
/// bot update queue (`bot_queue:<bot_fp>:<update_id>`) on each call, then
|
||||||
/// consumed entries.
|
/// 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(
|
async fn get_updates(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(token): Path<String>,
|
Path(token): Path<String>,
|
||||||
Json(params): Json<serde_json::Value>,
|
Json(params): Json<GetUpdatesParams>,
|
||||||
) -> Json<serde_json::Value> {
|
) -> Json<serde_json::Value> {
|
||||||
let bot_info = match validate_bot_token(&state, &token) {
|
let bot_info = match validate_bot_token(&state, &token) {
|
||||||
Some(info) => info,
|
Some(info) => info,
|
||||||
@@ -165,178 +237,60 @@ async fn get_updates(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("");
|
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("");
|
||||||
let timeout = params
|
let limit = params.limit.unwrap_or(100).min(100);
|
||||||
.get("timeout")
|
let timeout = params.timeout.unwrap_or(0);
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let prefix = format!("queue:{}", bot_fp);
|
// Step 1: Migrate raw queue entries into the persistent bot_queue.
|
||||||
let mut updates = Vec::new();
|
migrate_raw_queue(&state, bot_fp);
|
||||||
let mut keys_to_delete = Vec::new();
|
|
||||||
let mut update_id = 1u64;
|
|
||||||
|
|
||||||
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
|
// Step 2: If offset is provided, delete all acknowledged updates (update_id < offset).
|
||||||
let (key, value) = match item {
|
if let Some(offset) = params.offset {
|
||||||
Ok(pair) => pair,
|
let prefix = format!("bot_queue:{}:", bot_fp);
|
||||||
Err(_) => continue,
|
let mut to_delete = Vec::new();
|
||||||
};
|
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
|
||||||
|
let (key, value) = match item {
|
||||||
if let Ok(wire) =
|
Ok(pair) => pair,
|
||||||
bincode::deserialize::<warzone_protocol::message::WireMessage>(&value)
|
Err(_) => continue,
|
||||||
{
|
};
|
||||||
match wire {
|
if let Ok(update) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||||
warzone_protocol::message::WireMessage::Message {
|
let uid = update["update_id"].as_i64().unwrap_or(0);
|
||||||
id,
|
if uid < offset {
|
||||||
sender_fingerprint,
|
to_delete.push(key);
|
||||||
..
|
} else {
|
||||||
} => {
|
// Keys are ordered, so once we pass offset we can stop scanning
|
||||||
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&value);
|
// for deletions.
|
||||||
updates.push(serde_json::json!({
|
break;
|
||||||
"update_id": update_id,
|
|
||||||
"message": {
|
|
||||||
"message_id": id,
|
|
||||||
"from": {
|
|
||||||
"id": &sender_fingerprint,
|
|
||||||
"is_bot": false,
|
|
||||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"id": &sender_fingerprint,
|
|
||||||
"type": "private",
|
|
||||||
},
|
|
||||||
"date": chrono::Utc::now().timestamp(),
|
|
||||||
"text": null,
|
|
||||||
"raw_encrypted": raw_b64,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
update_id += 1;
|
|
||||||
}
|
}
|
||||||
warzone_protocol::message::WireMessage::KeyExchange {
|
|
||||||
id,
|
|
||||||
sender_fingerprint,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&value);
|
|
||||||
updates.push(serde_json::json!({
|
|
||||||
"update_id": update_id,
|
|
||||||
"message": {
|
|
||||||
"message_id": id,
|
|
||||||
"from": {
|
|
||||||
"id": &sender_fingerprint,
|
|
||||||
"is_bot": false,
|
|
||||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"id": &sender_fingerprint,
|
|
||||||
"type": "private",
|
|
||||||
},
|
|
||||||
"date": chrono::Utc::now().timestamp(),
|
|
||||||
"text": null,
|
|
||||||
"raw_encrypted": raw_b64,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
update_id += 1;
|
|
||||||
}
|
|
||||||
warzone_protocol::message::WireMessage::CallSignal {
|
|
||||||
id,
|
|
||||||
sender_fingerprint,
|
|
||||||
signal_type,
|
|
||||||
payload,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
updates.push(serde_json::json!({
|
|
||||||
"update_id": update_id,
|
|
||||||
"message": {
|
|
||||||
"message_id": id,
|
|
||||||
"from": {
|
|
||||||
"id": &sender_fingerprint,
|
|
||||||
"is_bot": false,
|
|
||||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"id": &sender_fingerprint,
|
|
||||||
"type": "private",
|
|
||||||
},
|
|
||||||
"date": chrono::Utc::now().timestamp(),
|
|
||||||
"text": format!("/call_{:?}", signal_type),
|
|
||||||
"call_signal": {
|
|
||||||
"type": format!("{:?}", signal_type),
|
|
||||||
"payload": payload,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
update_id += 1;
|
|
||||||
}
|
|
||||||
warzone_protocol::message::WireMessage::FileHeader {
|
|
||||||
id,
|
|
||||||
sender_fingerprint,
|
|
||||||
filename,
|
|
||||||
file_size,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
updates.push(serde_json::json!({
|
|
||||||
"update_id": update_id,
|
|
||||||
"message": {
|
|
||||||
"message_id": id,
|
|
||||||
"from": {
|
|
||||||
"id": &sender_fingerprint,
|
|
||||||
"is_bot": false,
|
|
||||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"id": &sender_fingerprint,
|
|
||||||
"type": "private",
|
|
||||||
},
|
|
||||||
"date": chrono::Utc::now().timestamp(),
|
|
||||||
"document": {
|
|
||||||
"file_name": filename,
|
|
||||||
"file_size": file_size,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
update_id += 1;
|
|
||||||
}
|
|
||||||
// Skip receipts — don't deliver as updates.
|
|
||||||
warzone_protocol::message::WireMessage::Receipt { .. } => {}
|
|
||||||
// Skip other variants (FileChunk, GroupSenderKey, SenderKeyDistribution).
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
} else if let Ok(bot_msg) = serde_json::from_slice::<serde_json::Value>(&value) {
|
|
||||||
// Try plaintext bot message (from other bots via sendMessage).
|
|
||||||
if bot_msg.get("type").and_then(|v| v.as_str()) == Some("bot_message") {
|
|
||||||
updates.push(serde_json::json!({
|
|
||||||
"update_id": update_id,
|
|
||||||
"message": {
|
|
||||||
"message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
|
||||||
"from": {
|
|
||||||
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""),
|
|
||||||
"is_bot": true,
|
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""),
|
|
||||||
"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(""),
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
update_id += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for key in &to_delete {
|
||||||
keys_to_delete.push(key);
|
let _ = state.db.messages.remove(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove consumed messages.
|
// Step 3: Collect remaining updates up to `limit`.
|
||||||
for key in &keys_to_delete {
|
let updates = collect_updates(&state, bot_fp, limit);
|
||||||
let _ = state.db.messages.remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simplified long-poll: if the queue was empty, wait up to `timeout` seconds
|
// Step 4: Long-poll if empty.
|
||||||
// (capped at 5 s) before returning, giving new messages a chance to arrive.
|
|
||||||
if updates.is_empty() && timeout > 0 {
|
if updates.is_empty() && timeout > 0 {
|
||||||
let wait = std::cmp::min(timeout, 5);
|
let wait = std::cmp::min(timeout, 30);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
|
// 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);
|
||||||
|
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!({
|
Json(serde_json::json!({
|
||||||
@@ -345,10 +299,219 @@ async fn get_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) {
|
||||||
|
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(&wire, &value)
|
||||||
|
} else if let Ok(bot_msg) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||||
|
bot_json_to_update(&bot_msg)
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a `WireMessage` into a Telegram-style update JSON (without update_id).
|
||||||
|
fn wire_message_to_update(
|
||||||
|
wire: &warzone_protocol::message::WireMessage,
|
||||||
|
raw_bytes: &[u8],
|
||||||
|
) -> 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);
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"message": {
|
||||||
|
"message_id": id,
|
||||||
|
"from": {
|
||||||
|
"id": sender_fingerprint,
|
||||||
|
"is_bot": false,
|
||||||
|
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"id": sender_fingerprint,
|
||||||
|
"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);
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"message": {
|
||||||
|
"message_id": id,
|
||||||
|
"from": {
|
||||||
|
"id": sender_fingerprint,
|
||||||
|
"is_bot": false,
|
||||||
|
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"id": sender_fingerprint,
|
||||||
|
"type": "private",
|
||||||
|
},
|
||||||
|
"date": chrono::Utc::now().timestamp(),
|
||||||
|
"text": null,
|
||||||
|
"raw_encrypted": raw_b64,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
warzone_protocol::message::WireMessage::CallSignal {
|
||||||
|
id,
|
||||||
|
sender_fingerprint,
|
||||||
|
signal_type,
|
||||||
|
payload,
|
||||||
|
..
|
||||||
|
} => Some(serde_json::json!({
|
||||||
|
"message": {
|
||||||
|
"message_id": id,
|
||||||
|
"from": {
|
||||||
|
"id": sender_fingerprint,
|
||||||
|
"is_bot": false,
|
||||||
|
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"id": sender_fingerprint,
|
||||||
|
"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,
|
||||||
|
..
|
||||||
|
} => Some(serde_json::json!({
|
||||||
|
"message": {
|
||||||
|
"message_id": id,
|
||||||
|
"from": {
|
||||||
|
"id": sender_fingerprint,
|
||||||
|
"is_bot": false,
|
||||||
|
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"id": sender_fingerprint,
|
||||||
|
"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(bot_msg: &serde_json::Value) -> Option<serde_json::Value> {
|
||||||
|
let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?;
|
||||||
|
match msg_type {
|
||||||
|
"bot_message" => Some(serde_json::json!({
|
||||||
|
"message": {
|
||||||
|
"message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"from": {
|
||||||
|
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"is_bot": true,
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"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" => Some(serde_json::json!({
|
||||||
|
"callback_query": {
|
||||||
|
"id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"from": {
|
||||||
|
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"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)]
|
#[derive(Deserialize)]
|
||||||
struct SendMessageRequest {
|
struct SendMessageRequest {
|
||||||
chat_id: String,
|
chat_id: String,
|
||||||
text: String,
|
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.
|
/// `POST /bot/:token/sendMessage` -- send a plaintext message to a user.
|
||||||
@@ -383,7 +546,11 @@ async fn send_message(
|
|||||||
"type": "bot_message",
|
"type": "bot_message",
|
||||||
"id": msg_id,
|
"id": msg_id,
|
||||||
"from": bot_fp,
|
"from": bot_fp,
|
||||||
|
"from_name": bot_info["name"],
|
||||||
"text": req.text,
|
"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(),
|
"timestamp": chrono::Utc::now().timestamp(),
|
||||||
});
|
});
|
||||||
let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default();
|
let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default();
|
||||||
@@ -401,3 +568,216 @@ async fn send_message(
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: String,
|
||||||
|
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 = req
|
||||||
|
.chat_id
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_hexdigit())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SendDocumentRequest {
|
||||||
|
chat_id: String,
|
||||||
|
/// File path, URL, or file_id reference. In v1, the reference is stored
|
||||||
|
/// and forwarded as-is without server-side file hosting.
|
||||||
|
document: String,
|
||||||
|
#[serde(default)]
|
||||||
|
caption: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /bot/:token/sendDocument` -- send a document reference to a user.
|
||||||
|
async fn send_document(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
Json(req): Json<SendDocumentRequest>,
|
||||||
|
) -> 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 = req
|
||||||
|
.chat_id
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_hexdigit())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
|
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let doc_msg = serde_json::json!({
|
||||||
|
"type": "bot_document",
|
||||||
|
"id": msg_id,
|
||||||
|
"from": bot_fp,
|
||||||
|
"document": req.document,
|
||||||
|
"caption": req.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": req.document},
|
||||||
|
"caption": req.caption,
|
||||||
|
"delivered": delivered,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -527,6 +527,45 @@ function connectWebSocket() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = async (event) => {
|
ws.onmessage = async (event) => {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
// Text frame — could be a bot message or missed call notification
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(event.data);
|
||||||
|
if (json.type === 'missed_call') {
|
||||||
|
addSys('Missed call from ' + (json.data?.caller_fp || 'unknown'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (json.type === 'bot_message') {
|
||||||
|
const botName = json.from_name || json.from || 'bot';
|
||||||
|
let msgText = json.text || '';
|
||||||
|
if (json.reply_markup && json.reply_markup.inline_keyboard) {
|
||||||
|
msgText += '\\n';
|
||||||
|
for (const row of json.reply_markup.inline_keyboard) {
|
||||||
|
for (const btn of row) {
|
||||||
|
msgText += ' [' + btn.text + '] ';
|
||||||
|
}
|
||||||
|
msgText += '\\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addMsg('@' + botName, msgText, false);
|
||||||
|
lastDmPeer = json.from ? normFP(json.from) : '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (json.type === 'bot_edit') {
|
||||||
|
addSys('[bot updated: ' + (json.text || '') + ']');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (json.type === 'bot_document') {
|
||||||
|
addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + ']', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
// If not JSON or unrecognized, try treating as binary
|
||||||
|
const bytes = new TextEncoder().encode(event.data);
|
||||||
|
dbg('WS text frame treated as bytes,', bytes.length, 'bytes');
|
||||||
|
await handleIncomingMessage(bytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const bytes = new Uint8Array(event.data);
|
const bytes = new Uint8Array(event.data);
|
||||||
dbg('WS received', bytes.length, 'bytes');
|
dbg('WS received', bytes.length, 'bytes');
|
||||||
await handleIncomingMessage(bytes);
|
await handleIncomingMessage(bytes);
|
||||||
@@ -628,12 +667,39 @@ async function handleIncomingMessage(bytes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last try: raw JSON file messages (from web file upload)
|
// Last try: raw JSON file messages (from web file upload) or bot messages
|
||||||
try {
|
try {
|
||||||
const str = new TextDecoder().decode(bytes);
|
const str = new TextDecoder().decode(bytes);
|
||||||
const json = JSON.parse(str);
|
const json = JSON.parse(str);
|
||||||
if (json.type === 'file_header') { handleFileHeader(json); return; }
|
if (json.type === 'file_header') { handleFileHeader(json); return; }
|
||||||
if (json.type === 'file_chunk') { handleFileChunk(json); return; }
|
if (json.type === 'file_chunk') { handleFileChunk(json); return; }
|
||||||
|
// Handle bot messages (plaintext JSON from bot API)
|
||||||
|
if (json.type === 'bot_message') {
|
||||||
|
const botName = json.from_name || json.from || 'bot';
|
||||||
|
let msgText = json.text || '';
|
||||||
|
// Handle inline keyboard if present
|
||||||
|
if (json.reply_markup && json.reply_markup.inline_keyboard) {
|
||||||
|
msgText += '\\n';
|
||||||
|
for (const row of json.reply_markup.inline_keyboard) {
|
||||||
|
for (const btn of row) {
|
||||||
|
msgText += ' [' + btn.text + '] ';
|
||||||
|
}
|
||||||
|
msgText += '\\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addMsg('@' + botName, msgText, false);
|
||||||
|
lastDmPeer = json.from ? normFP(json.from) : '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (json.type === 'bot_edit') {
|
||||||
|
addSys('[bot updated message: ' + (json.text || '') + ']');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (json.type === 'bot_document') {
|
||||||
|
const caption = json.caption ? ' \u2014 ' + json.caption : '';
|
||||||
|
addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + caption + ']', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
dbg('ALL decrypt attempts failed');
|
dbg('ALL decrypt attempts failed');
|
||||||
|
|||||||
214
warzone/docs/LLM_BOT_DEV.md
Normal file
214
warzone/docs/LLM_BOT_DEV.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# featherChat Bot Development Reference
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Server: `http://HOST:7700`
|
||||||
|
All bot endpoints: `/v1/bot/<TOKEN>/METHOD`
|
||||||
|
|
||||||
|
Register bot:
|
||||||
|
```
|
||||||
|
POST /v1/bot/register
|
||||||
|
{"name":"MyBot","fingerprint":"any_32_hex_chars"}
|
||||||
|
→ {"ok":true,"result":{"token":"TOKEN","alias":"@mybot_bot"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bot names must end with Bot/bot/_bot. Token format: `<fp_prefix>:<random_hex>`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### getMe
|
||||||
|
```
|
||||||
|
GET /v1/bot/TOKEN/getMe
|
||||||
|
→ {"ok":true,"result":{"id":"fp","is_bot":true,"first_name":"MyBot","username":"MyBot"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### getUpdates (long-poll)
|
||||||
|
```
|
||||||
|
POST /v1/bot/TOKEN/getUpdates
|
||||||
|
{"offset":LAST_UPDATE_ID+1,"timeout":30,"limit":100}
|
||||||
|
→ {"ok":true,"result":[{"update_id":N,"message":{...}}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
offset: skip updates with id < offset (acknowledge processed)
|
||||||
|
timeout: long-poll seconds (max 30)
|
||||||
|
limit: max updates to return (default 100)
|
||||||
|
|
||||||
|
### sendMessage
|
||||||
|
```
|
||||||
|
POST /v1/bot/TOKEN/sendMessage
|
||||||
|
{
|
||||||
|
"chat_id": "FINGERPRINT",
|
||||||
|
"text": "Hello!",
|
||||||
|
"parse_mode": "HTML", // optional
|
||||||
|
"reply_to_message_id": "MSG_ID", // optional
|
||||||
|
"reply_markup": { // optional, inline keyboard
|
||||||
|
"inline_keyboard": [
|
||||||
|
[{"text":"Yes","callback_data":"yes"},{"text":"No","callback_data":"no"}]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
→ {"ok":true,"result":{"message_id":"UUID","delivered":true}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### answerCallbackQuery
|
||||||
|
```
|
||||||
|
POST /v1/bot/TOKEN/answerCallbackQuery
|
||||||
|
{"callback_query_id":"ID","text":"Done!","show_alert":false}
|
||||||
|
→ {"ok":true,"result":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### editMessageText
|
||||||
|
```
|
||||||
|
POST /v1/bot/TOKEN/editMessageText
|
||||||
|
{"chat_id":"FP","message_id":"MSG_ID","text":"Updated text","reply_markup":{...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### sendDocument
|
||||||
|
```
|
||||||
|
POST /v1/bot/TOKEN/sendDocument
|
||||||
|
{"chat_id":"FP","document":"filename_or_url","caption":"optional"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### setWebhook / deleteWebhook / getWebhookInfo
|
||||||
|
```
|
||||||
|
POST /v1/bot/TOKEN/setWebhook {"url":"https://mybot.example.com/webhook"}
|
||||||
|
POST /v1/bot/TOKEN/deleteWebhook
|
||||||
|
GET /v1/bot/TOKEN/getWebhookInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update Types
|
||||||
|
|
||||||
|
Messages from users arrive in getUpdates as:
|
||||||
|
|
||||||
|
**Plaintext (from other bots):**
|
||||||
|
```json
|
||||||
|
{"update_id":1,"message":{"message_id":"id","from":{"id":"fp","is_bot":true},"chat":{"id":"fp","type":"private"},"text":"Hello"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Encrypted (from users with E2E sessions):**
|
||||||
|
```json
|
||||||
|
{"update_id":2,"message":{"message_id":"id","from":{"id":"fp","is_bot":false},"chat":{"id":"fp"},"text":null,"raw_encrypted":"base64..."}}
|
||||||
|
```
|
||||||
|
Note: v1 bots cannot decrypt E2E messages. They see text=null + raw_encrypted blob.
|
||||||
|
|
||||||
|
**Call signal:**
|
||||||
|
```json
|
||||||
|
{"update_id":3,"message":{"text":"/call_Offer","call_signal":{"type":"Offer","payload":"..."}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:**
|
||||||
|
```json
|
||||||
|
{"update_id":4,"message":{"document":{"file_name":"report.pdf","file_size":1234}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Examples
|
||||||
|
|
||||||
|
### Echo Bot
|
||||||
|
```python
|
||||||
|
import requests, time
|
||||||
|
|
||||||
|
TOKEN = "YOUR_TOKEN"
|
||||||
|
API = f"http://localhost:7700/v1/bot/{TOKEN}"
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).json()
|
||||||
|
for update in resp.get("result", []):
|
||||||
|
offset = update["update_id"] + 1
|
||||||
|
msg = update.get("message", {})
|
||||||
|
chat_id = msg.get("chat", {}).get("id", "")
|
||||||
|
text = msg.get("text")
|
||||||
|
if text and chat_id:
|
||||||
|
requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": f"Echo: {text}"})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline Keyboard Bot
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
TOKEN = "YOUR_TOKEN"
|
||||||
|
API = f"http://localhost:7700/v1/bot/{TOKEN}"
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
def send_menu(chat_id):
|
||||||
|
requests.post(f"{API}/sendMessage", json={
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": "Choose an option:",
|
||||||
|
"reply_markup": {
|
||||||
|
"inline_keyboard": [
|
||||||
|
[{"text": "Option A", "callback_data": "a"}, {"text": "Option B", "callback_data": "b"}],
|
||||||
|
[{"text": "Help", "callback_data": "help"}]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).json()
|
||||||
|
for update in resp.get("result", []):
|
||||||
|
offset = update["update_id"] + 1
|
||||||
|
msg = update.get("message", {})
|
||||||
|
text = msg.get("text", "")
|
||||||
|
chat_id = msg.get("chat", {}).get("id", "")
|
||||||
|
if text == "/start":
|
||||||
|
send_menu(chat_id)
|
||||||
|
elif text:
|
||||||
|
requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": f"You said: {text}"})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js Echo Bot
|
||||||
|
```javascript
|
||||||
|
const API = `http://localhost:7700/v1/bot/${process.env.BOT_TOKEN}`;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/getUpdates`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({offset, timeout: 30})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
for (const update of data.result || []) {
|
||||||
|
offset = update.update_id + 1;
|
||||||
|
const msg = update.message;
|
||||||
|
if (msg?.text && msg?.chat?.id) {
|
||||||
|
await fetch(`${API}/sendMessage`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({chat_id: msg.chat.id, text: `Echo: ${msg.text}`})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); await new Promise(r => setTimeout(r, 3000)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Differences from Telegram
|
||||||
|
|
||||||
|
| Feature | Telegram | featherChat |
|
||||||
|
|---------|----------|------------|
|
||||||
|
| chat_id | numeric | hex fingerprint string |
|
||||||
|
| getUpdates timeout | up to 50s | up to 30s |
|
||||||
|
| User messages | plaintext | E2E encrypted (text=null in v1) |
|
||||||
|
| Bot messages | plaintext | plaintext (no E2E) |
|
||||||
|
| File upload | multipart form | JSON reference (v1) |
|
||||||
|
| Inline keyboards | full support | stored + delivered, no popup |
|
||||||
|
| Callback queries | full popup | acknowledged, no popup |
|
||||||
|
| Webhooks | full HTTPS | URL stored, delivery planned |
|
||||||
|
| Media groups | supported | not yet |
|
||||||
|
| parse_mode | renders HTML/MD | stored, not rendered (v1) |
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
**Always use offset** — without it, the same messages are returned every poll.
|
||||||
|
|
||||||
|
**chat_id is the sender's fingerprint** — use `msg.chat.id` or `msg.from.id`.
|
||||||
|
|
||||||
|
**Bot alias** — users message bots via `@mybot_bot` which resolves to the bot's fingerprint.
|
||||||
|
|
||||||
|
**Error handling** — all responses have `{"ok": bool}`. Check `ok` before accessing `result`.
|
||||||
|
|
||||||
|
**Rate limits** — 200 concurrent server requests, no per-bot limit (be reasonable).
|
||||||
Reference in New Issue
Block a user