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:
10
warzone/Cargo.lock
generated
10
warzone/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
})));
|
})));
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
38
warzone/tools/README.md
Normal 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
175
warzone/tools/bot-bridge.py
Executable 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()
|
||||||
Reference in New Issue
Block a user