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

10
warzone/Cargo.lock generated
View File

@@ -2956,7 +2956,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-client" name = "warzone-client"
version = "0.0.25" version = "0.0.27"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -2989,7 +2989,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-mule" name = "warzone-mule"
version = "0.0.25" version = "0.0.27"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -2998,7 +2998,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-protocol" name = "warzone-protocol"
version = "0.0.25" version = "0.0.27"
dependencies = [ dependencies = [
"base64", "base64",
"bincode", "bincode",
@@ -3023,7 +3023,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-server" name = "warzone-server"
version = "0.0.25" version = "0.0.27"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -3053,7 +3053,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-wasm" name = "warzone-wasm"
version = "0.0.25" version = "0.0.27"
dependencies = [ dependencies = [
"base64", "base64",
"bincode", "bincode",

View File

@@ -9,7 +9,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.0.25" version = "0.0.27"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
rust-version = "1.75" rust-version = "1.75"

View File

@@ -541,6 +541,48 @@ impl App {
} }
}; };
// If peer is a bot alias, send plaintext (no E2E)
let is_bot_peer = {
let url = format!("{}/v1/alias/whois/{}", client.base_url, normfp(&peer));
match client.client.get(&url).send().await {
Ok(resp) => resp.json::<serde_json::Value>().await.ok()
.and_then(|d| d.get("alias").and_then(|a| a.as_str().map(|s| s.ends_with("bot") || s.ends_with("Bot") || s.ends_with("_bot"))))
.unwrap_or(false),
Err(_) => false,
}
};
if is_bot_peer {
let msg_id = uuid::Uuid::new_v4().to_string();
let bot_msg = serde_json::json!({
"type": "bot_message",
"id": msg_id,
"from": normfp(&self.our_fp),
"from_name": if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { self.our_eth.clone() },
"text": text,
"timestamp": chrono::Utc::now().timestamp(),
});
let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default();
match client.send_message(&peer, Some(&self.our_fp), &msg_bytes).await {
Ok(_) => {
self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent);
let _ = db.touch_contact(&peer, None);
let _ = db.store_message(&peer, &self.our_fp, &text, true);
self.add_message(ChatLine {
sender: if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { format!("{}...", &self.our_eth[..self.our_eth.len().min(12)]) },
text: text.clone(),
is_system: false,
is_self: true,
message_id: Some(msg_id), timestamp: Local::now(),
});
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
return;
}
let msg_id = uuid::Uuid::new_v4().to_string(); let msg_id = uuid::Uuid::new_v4().to_string();
let our_pub = identity.public_identity(); let our_pub = identity.public_identity();
let mut ratchet = db.load_session(&peer_fp).ok().flatten(); let mut ratchet = db.load_session(&peer_fp).ok().flatten();

View File

@@ -216,15 +216,16 @@ mod tests {
// 2. header_contains_fingerprint // 2. header_contains_fingerprint
// ---------------------------------------------------------------- // ----------------------------------------------------------------
#[test] #[test]
fn header_contains_fingerprint() { fn header_contains_identity() {
let app = make_app(); let app = make_app();
let mut terminal = make_terminal(); let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap(); terminal.draw(|f| app.draw(f)).unwrap();
let header = row_text(&terminal, 0); let header = row_text(&terminal, 0);
// Header shows ETH address (if seed exists) or fingerprint
assert!( assert!(
header.contains("aabbcc"), header.contains("aabbcc") || header.contains("0x"),
"header should contain our fingerprint 'aabbcc', got: {header}" "header should contain fingerprint or ETH address, got: {header}"
); );
} }

View File

@@ -163,7 +163,8 @@ mod tests {
let msgs = app.messages.lock().unwrap(); let msgs = app.messages.lock().unwrap();
assert!(msgs.len() >= 2); assert!(msgs.len() >= 2);
assert!(msgs[0].is_system); assert!(msgs[0].is_system);
assert!(msgs[0].text.contains("aabbcc")); // First message shows ETH address (if seed exists) or fingerprint
assert!(msgs[0].text.contains("You are"));
} }
#[test] #[test]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "warzone-protocol" name = "warzone-protocol"
version = "0.0.25" version = "0.0.27"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
description = "Core crypto & wire protocol for featherChat (Warzone messenger)" description = "Core crypto & wire protocol for featherChat (Warzone messenger)"

View File

@@ -22,6 +22,10 @@ struct Cli {
/// Federation config file (JSON). Enables server-to-server message relay. /// Federation config file (JSON). Enables server-to-server message relay.
#[arg(short, long)] #[arg(short, long)]
federation: Option<String>, federation: Option<String>,
/// Enable bot API (disabled by default)
#[arg(long, default_value = "false")]
enable_bots: bool,
} }
#[tokio::main] #[tokio::main]
@@ -49,6 +53,36 @@ async fn main() -> anyhow::Result<()> {
state.federation = Some(handle); 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 // Spawn federation outgoing WS connection if enabled
if let Some(ref fed) = state.federation { if let Some(ref fed) = state.federation {
let handle = fed.clone(); let handle = fed.clone();

View File

@@ -49,12 +49,55 @@ pub fn routes() -> Router<AppState> {
/// Validate a bot token against the `tokens` sled tree. /// Validate a bot token against the `tokens` sled tree.
/// Returns the stored bot info JSON if the token is valid. /// 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> { fn validate_bot_token(state: &AppState, token: &str) -> Option<serde_json::Value> {
if !state.bots_enabled {
return None;
}
let key = format!("bot:{}", token); let key = format!("bot:{}", token);
let ivec = state.db.tokens.get(key.as_bytes()).ok()??; let ivec = state.db.tokens.get(key.as_bytes()).ok()??;
serde_json::from_slice(&ivec).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. /// 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>`. /// 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 // Handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -100,6 +228,14 @@ fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value)
struct RegisterBotRequest { struct RegisterBotRequest {
name: String, name: String,
fingerprint: 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. /// Register a bot and receive a token.
@@ -113,6 +249,12 @@ async fn register_bot(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RegisterBotRequest>, Json(req): Json<RegisterBotRequest>,
) -> AppResult<Json<serde_json::Value>> { ) -> 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 let fp = req
.fingerprint .fingerprint
.chars() .chars()
@@ -131,6 +273,8 @@ async fn register_bot(
"name": req.name, "name": req.name,
"fingerprint": fp, "fingerprint": fp,
"token": token, "token": token,
"owner": req.owner.as_deref().unwrap_or(&fp),
"e2e": req.e2e.unwrap_or(false),
"created_at": chrono::Utc::now().timestamp(), "created_at": chrono::Utc::now().timestamp(),
}); });
@@ -148,11 +292,29 @@ async fn register_bot(
.tokens .tokens
.insert(fp_key.as_bytes(), token.as_bytes())?; .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!( tracing::info!(
"Bot registered: {} ({}) token={}...", "Bot registered: {} ({}) token={}... e2e={}",
req.name, req.name,
fp, 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) // Auto-register bot alias (name must end with Bot or _bot)
@@ -174,6 +336,7 @@ async fn register_bot(
"name": req.name, "name": req.name,
"fingerprint": fp, "fingerprint": fp,
"alias": format!("@{}", bot_alias), "alias": format!("@{}", bot_alias),
"e2e": req.e2e.unwrap_or(false),
} }
}))) })))
} }
@@ -184,15 +347,19 @@ async fn get_me(
Path(token): Path<String>, Path(token): Path<String>,
) -> Json<serde_json::Value> { ) -> Json<serde_json::Value> {
match validate_bot_token(&state, &token) { match validate_bot_token(&state, &token) {
Some(info) => Json(serde_json::json!({ Some(info) => {
"ok": true, let fp = info["fingerprint"].as_str().unwrap_or("");
"result": { Json(serde_json::json!({
"id": info["fingerprint"], "ok": true,
"is_bot": true, "result": {
"first_name": info["name"], "id": crate::routes::resolve::fp_to_numeric_id(fp),
"username": info["name"], "id_str": fp,
} "is_bot": true,
})), "first_name": info["name"],
"username": info["name"],
}
}))
}
None => Json(serde_json::json!({ None => Json(serde_json::json!({
"ok": false, "ok": false,
"description": "invalid token", "description": "invalid token",
@@ -273,7 +440,7 @@ async fn get_updates(
// Step 4: Long-poll if empty. // Step 4: Long-poll if empty.
if updates.is_empty() && timeout > 0 { 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. // Poll in 1-second intervals so new messages are picked up promptly.
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait);
loop { loop {
@@ -346,16 +513,19 @@ fn wire_message_to_update(
.. ..
} => { } => {
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); 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!({ Some(serde_json::json!({
"message": { "message": {
"message_id": id, "message_id": id,
"from": { "from": {
"id": sender_fingerprint, "id": numeric,
"id_str": sender_fingerprint,
"is_bot": false, "is_bot": false,
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
}, },
"chat": { "chat": {
"id": sender_fingerprint, "id": numeric,
"id_str": sender_fingerprint,
"type": "private", "type": "private",
}, },
"date": chrono::Utc::now().timestamp(), "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 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!({ Some(serde_json::json!({
"message": { "message": {
"message_id": id, "message_id": id,
"from": { "from": {
"id": sender_fingerprint, "id": numeric,
"id_str": sender_fingerprint,
"is_bot": false, "is_bot": false,
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
}, },
"chat": { "chat": {
"id": sender_fingerprint, "id": numeric,
"id_str": sender_fingerprint,
"type": "private", "type": "private",
}, },
"date": chrono::Utc::now().timestamp(), "date": chrono::Utc::now().timestamp(),
@@ -394,51 +567,61 @@ fn wire_message_to_update(
signal_type, signal_type,
payload, payload,
.. ..
} => Some(serde_json::json!({ } => {
"message": { let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint);
"message_id": id, Some(serde_json::json!({
"from": { "message": {
"id": sender_fingerprint, "message_id": id,
"is_bot": false, "from": {
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], "id": numeric,
}, "id_str": sender_fingerprint,
"chat": { "is_bot": false,
"id": sender_fingerprint, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
"type": "private", },
}, "chat": {
"date": chrono::Utc::now().timestamp(), "id": numeric,
"text": format!("/call_{:?}", signal_type), "id_str": sender_fingerprint,
"call_signal": { "type": "private",
"type": format!("{:?}", signal_type), },
"payload": payload, "date": chrono::Utc::now().timestamp(),
}, "text": format!("/call_{:?}", signal_type),
} "call_signal": {
})), "type": format!("{:?}", signal_type),
"payload": payload,
},
}
}))
}
warzone_protocol::message::WireMessage::FileHeader { warzone_protocol::message::WireMessage::FileHeader {
id, id,
sender_fingerprint, sender_fingerprint,
filename, filename,
file_size, file_size,
.. ..
} => Some(serde_json::json!({ } => {
"message": { let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint);
"message_id": id, Some(serde_json::json!({
"from": { "message": {
"id": sender_fingerprint, "message_id": id,
"is_bot": false, "from": {
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], "id": numeric,
}, "id_str": sender_fingerprint,
"chat": { "is_bot": false,
"id": sender_fingerprint, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
"type": "private", },
}, "chat": {
"date": chrono::Utc::now().timestamp(), "id": numeric,
"document": { "id_str": sender_fingerprint,
"file_name": filename, "type": "private",
"file_size": file_size, },
}, "date": chrono::Utc::now().timestamp(),
} "document": {
})), "file_name": filename,
"file_size": file_size,
},
}
}))
}
// Skip receipts and other variants. // Skip receipts and other variants.
warzone_protocol::message::WireMessage::Receipt { .. } => None, warzone_protocol::message::WireMessage::Receipt { .. } => None,
_ => 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> { 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())?; let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?;
match msg_type { match msg_type {
"bot_message" => Some(serde_json::json!({ "bot_message" => {
"message": { let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or("");
"message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), let numeric = crate::routes::resolve::fp_to_numeric_id(from_fp);
"from": { Some(serde_json::json!({
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), "message": {
"is_bot": true, "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
}, "from": {
"chat": { "id": numeric,
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), "id_str": from_fp,
"type": "private", "is_bot": true,
}, },
"date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0), "chat": {
"text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""), "id": numeric,
} "id_str": from_fp,
})), "type": "private",
"callback_query" => Some(serde_json::json!({ },
"callback_query": { "date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0),
"id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), "text": bot_msg.get("text").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, }
}, "callback_query" => {
"data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""), let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or("");
"message": bot_msg.get("message"), 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, _ => None,
} }
} }
@@ -504,7 +698,7 @@ fn collect_updates(state: &AppState, bot_fp: &str, limit: usize) -> Vec<serde_js
#[derive(Deserialize)] #[derive(Deserialize)]
struct SendMessageRequest { struct SendMessageRequest {
chat_id: String, chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
text: String, text: String,
#[serde(default)] #[serde(default)]
parse_mode: Option<String>, parse_mode: Option<String>,
@@ -533,12 +727,12 @@ async fn send_message(
} }
}; };
let to_fp = req let to_fp = match resolve_chat_id(&state, &req.chat_id) {
.chat_id Some(fp) => fp,
.chars() None => {
.filter(|c| c.is_ascii_hexdigit()) return Json(serde_json::json!({"ok": false, "description": "chat_id not found"}))
.collect::<String>() }
.to_lowercase(); };
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
let msg_id = uuid::Uuid::new_v4().to_string(); let msg_id = uuid::Uuid::new_v4().to_string();
@@ -608,7 +802,7 @@ async fn answer_callback_query(
#[derive(Deserialize)] #[derive(Deserialize)]
struct EditMessageRequest { struct EditMessageRequest {
chat_id: String, chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
message_id: String, message_id: String,
text: String, text: String,
#[serde(default)] #[serde(default)]
@@ -626,12 +820,12 @@ async fn edit_message_text(
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
}; };
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
let to_fp = req let to_fp = match resolve_chat_id(&state, &req.chat_id) {
.chat_id Some(fp) => fp,
.chars() None => {
.filter(|c| c.is_ascii_hexdigit()) return Json(serde_json::json!({"ok": false, "description": "chat_id not found"}))
.collect::<String>() }
.to_lowercase(); };
let edit_msg = serde_json::json!({ let edit_msg = serde_json::json!({
"type": "bot_edit", "type": "bot_edit",
@@ -732,7 +926,7 @@ async fn get_webhook_info(
#[derive(Deserialize)] #[derive(Deserialize)]
struct SendDocumentRequest { 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 /// File path, URL, or file_id reference. In v1, the reference is stored
/// and forwarded as-is without server-side file hosting. /// and forwarded as-is without server-side file hosting.
document: String, document: String,
@@ -751,12 +945,12 @@ async fn send_document(
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
}; };
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
let to_fp = req let to_fp = match resolve_chat_id(&state, &req.chat_id) {
.chat_id Some(fp) => fp,
.chars() None => {
.filter(|c| c.is_ascii_hexdigit()) return Json(serde_json::json!({"ok": false, "description": "chat_id not found"}))
.collect::<String>() }
.to_lowercase(); };
let msg_id = uuid::Uuid::new_v4().to_string(); let msg_id = uuid::Uuid::new_v4().to_string();
let doc_msg = serde_json::json!({ let doc_msg = serde_json::json!({

View File

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

View File

@@ -7,6 +7,20 @@ use axum::{
use crate::errors::AppResult; use crate::errors::AppResult;
use crate::state::AppState; 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> { pub fn routes() -> Router<AppState> {
Router::new().route("/resolve/:address", get(resolve_address)) Router::new().route("/resolve/:address", get(resolve_address))
} }
@@ -27,6 +41,7 @@ async fn resolve_address(
return Ok(Json(serde_json::json!({ return Ok(Json(serde_json::json!({
"address": address, "address": address,
"fingerprint": fp, "fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"type": "eth", "type": "eth",
}))); })));
} }
@@ -40,6 +55,7 @@ async fn resolve_address(
return Ok(Json(serde_json::json!({ return Ok(Json(serde_json::json!({
"address": address, "address": address,
"fingerprint": fp, "fingerprint": fp,
"numeric_id": fp_to_numeric_id(fp),
"type": "eth", "type": "eth",
"federated": true, "federated": true,
}))); })));
@@ -61,6 +77,7 @@ async fn resolve_address(
return Ok(Json(serde_json::json!({ return Ok(Json(serde_json::json!({
"address": address, "address": address,
"fingerprint": fp, "fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"type": "alias", "type": "alias",
}))); })));
} }
@@ -70,6 +87,7 @@ async fn resolve_address(
return Ok(Json(serde_json::json!({ return Ok(Json(serde_json::json!({
"address": address, "address": address,
"fingerprint": fp, "fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"type": "alias", "type": "alias",
"federated": true, "federated": true,
}))); })));
@@ -93,6 +111,7 @@ async fn resolve_address(
return Ok(Json(serde_json::json!({ return Ok(Json(serde_json::json!({
"address": address, "address": address,
"fingerprint": fp, "fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"eth_address": eth, "eth_address": eth,
"type": "fingerprint", "type": "fingerprint",
}))); })));

View File

@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
async fn service_worker() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/javascript")], r##" ([(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']; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
self.addEventListener('install', e => { self.addEventListener('install', e => {
@@ -241,7 +241,7 @@ let pollTimer = null;
let ws = null; // WebSocket connection let ws = null; // WebSocket connection
let wasmReady = false; let wasmReady = false;
const VERSION = '0.0.25'; const VERSION = '0.0.27';
let DEBUG = true; // toggle with /debug command let DEBUG = true; // toggle with /debug command
// ── Receipt tracking ── // ── Receipt tracking ──
@@ -547,7 +547,8 @@ function connectWebSocket() {
msgText += '\\n'; msgText += '\\n';
} }
} }
addMsg('@' + botName, msgText, false); const useHtml = json.parse_mode === 'HTML';
addMsg('@' + botName, msgText, false, null, useHtml);
lastDmPeer = json.from ? normFP(json.from) : ''; lastDmPeer = json.from ? normFP(json.from) : '';
return; return;
} }
@@ -693,7 +694,8 @@ async function handleIncomingMessage(bytes) {
msgText += '\\n'; msgText += '\\n';
} }
} }
addMsg('@' + botName, msgText, false); const useHtml = json.parse_mode === 'HTML';
addMsg('@' + botName, msgText, false, null, useHtml);
lastDmPeer = json.from ? normFP(json.from) : ''; lastDmPeer = json.from ? normFP(json.from) : '';
return; return;
} }
@@ -801,7 +803,7 @@ function formatSize(n) {
return (n/1048576).toFixed(1) + ' MB'; return (n/1048576).toFixed(1) + ' MB';
} }
function addMsg(from, text, isSelf, messageId) { function addMsg(from, text, isSelf, messageId, rawHtml) {
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'msg'; d.className = 'msg';
const color = isSelf ? '#4ade80' : peerColor(from); const color = isSelf ? '#4ade80' : peerColor(from);
@@ -811,7 +813,8 @@ function addMsg(from, text, isSelf, messageId) {
const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent'; const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent';
receiptHtml = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>'; 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 // Attach click handler for .addr spans
d.querySelectorAll('.addr').forEach(el => { d.querySelectorAll('.addr').forEach(el => {
el.addEventListener('click', () => handleAddrClick(el.dataset.addr)); el.addEventListener('click', () => handleAddrClick(el.dataset.addr));
@@ -1202,6 +1205,22 @@ async function doSend() {
localStorage.setItem('wz-peer', $peerInput.value.trim()); 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 { try {
const msgId = await sendEncrypted(peer, text); const msgId = await sendEncrypted(peer, text);
sentMsgReceipts[msgId] = { status: 'sent', el: null }; sentMsgReceipts[msgId] = { status: 'sent', el: null };

View File

@@ -88,6 +88,7 @@ pub struct AppState {
pub dedup: DedupTracker, pub dedup: DedupTracker,
pub active_calls: Arc<Mutex<HashMap<String, CallState>>>, pub active_calls: Arc<Mutex<HashMap<String, CallState>>>,
pub federation: Option<crate::federation::FederationHandle>, pub federation: Option<crate::federation::FederationHandle>,
pub bots_enabled: bool,
} }
impl AppState { impl AppState {
@@ -99,6 +100,7 @@ impl AppState {
dedup: DedupTracker::new(), dedup: DedupTracker::new(),
active_calls: Arc::new(Mutex::new(HashMap::new())), active_calls: Arc::new(Mutex::new(HashMap::new())),
federation: None, federation: None,
bots_enabled: false,
}) })
} }
@@ -188,6 +190,21 @@ impl AppState {
// 3. Queue in local DB // 3. Queue in local DB
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4()); let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
let _ = self.db.messages.insert(key.as_bytes(), message); 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 false
} }

38
warzone/tools/README.md Normal file
View File

@@ -0,0 +1,38 @@
# featherChat Bot Tools
## bot-bridge.py
Proxy server that makes featherChat compatible with Telegram bot libraries.
### Quick Start
```bash
# 1. Register a bot on featherChat
curl -X POST http://server:7700/v1/bot/register \
-H 'Content-Type: application/json' \
-d '{"name":"MyBot","fingerprint":"aabbccddaabbccddaabbccddaabbccdd"}'
# 2. Start the bridge
python3 tools/bot-bridge.py --server http://server:7700 --token YOUR_TOKEN --port 8081
# 3. Point your TG bot at the bridge
# Python (python-telegram-bot):
# bot = Bot(token="TOKEN", base_url="http://localhost:8081/botTOKEN")
# Node (Telegraf):
# const bot = new Telegraf("TOKEN", { telegram: { apiRoot: "http://localhost:8081" } })
```
### What it does
- Translates Telegram API calls to featherChat Bot API
- Converts numeric chat_id <-> fingerprint hex strings
- Proxies getUpdates long-polling
- Passes through sendMessage, editMessageText, etc.
### Future: E2E Mode
When E2E bot support is complete, the bridge will:
- Hold the bot's seed/keypair
- Decrypt incoming E2E messages before forwarding to the TG bot
- Encrypt outgoing messages with the user's ratchet session
- The TG bot sees plaintext; the server sees only ciphertext

175
warzone/tools/bot-bridge.py Executable file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
featherChat E2E Bot Bridge
Runs a local Telegram-compatible API server that proxies to featherChat.
Your Telegram bot connects to this bridge instead of api.telegram.org.
Usage:
python bot-bridge.py --server http://featherchat:7700 --token YOUR_BOT_TOKEN --port 8081
Your bot code:
# Instead of: bot = Bot(token="...", base_url="https://api.telegram.org")
# Use: bot = Bot(token="...", base_url="http://localhost:8081")
Architecture:
[TG Bot] <--HTTP--> [Bridge :8081] <--HTTP--> [featherChat :7700]
The bridge translates between Telegram API format and featherChat Bot API,
handling the chat_id type differences and other incompatibilities.
"""
import argparse
import json
import sys
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from urllib.error import URLError
class BotBridgeHandler(BaseHTTPRequestHandler):
server_url = ""
bot_token = ""
def log_message(self, format, *args):
print(f"[bridge] {args[0]}" if args else "")
def do_GET(self):
self._proxy()
def do_POST(self):
self._proxy()
def _proxy(self):
# Extract the method from URL: /bot<token>/methodName
path = self.path
# Strip /bot<token>/ prefix if present (TG libraries send this)
if path.startswith(f'/bot{self.bot_token}/'):
method = path[len(f'/bot{self.bot_token}/'):]
elif path.startswith('/bot'):
# Library might send a different token format
parts = path.split('/', 3)
method = parts[3] if len(parts) > 3 else parts[-1]
else:
method = path.lstrip('/')
# Read request body
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length) if content_length > 0 else b''
# Transform request for featherChat
fc_url = f"{self.server_url}/v1/bot/{self.bot_token}/{method}"
# Transform body if needed
if body and method == 'sendMessage':
body = self._transform_send_message(body)
try:
req = Request(fc_url, data=body if body else None, method=self.command)
req.add_header('Content-Type', 'application/json')
with urlopen(req, timeout=60) as resp:
response_body = resp.read()
# Transform response
if method == 'getUpdates':
response_body = self._transform_updates(response_body)
self.send_response(resp.status)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(response_body)
except URLError as e:
self.send_response(502)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({
"ok": False,
"description": f"Bridge error: {e}"
}).encode())
def _transform_send_message(self, body):
"""Transform sendMessage: convert numeric chat_id to string if needed."""
try:
data = json.loads(body)
# chat_id: featherChat accepts both string and number now
# No transformation needed -- pass through
return json.dumps(data).encode()
except:
return body
def _transform_updates(self, body):
"""Transform getUpdates response: ensure chat_id is integer for TG libs."""
try:
data = json.loads(body)
if data.get('ok') and data.get('result'):
for update in data['result']:
msg = update.get('message', {})
# Convert string IDs to numeric for TG library compatibility
if 'from' in msg and isinstance(msg['from'].get('id'), str):
fp = msg['from']['id']
msg['from']['id_str'] = fp
msg['from']['id'] = _fp_to_numeric(fp)
if 'chat' in msg and isinstance(msg['chat'].get('id'), str):
fp = msg['chat']['id']
msg['chat']['id_str'] = fp
msg['chat']['id'] = _fp_to_numeric(fp)
return json.dumps(data).encode()
except:
return body
def _fp_to_numeric(fp: str) -> int:
"""Convert fingerprint hex string to positive i64 (same as server's fp_to_numeric_id)."""
clean = ''.join(c for c in fp if c in '0123456789abcdefABCDEF')[:16]
if len(clean) >= 16:
return int(clean, 16) & 0x7FFFFFFFFFFFFFFF
return 0
def main():
parser = argparse.ArgumentParser(description='featherChat E2E Bot Bridge')
parser.add_argument('--server', required=True, help='featherChat server URL (e.g., http://localhost:7700)')
parser.add_argument('--token', required=True, help='Bot token from /v1/bot/register')
parser.add_argument('--port', type=int, default=8081, help='Local port for TG-compatible API (default: 8081)')
args = parser.parse_args()
BotBridgeHandler.server_url = args.server.rstrip('/')
BotBridgeHandler.bot_token = args.token
# Verify bot token
try:
req = Request(f"{args.server}/v1/bot/{args.token}/getMe")
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
if not data.get('ok'):
print(f"ERROR: Invalid bot token")
sys.exit(1)
bot_name = data['result'].get('first_name', '?')
print(f"Bot: {bot_name}")
except Exception as e:
print(f"ERROR: Cannot reach server: {e}")
sys.exit(1)
server = HTTPServer(('127.0.0.1', args.port), BotBridgeHandler)
print(f"Bridge running on http://127.0.0.1:{args.port}")
print(f"Proxying to {args.server}")
print(f"")
print(f"Configure your bot:")
print(f" base_url = 'http://127.0.0.1:{args.port}/bot{args.token}'")
print(f"")
print(f"Example (python-telegram-bot):")
print(f" from telegram import Bot")
print(f" bot = Bot(token='{args.token}', base_url='http://127.0.0.1:{args.port}/bot{args.token}')")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nBridge stopped.")
if __name__ == '__main__':
main()