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:
Siavash Sameni
2026-03-29 09:45:45 +04:00
parent 067f1ea20b
commit 8603087afb
14 changed files with 660 additions and 120 deletions

View File

@@ -22,6 +22,10 @@ struct Cli {
/// Federation config file (JSON). Enables server-to-server message relay.
#[arg(short, long)]
federation: Option<String>,
/// Enable bot API (disabled by default)
#[arg(long, default_value = "false")]
enable_bots: bool,
}
#[tokio::main]
@@ -49,6 +53,36 @@ async fn main() -> anyhow::Result<()> {
state.federation = Some(handle);
}
// Enable bot API if requested
state.bots_enabled = cli.enable_bots;
if cli.enable_bots {
tracing::info!("Bot API enabled");
// Auto-create BotFather if it doesn't exist
let botfather_fp = "0000000000000000botfather00000000";
let botfather_key = format!("bot_fp:{}", botfather_fp);
if state.db.tokens.get(botfather_key.as_bytes()).ok().flatten().is_none() {
let token = format!("botfather:{}", hex::encode(rand::random::<[u8; 16]>()));
let bot_info = serde_json::json!({
"name": "BotFather",
"fingerprint": botfather_fp,
"token": token,
"owner": "system",
"e2e": false,
"created_at": chrono::Utc::now().timestamp(),
});
let key = format!("bot:{}", token);
let _ = state.db.tokens.insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default());
let _ = state.db.tokens.insert(botfather_key.as_bytes(), token.as_bytes());
// Register alias
let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes());
let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather");
tracing::info!("BotFather created: @botfather (token: {}...)", &token[..20]);
} else {
tracing::info!("BotFather already exists");
}
}
// Spawn federation outgoing WS connection if enabled
if let Some(ref fed) = state.federation {
let handle = fed.clone();

View File

@@ -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!({

View File

@@ -1,6 +1,6 @@
mod aliases;
pub mod auth;
mod bot;
pub mod bot;
mod calls;
mod devices;
mod federation;

View File

@@ -7,6 +7,20 @@ use axum::{
use crate::errors::AppResult;
use crate::state::AppState;
/// Convert a fingerprint hex string to a stable i64 ID (for Telegram compatibility).
/// Uses first 8 bytes of the fingerprint as a positive i64.
pub fn fp_to_numeric_id(fp: &str) -> i64 {
let clean: String = fp.chars().filter(|c| c.is_ascii_hexdigit()).take(16).collect();
let bytes = hex::decode(&clean).unwrap_or_default();
if bytes.len() >= 8 {
let mut arr = [0u8; 8];
arr.copy_from_slice(&bytes[..8]);
i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive
} else {
0
}
}
pub fn routes() -> Router<AppState> {
Router::new().route("/resolve/:address", get(resolve_address))
}
@@ -27,6 +41,7 @@ async fn resolve_address(
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"type": "eth",
})));
}
@@ -40,6 +55,7 @@ async fn resolve_address(
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(fp),
"type": "eth",
"federated": true,
})));
@@ -61,6 +77,7 @@ async fn resolve_address(
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"type": "alias",
})));
}
@@ -70,6 +87,7 @@ async fn resolve_address(
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"type": "alias",
"federated": true,
})));
@@ -93,6 +111,7 @@ async fn resolve_address(
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"eth_address": eth,
"type": "fingerprint",
})));

View File

@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
async fn service_worker() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/javascript")], r##"
const CACHE = 'wz-v7';
const CACHE = 'wz-v9';
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
self.addEventListener('install', e => {
@@ -241,7 +241,7 @@ let pollTimer = null;
let ws = null; // WebSocket connection
let wasmReady = false;
const VERSION = '0.0.25';
const VERSION = '0.0.27';
let DEBUG = true; // toggle with /debug command
// ── Receipt tracking ──
@@ -547,7 +547,8 @@ function connectWebSocket() {
msgText += '\\n';
}
}
addMsg('@' + botName, msgText, false);
const useHtml = json.parse_mode === 'HTML';
addMsg('@' + botName, msgText, false, null, useHtml);
lastDmPeer = json.from ? normFP(json.from) : '';
return;
}
@@ -693,7 +694,8 @@ async function handleIncomingMessage(bytes) {
msgText += '\\n';
}
}
addMsg('@' + botName, msgText, false);
const useHtml = json.parse_mode === 'HTML';
addMsg('@' + botName, msgText, false, null, useHtml);
lastDmPeer = json.from ? normFP(json.from) : '';
return;
}
@@ -801,7 +803,7 @@ function formatSize(n) {
return (n/1048576).toFixed(1) + ' MB';
}
function addMsg(from, text, isSelf, messageId) {
function addMsg(from, text, isSelf, messageId, rawHtml) {
const d = document.createElement('div');
d.className = 'msg';
const color = isSelf ? '#4ade80' : peerColor(from);
@@ -811,7 +813,8 @@ function addMsg(from, text, isSelf, messageId) {
const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent';
receiptHtml = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>';
}
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + makeAddressClickable(esc(from)) + '</span>: ' + makeAddressClickable(esc(text)) + receiptHtml;
const bodyHtml = rawHtml ? text : makeAddressClickable(esc(text));
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + makeAddressClickable(esc(from)) + '</span>: ' + bodyHtml + receiptHtml;
// Attach click handler for .addr spans
d.querySelectorAll('.addr').forEach(el => {
el.addEventListener('click', () => handleAddrClick(el.dataset.addr));
@@ -1202,6 +1205,22 @@ async function doSend() {
localStorage.setItem('wz-peer', $peerInput.value.trim());
// Check if peer is a bot — send plaintext instead of E2E
let isBotPeer = false;
try {
const wr = await fetch(SERVER + '/v1/alias/whois/' + normFP(peer));
const wd = await wr.json();
if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot'))) isBotPeer = true;
} catch(e) {}
if (isBotPeer) {
const msgId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString();
const botMsg = {type:'bot_message',id:msgId,from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:text,timestamp:Math.floor(Date.now()/1000)};
await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:normFP(peer),from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(botMsg)))})});
addMsg((myEthAddress ? myEthAddress.slice(0,12)+'...' : myFingerprint.slice(0,19)), text, true, msgId);
return;
}
try {
const msgId = await sendEncrypted(peer, text);
sentMsgReceipts[msgId] = { status: 'sent', el: null };

View File

@@ -88,6 +88,7 @@ pub struct AppState {
pub dedup: DedupTracker,
pub active_calls: Arc<Mutex<HashMap<String, CallState>>>,
pub federation: Option<crate::federation::FederationHandle>,
pub bots_enabled: bool,
}
impl AppState {
@@ -99,6 +100,7 @@ impl AppState {
dedup: DedupTracker::new(),
active_calls: Arc::new(Mutex::new(HashMap::new())),
federation: None,
bots_enabled: false,
})
}
@@ -188,6 +190,21 @@ impl AppState {
// 3. Queue in local DB
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
let _ = self.db.messages.insert(key.as_bytes(), message);
// 4. Try bot webhook delivery (async, does not block the caller)
{
let state = self.clone();
let fp = to_fp.to_string();
let queue_key = key.clone();
let msg = message.to_vec();
tokio::spawn(async move {
if crate::routes::bot::try_bot_webhook(&state, &fp, &msg).await {
// Webhook accepted -- remove from offline queue
let _ = state.db.messages.remove(queue_key.as_bytes());
}
});
}
false
}