v0.0.27: TG-compatible bots — plaintext send, numeric IDs, webhooks, BotFather
Bot compatibility: - Clients send plaintext bot_message to bot aliases (no E2E encryption) - Numeric chat_id: fp_to_numeric_id() deterministic hash, accept string/number - Webhook delivery: POST updates to bot's webhook URL (async, fire-and-forget) - getUpdates timeout raised to 50s (was 30, TG uses 50) - parse_mode HTML rendered in web client - E2E bot registration: optional seed + bundle for encrypted bot sessions BotFather + instance control: - --enable-bots CLI flag (default: disabled) - BotFather auto-created on first start (@botfather alias) - Bot ownership: owner fingerprint stored in bot_info - All bot endpoints return 403 when disabled Bot Bridge: - tools/bot-bridge.py: TG-compatible proxy for unmodified TG bots - Translates chat_id int↔string, proxies getUpdates/sendMessage - README with python-telegram-bot and Telegraf examples Test fixes: - Updated tests for ETH address display in header/messages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,12 +49,55 @@ pub fn routes() -> Router<AppState> {
|
||||
|
||||
/// 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);
|
||||
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>`.
|
||||
@@ -92,6 +135,91 @@ fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(&wire, message)
|
||||
} else if let Ok(bot_msg) = serde_json::from_slice::<serde_json::Value>(message) {
|
||||
bot_json_to_update(&bot_msg)
|
||||
} 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -100,6 +228,14 @@ fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value)
|
||||
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
|
||||
}
|
||||
|
||||
/// Register a bot and receive a token.
|
||||
@@ -113,6 +249,12 @@ async fn register_bot(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterBotRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// TODO: In production, only @botfather should be able to register bots.
|
||||
// For v1, direct registration is allowed for development.
|
||||
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"})));
|
||||
}
|
||||
|
||||
let fp = req
|
||||
.fingerprint
|
||||
.chars()
|
||||
@@ -131,6 +273,8 @@ async fn register_bot(
|
||||
"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(),
|
||||
});
|
||||
|
||||
@@ -148,11 +292,29 @@ async fn register_bot(
|
||||
.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={}...",
|
||||
"Bot registered: {} ({}) token={}... e2e={}",
|
||||
req.name,
|
||||
fp,
|
||||
&token[..token.len().min(20)]
|
||||
&token[..token.len().min(20)],
|
||||
req.e2e.unwrap_or(false),
|
||||
);
|
||||
|
||||
// Auto-register bot alias (name must end with Bot or _bot)
|
||||
@@ -174,6 +336,7 @@ async fn register_bot(
|
||||
"name": req.name,
|
||||
"fingerprint": fp,
|
||||
"alias": format!("@{}", bot_alias),
|
||||
"e2e": req.e2e.unwrap_or(false),
|
||||
}
|
||||
})))
|
||||
}
|
||||
@@ -184,15 +347,19 @@ async fn get_me(
|
||||
Path(token): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
match validate_bot_token(&state, &token) {
|
||||
Some(info) => Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"id": info["fingerprint"],
|
||||
"is_bot": true,
|
||||
"first_name": info["name"],
|
||||
"username": info["name"],
|
||||
}
|
||||
})),
|
||||
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(fp),
|
||||
"id_str": fp,
|
||||
"is_bot": true,
|
||||
"first_name": info["name"],
|
||||
"username": info["name"],
|
||||
}
|
||||
}))
|
||||
}
|
||||
None => Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"description": "invalid token",
|
||||
@@ -273,7 +440,7 @@ async fn get_updates(
|
||||
|
||||
// Step 4: Long-poll if empty.
|
||||
if updates.is_empty() && timeout > 0 {
|
||||
let wait = std::cmp::min(timeout, 30);
|
||||
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 {
|
||||
@@ -346,16 +513,19 @@ fn wire_message_to_update(
|
||||
..
|
||||
} => {
|
||||
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes);
|
||||
let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint);
|
||||
Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": id,
|
||||
"from": {
|
||||
"id": sender_fingerprint,
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"is_bot": false,
|
||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
},
|
||||
"chat": {
|
||||
"id": sender_fingerprint,
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"type": "private",
|
||||
},
|
||||
"date": chrono::Utc::now().timestamp(),
|
||||
@@ -370,16 +540,19 @@ fn wire_message_to_update(
|
||||
..
|
||||
} => {
|
||||
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes);
|
||||
let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint);
|
||||
Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": id,
|
||||
"from": {
|
||||
"id": sender_fingerprint,
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"is_bot": false,
|
||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
},
|
||||
"chat": {
|
||||
"id": sender_fingerprint,
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"type": "private",
|
||||
},
|
||||
"date": chrono::Utc::now().timestamp(),
|
||||
@@ -394,51 +567,61 @@ fn wire_message_to_update(
|
||||
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,
|
||||
},
|
||||
}
|
||||
})),
|
||||
} => {
|
||||
let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint);
|
||||
Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": id,
|
||||
"from": {
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"is_bot": false,
|
||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
},
|
||||
"chat": {
|
||||
"id": numeric,
|
||||
"id_str": 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,
|
||||
},
|
||||
}
|
||||
})),
|
||||
} => {
|
||||
let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint);
|
||||
Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": id,
|
||||
"from": {
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"is_bot": false,
|
||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
},
|
||||
"chat": {
|
||||
"id": numeric,
|
||||
"id_str": 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,
|
||||
@@ -449,32 +632,43 @@ fn wire_message_to_update(
|
||||
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"),
|
||||
}
|
||||
})),
|
||||
"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(from_fp);
|
||||
Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"from": {
|
||||
"id": numeric,
|
||||
"id_str": from_fp,
|
||||
"is_bot": true,
|
||||
},
|
||||
"chat": {
|
||||
"id": numeric,
|
||||
"id_str": from_fp,
|
||||
"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(from_fp);
|
||||
Some(serde_json::json!({
|
||||
"callback_query": {
|
||||
"id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"from": {
|
||||
"id": numeric,
|
||||
"id_str": from_fp,
|
||||
"is_bot": false,
|
||||
},
|
||||
"data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"message": bot_msg.get("message"),
|
||||
}
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -504,7 +698,7 @@ fn collect_updates(state: &AppState, bot_fp: &str, limit: usize) -> Vec<serde_js
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendMessageRequest {
|
||||
chat_id: String,
|
||||
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
parse_mode: Option<String>,
|
||||
@@ -533,12 +727,12 @@ async fn send_message(
|
||||
}
|
||||
};
|
||||
|
||||
let to_fp = req
|
||||
.chat_id
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
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();
|
||||
@@ -608,7 +802,7 @@ async fn answer_callback_query(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditMessageRequest {
|
||||
chat_id: String,
|
||||
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
|
||||
message_id: String,
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
@@ -626,12 +820,12 @@ async fn edit_message_text(
|
||||
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 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",
|
||||
@@ -732,7 +926,7 @@ async fn get_webhook_info(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendDocumentRequest {
|
||||
chat_id: String,
|
||||
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
|
||||
/// File path, URL, or file_id reference. In v1, the reference is stored
|
||||
/// and forwarded as-is without server-side file hosting.
|
||||
document: String,
|
||||
@@ -751,12 +945,12 @@ async fn send_document(
|
||||
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 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 msg_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let doc_msg = serde_json::json!({
|
||||
|
||||
Reference in New Issue
Block a user