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]]
|
||||
name = "warzone-client"
|
||||
version = "0.0.25"
|
||||
version = "0.0.27"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -2989,7 +2989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-mule"
|
||||
version = "0.0.25"
|
||||
version = "0.0.27"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -2998,7 +2998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.25"
|
||||
version = "0.0.27"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
@@ -3023,7 +3023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-server"
|
||||
version = "0.0.25"
|
||||
version = "0.0.27"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3053,7 +3053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-wasm"
|
||||
version = "0.0.25"
|
||||
version = "0.0.27"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.25"
|
||||
version = "0.0.27"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
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 our_pub = identity.public_identity();
|
||||
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
|
||||
|
||||
@@ -216,15 +216,16 @@ mod tests {
|
||||
// 2. header_contains_fingerprint
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn header_contains_fingerprint() {
|
||||
fn header_contains_identity() {
|
||||
let app = make_app();
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
let header = row_text(&terminal, 0);
|
||||
// Header shows ETH address (if seed exists) or fingerprint
|
||||
assert!(
|
||||
header.contains("aabbcc"),
|
||||
"header should contain our fingerprint 'aabbcc', got: {header}"
|
||||
header.contains("aabbcc") || header.contains("0x"),
|
||||
"header should contain fingerprint or ETH address, got: {header}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,8 @@ mod tests {
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
assert!(msgs.len() >= 2);
|
||||
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]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.25"
|
||||
version = "0.0.27"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
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.
|
||||
#[arg(short, long)]
|
||||
federation: Option<String>,
|
||||
|
||||
/// Enable bot API (disabled by default)
|
||||
#[arg(long, default_value = "false")]
|
||||
enable_bots: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -49,6 +53,36 @@ async fn main() -> anyhow::Result<()> {
|
||||
state.federation = Some(handle);
|
||||
}
|
||||
|
||||
// Enable bot API if requested
|
||||
state.bots_enabled = cli.enable_bots;
|
||||
if cli.enable_bots {
|
||||
tracing::info!("Bot API enabled");
|
||||
|
||||
// Auto-create BotFather if it doesn't exist
|
||||
let botfather_fp = "0000000000000000botfather00000000";
|
||||
let botfather_key = format!("bot_fp:{}", botfather_fp);
|
||||
if state.db.tokens.get(botfather_key.as_bytes()).ok().flatten().is_none() {
|
||||
let token = format!("botfather:{}", hex::encode(rand::random::<[u8; 16]>()));
|
||||
let bot_info = serde_json::json!({
|
||||
"name": "BotFather",
|
||||
"fingerprint": botfather_fp,
|
||||
"token": token,
|
||||
"owner": "system",
|
||||
"e2e": false,
|
||||
"created_at": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let key = format!("bot:{}", token);
|
||||
let _ = state.db.tokens.insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default());
|
||||
let _ = state.db.tokens.insert(botfather_key.as_bytes(), token.as_bytes());
|
||||
// Register alias
|
||||
let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes());
|
||||
let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather");
|
||||
tracing::info!("BotFather created: @botfather (token: {}...)", &token[..20]);
|
||||
} else {
|
||||
tracing::info!("BotFather already exists");
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn federation outgoing WS connection if enabled
|
||||
if let Some(ref fed) = state.federation {
|
||||
let handle = fed.clone();
|
||||
|
||||
@@ -49,12 +49,55 @@ pub fn routes() -> Router<AppState> {
|
||||
|
||||
/// Validate a bot token against the `tokens` sled tree.
|
||||
/// Returns the stored bot info JSON if the token is valid.
|
||||
/// Returns `None` if bots are disabled on this server instance.
|
||||
fn validate_bot_token(state: &AppState, token: &str) -> Option<serde_json::Value> {
|
||||
if !state.bots_enabled {
|
||||
return None;
|
||||
}
|
||||
let key = format!("bot:{}", token);
|
||||
let ivec = state.db.tokens.get(key.as_bytes()).ok()??;
|
||||
serde_json::from_slice(&ivec).ok()
|
||||
}
|
||||
|
||||
/// Resolve a `chat_id` that may be a string fingerprint, ETH address, or numeric ID.
|
||||
fn resolve_chat_id(state: &AppState, chat_id: &serde_json::Value) -> Option<String> {
|
||||
match chat_id {
|
||||
serde_json::Value::String(s) => {
|
||||
let clean: String = s
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
if clean.len() >= 16 {
|
||||
Some(clean)
|
||||
} else if s.starts_with("0x") {
|
||||
// ETH address -- resolve
|
||||
state
|
||||
.db
|
||||
.eth_addresses
|
||||
.get(s.to_lowercase().as_bytes())
|
||||
.ok()?
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string())
|
||||
} else {
|
||||
Some(s.clone())
|
||||
}
|
||||
}
|
||||
serde_json::Value::Number(n) => {
|
||||
let num = n.as_i64().unwrap_or(0);
|
||||
for item in state.db.keys.iter().flatten() {
|
||||
let key_str = String::from_utf8_lossy(&item.0).to_string();
|
||||
if !key_str.contains(':') && key_str.len() == 32 {
|
||||
if crate::routes::resolve::fp_to_numeric_id(&key_str) == num {
|
||||
return Some(key_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next update_id for a bot and atomically increment the counter.
|
||||
///
|
||||
/// The counter is stored in the `tokens` tree under `bot_update_id:<bot_fp>`.
|
||||
@@ -92,6 +135,91 @@ fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook delivery (public -- called from state.rs deliver_or_queue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check if a fingerprint belongs to a bot with a webhook, and deliver the message.
|
||||
///
|
||||
/// Called from `AppState::deliver_or_queue` after queueing. Returns `true` if
|
||||
/// the webhook accepted the update (HTTP 2xx), meaning the queued entry can be
|
||||
/// removed.
|
||||
pub async fn try_bot_webhook(state: &AppState, to_fp: &str, message: &[u8]) -> bool {
|
||||
// 1. Check if this fingerprint is a bot
|
||||
let token_key = format!("bot_fp:{}", to_fp);
|
||||
let token = match state.db.tokens.get(token_key.as_bytes()) {
|
||||
Ok(Some(v)) => String::from_utf8_lossy(&v).to_string(),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// 2. Load bot info and check for webhook URL
|
||||
let bot_info: serde_json::Value =
|
||||
match state.db.tokens.get(format!("bot:{}", token).as_bytes()) {
|
||||
Ok(Some(v)) => match serde_json::from_slice(&v) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let webhook_url = match bot_info.get("webhook_url").and_then(|v| v.as_str()) {
|
||||
Some(url) if !url.is_empty() => url.to_string(),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// 3. Build Telegram-style update from the raw message bytes
|
||||
let update = if let Ok(wire) =
|
||||
bincode::deserialize::<warzone_protocol::message::WireMessage>(message)
|
||||
{
|
||||
wire_message_to_update(&wire, message)
|
||||
} else if let Ok(bot_msg) = serde_json::from_slice::<serde_json::Value>(message) {
|
||||
bot_json_to_update(&bot_msg)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut update = match update {
|
||||
Some(u) => u,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// Assign a real update_id so the webhook consumer can track ordering
|
||||
let uid = next_update_id(state, to_fp);
|
||||
update["update_id"] = serde_json::json!(uid);
|
||||
|
||||
// 4. POST to webhook URL with a short timeout
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
match client
|
||||
.post(&webhook_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&update)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
tracing::info!("Webhook delivered to {} for bot {}", webhook_url, to_fp);
|
||||
true
|
||||
}
|
||||
Ok(resp) => {
|
||||
tracing::warn!(
|
||||
"Webhook {} returned {} for bot {}",
|
||||
webhook_url,
|
||||
resp.status(),
|
||||
to_fp
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Webhook {} failed for bot {}: {}", webhook_url, to_fp, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -100,6 +228,14 @@ fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value)
|
||||
struct RegisterBotRequest {
|
||||
name: String,
|
||||
fingerprint: String,
|
||||
#[serde(default)]
|
||||
bundle: Option<Vec<u8>>, // bincode PreKeyBundle for E2E bots
|
||||
#[serde(default)]
|
||||
eth_address: Option<String>,
|
||||
#[serde(default)]
|
||||
e2e: Option<bool>, // true = E2E bot, false/None = plaintext bot
|
||||
#[serde(default)]
|
||||
owner: Option<String>, // fingerprint of the bot creator
|
||||
}
|
||||
|
||||
/// Register a bot and receive a token.
|
||||
@@ -113,6 +249,12 @@ async fn register_bot(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterBotRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// TODO: In production, only @botfather should be able to register bots.
|
||||
// For v1, direct registration is allowed for development.
|
||||
if !state.bots_enabled {
|
||||
return Ok(Json(serde_json::json!({"ok": false, "description": "Bot API is disabled on this server. Use a server with --enable-bots"})));
|
||||
}
|
||||
|
||||
let fp = req
|
||||
.fingerprint
|
||||
.chars()
|
||||
@@ -131,6 +273,8 @@ async fn register_bot(
|
||||
"name": req.name,
|
||||
"fingerprint": fp,
|
||||
"token": token,
|
||||
"owner": req.owner.as_deref().unwrap_or(&fp),
|
||||
"e2e": req.e2e.unwrap_or(false),
|
||||
"created_at": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
|
||||
@@ -148,11 +292,29 @@ async fn register_bot(
|
||||
.tokens
|
||||
.insert(fp_key.as_bytes(), token.as_bytes())?;
|
||||
|
||||
// If E2E bot, register pre-key bundle (bot can receive encrypted messages)
|
||||
if req.e2e.unwrap_or(false) {
|
||||
if let Some(ref bundle_bytes) = req.bundle {
|
||||
let _ = state.db.keys.insert(fp.as_bytes(), bundle_bytes.as_slice());
|
||||
let device_key = format!("device:{}:bot", fp);
|
||||
let _ = state.db.keys.insert(device_key.as_bytes(), bundle_bytes.as_slice());
|
||||
tracing::info!("E2E bot: registered pre-key bundle for {}", fp);
|
||||
}
|
||||
}
|
||||
|
||||
// Store ETH address mapping if provided
|
||||
if let Some(ref eth) = req.eth_address {
|
||||
let eth_lower = eth.to_lowercase();
|
||||
let _ = state.db.eth_addresses.insert(eth_lower.as_bytes(), fp.as_bytes());
|
||||
let _ = state.db.eth_addresses.insert(format!("rev:{}", fp).as_bytes(), eth_lower.as_bytes());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Bot registered: {} ({}) token={}...",
|
||||
"Bot registered: {} ({}) token={}... e2e={}",
|
||||
req.name,
|
||||
fp,
|
||||
&token[..token.len().min(20)]
|
||||
&token[..token.len().min(20)],
|
||||
req.e2e.unwrap_or(false),
|
||||
);
|
||||
|
||||
// Auto-register bot alias (name must end with Bot or _bot)
|
||||
@@ -174,6 +336,7 @@ async fn register_bot(
|
||||
"name": req.name,
|
||||
"fingerprint": fp,
|
||||
"alias": format!("@{}", bot_alias),
|
||||
"e2e": req.e2e.unwrap_or(false),
|
||||
}
|
||||
})))
|
||||
}
|
||||
@@ -184,15 +347,19 @@ async fn get_me(
|
||||
Path(token): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
match validate_bot_token(&state, &token) {
|
||||
Some(info) => Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"id": info["fingerprint"],
|
||||
"is_bot": true,
|
||||
"first_name": info["name"],
|
||||
"username": info["name"],
|
||||
}
|
||||
})),
|
||||
Some(info) => {
|
||||
let fp = info["fingerprint"].as_str().unwrap_or("");
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"id": crate::routes::resolve::fp_to_numeric_id(fp),
|
||||
"id_str": fp,
|
||||
"is_bot": true,
|
||||
"first_name": info["name"],
|
||||
"username": info["name"],
|
||||
}
|
||||
}))
|
||||
}
|
||||
None => Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"description": "invalid token",
|
||||
@@ -273,7 +440,7 @@ async fn get_updates(
|
||||
|
||||
// Step 4: Long-poll if empty.
|
||||
if updates.is_empty() && timeout > 0 {
|
||||
let wait = std::cmp::min(timeout, 30);
|
||||
let wait = std::cmp::min(timeout, 50);
|
||||
// Poll in 1-second intervals so new messages are picked up promptly.
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait);
|
||||
loop {
|
||||
@@ -346,16 +513,19 @@ fn wire_message_to_update(
|
||||
..
|
||||
} => {
|
||||
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes);
|
||||
let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint);
|
||||
Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": id,
|
||||
"from": {
|
||||
"id": sender_fingerprint,
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"is_bot": false,
|
||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
},
|
||||
"chat": {
|
||||
"id": sender_fingerprint,
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"type": "private",
|
||||
},
|
||||
"date": chrono::Utc::now().timestamp(),
|
||||
@@ -370,16 +540,19 @@ fn wire_message_to_update(
|
||||
..
|
||||
} => {
|
||||
let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes);
|
||||
let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint);
|
||||
Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": id,
|
||||
"from": {
|
||||
"id": sender_fingerprint,
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"is_bot": false,
|
||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
},
|
||||
"chat": {
|
||||
"id": sender_fingerprint,
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"type": "private",
|
||||
},
|
||||
"date": chrono::Utc::now().timestamp(),
|
||||
@@ -394,51 +567,61 @@ fn wire_message_to_update(
|
||||
signal_type,
|
||||
payload,
|
||||
..
|
||||
} => Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": id,
|
||||
"from": {
|
||||
"id": sender_fingerprint,
|
||||
"is_bot": false,
|
||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
},
|
||||
"chat": {
|
||||
"id": sender_fingerprint,
|
||||
"type": "private",
|
||||
},
|
||||
"date": chrono::Utc::now().timestamp(),
|
||||
"text": format!("/call_{:?}", signal_type),
|
||||
"call_signal": {
|
||||
"type": format!("{:?}", signal_type),
|
||||
"payload": payload,
|
||||
},
|
||||
}
|
||||
})),
|
||||
} => {
|
||||
let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint);
|
||||
Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": id,
|
||||
"from": {
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"is_bot": false,
|
||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
},
|
||||
"chat": {
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"type": "private",
|
||||
},
|
||||
"date": chrono::Utc::now().timestamp(),
|
||||
"text": format!("/call_{:?}", signal_type),
|
||||
"call_signal": {
|
||||
"type": format!("{:?}", signal_type),
|
||||
"payload": payload,
|
||||
},
|
||||
}
|
||||
}))
|
||||
}
|
||||
warzone_protocol::message::WireMessage::FileHeader {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
filename,
|
||||
file_size,
|
||||
..
|
||||
} => Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": id,
|
||||
"from": {
|
||||
"id": sender_fingerprint,
|
||||
"is_bot": false,
|
||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
},
|
||||
"chat": {
|
||||
"id": sender_fingerprint,
|
||||
"type": "private",
|
||||
},
|
||||
"date": chrono::Utc::now().timestamp(),
|
||||
"document": {
|
||||
"file_name": filename,
|
||||
"file_size": file_size,
|
||||
},
|
||||
}
|
||||
})),
|
||||
} => {
|
||||
let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint);
|
||||
Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": id,
|
||||
"from": {
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"is_bot": false,
|
||||
"first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
},
|
||||
"chat": {
|
||||
"id": numeric,
|
||||
"id_str": sender_fingerprint,
|
||||
"type": "private",
|
||||
},
|
||||
"date": chrono::Utc::now().timestamp(),
|
||||
"document": {
|
||||
"file_name": filename,
|
||||
"file_size": file_size,
|
||||
},
|
||||
}
|
||||
}))
|
||||
}
|
||||
// Skip receipts and other variants.
|
||||
warzone_protocol::message::WireMessage::Receipt { .. } => None,
|
||||
_ => None,
|
||||
@@ -449,32 +632,43 @@ fn wire_message_to_update(
|
||||
fn bot_json_to_update(bot_msg: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?;
|
||||
match msg_type {
|
||||
"bot_message" => Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"from": {
|
||||
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"is_bot": true,
|
||||
},
|
||||
"chat": {
|
||||
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"type": "private",
|
||||
},
|
||||
"date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
"text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
}
|
||||
})),
|
||||
"callback_query" => Some(serde_json::json!({
|
||||
"callback_query": {
|
||||
"id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"from": {
|
||||
"id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"is_bot": false,
|
||||
},
|
||||
"data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"message": bot_msg.get("message"),
|
||||
}
|
||||
})),
|
||||
"bot_message" => {
|
||||
let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let numeric = crate::routes::resolve::fp_to_numeric_id(from_fp);
|
||||
Some(serde_json::json!({
|
||||
"message": {
|
||||
"message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"from": {
|
||||
"id": numeric,
|
||||
"id_str": from_fp,
|
||||
"is_bot": true,
|
||||
},
|
||||
"chat": {
|
||||
"id": numeric,
|
||||
"id_str": from_fp,
|
||||
"type": "private",
|
||||
},
|
||||
"date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
"text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
}
|
||||
}))
|
||||
}
|
||||
"callback_query" => {
|
||||
let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let numeric = crate::routes::resolve::fp_to_numeric_id(from_fp);
|
||||
Some(serde_json::json!({
|
||||
"callback_query": {
|
||||
"id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"from": {
|
||||
"id": numeric,
|
||||
"id_str": from_fp,
|
||||
"is_bot": false,
|
||||
},
|
||||
"data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"message": bot_msg.get("message"),
|
||||
}
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -504,7 +698,7 @@ fn collect_updates(state: &AppState, bot_fp: &str, limit: usize) -> Vec<serde_js
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendMessageRequest {
|
||||
chat_id: String,
|
||||
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
parse_mode: Option<String>,
|
||||
@@ -533,12 +727,12 @@ async fn send_message(
|
||||
}
|
||||
};
|
||||
|
||||
let to_fp = req
|
||||
.chat_id
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
let to_fp = match resolve_chat_id(&state, &req.chat_id) {
|
||||
Some(fp) => fp,
|
||||
None => {
|
||||
return Json(serde_json::json!({"ok": false, "description": "chat_id not found"}))
|
||||
}
|
||||
};
|
||||
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
|
||||
|
||||
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -608,7 +802,7 @@ async fn answer_callback_query(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditMessageRequest {
|
||||
chat_id: String,
|
||||
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
|
||||
message_id: String,
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
@@ -626,12 +820,12 @@ async fn edit_message_text(
|
||||
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
|
||||
};
|
||||
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
|
||||
let to_fp = req
|
||||
.chat_id
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
let to_fp = match resolve_chat_id(&state, &req.chat_id) {
|
||||
Some(fp) => fp,
|
||||
None => {
|
||||
return Json(serde_json::json!({"ok": false, "description": "chat_id not found"}))
|
||||
}
|
||||
};
|
||||
|
||||
let edit_msg = serde_json::json!({
|
||||
"type": "bot_edit",
|
||||
@@ -732,7 +926,7 @@ async fn get_webhook_info(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendDocumentRequest {
|
||||
chat_id: String,
|
||||
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
|
||||
/// File path, URL, or file_id reference. In v1, the reference is stored
|
||||
/// and forwarded as-is without server-side file hosting.
|
||||
document: String,
|
||||
@@ -751,12 +945,12 @@ async fn send_document(
|
||||
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
|
||||
};
|
||||
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
|
||||
let to_fp = req
|
||||
.chat_id
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
let to_fp = match resolve_chat_id(&state, &req.chat_id) {
|
||||
Some(fp) => fp,
|
||||
None => {
|
||||
return Json(serde_json::json!({"ok": false, "description": "chat_id not found"}))
|
||||
}
|
||||
};
|
||||
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let doc_msg = serde_json::json!({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mod aliases;
|
||||
pub mod auth;
|
||||
mod bot;
|
||||
pub mod bot;
|
||||
mod calls;
|
||||
mod devices;
|
||||
mod federation;
|
||||
|
||||
@@ -7,6 +7,20 @@ use axum::{
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Convert a fingerprint hex string to a stable i64 ID (for Telegram compatibility).
|
||||
/// Uses first 8 bytes of the fingerprint as a positive i64.
|
||||
pub fn fp_to_numeric_id(fp: &str) -> i64 {
|
||||
let clean: String = fp.chars().filter(|c| c.is_ascii_hexdigit()).take(16).collect();
|
||||
let bytes = hex::decode(&clean).unwrap_or_default();
|
||||
if bytes.len() >= 8 {
|
||||
let mut arr = [0u8; 8];
|
||||
arr.copy_from_slice(&bytes[..8]);
|
||||
i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/resolve/:address", get(resolve_address))
|
||||
}
|
||||
@@ -27,6 +41,7 @@ async fn resolve_address(
|
||||
return Ok(Json(serde_json::json!({
|
||||
"address": address,
|
||||
"fingerprint": fp,
|
||||
"numeric_id": fp_to_numeric_id(&fp),
|
||||
"type": "eth",
|
||||
})));
|
||||
}
|
||||
@@ -40,6 +55,7 @@ async fn resolve_address(
|
||||
return Ok(Json(serde_json::json!({
|
||||
"address": address,
|
||||
"fingerprint": fp,
|
||||
"numeric_id": fp_to_numeric_id(fp),
|
||||
"type": "eth",
|
||||
"federated": true,
|
||||
})));
|
||||
@@ -61,6 +77,7 @@ async fn resolve_address(
|
||||
return Ok(Json(serde_json::json!({
|
||||
"address": address,
|
||||
"fingerprint": fp,
|
||||
"numeric_id": fp_to_numeric_id(&fp),
|
||||
"type": "alias",
|
||||
})));
|
||||
}
|
||||
@@ -70,6 +87,7 @@ async fn resolve_address(
|
||||
return Ok(Json(serde_json::json!({
|
||||
"address": address,
|
||||
"fingerprint": fp,
|
||||
"numeric_id": fp_to_numeric_id(&fp),
|
||||
"type": "alias",
|
||||
"federated": true,
|
||||
})));
|
||||
@@ -93,6 +111,7 @@ async fn resolve_address(
|
||||
return Ok(Json(serde_json::json!({
|
||||
"address": address,
|
||||
"fingerprint": fp,
|
||||
"numeric_id": fp_to_numeric_id(&fp),
|
||||
"eth_address": eth,
|
||||
"type": "fingerprint",
|
||||
})));
|
||||
|
||||
@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
|
||||
|
||||
async fn service_worker() -> impl IntoResponse {
|
||||
([(header::CONTENT_TYPE, "application/javascript")], r##"
|
||||
const CACHE = 'wz-v7';
|
||||
const CACHE = 'wz-v9';
|
||||
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
@@ -241,7 +241,7 @@ let pollTimer = null;
|
||||
let ws = null; // WebSocket connection
|
||||
let wasmReady = false;
|
||||
|
||||
const VERSION = '0.0.25';
|
||||
const VERSION = '0.0.27';
|
||||
let DEBUG = true; // toggle with /debug command
|
||||
|
||||
// ── Receipt tracking ──
|
||||
@@ -547,7 +547,8 @@ function connectWebSocket() {
|
||||
msgText += '\\n';
|
||||
}
|
||||
}
|
||||
addMsg('@' + botName, msgText, false);
|
||||
const useHtml = json.parse_mode === 'HTML';
|
||||
addMsg('@' + botName, msgText, false, null, useHtml);
|
||||
lastDmPeer = json.from ? normFP(json.from) : '';
|
||||
return;
|
||||
}
|
||||
@@ -693,7 +694,8 @@ async function handleIncomingMessage(bytes) {
|
||||
msgText += '\\n';
|
||||
}
|
||||
}
|
||||
addMsg('@' + botName, msgText, false);
|
||||
const useHtml = json.parse_mode === 'HTML';
|
||||
addMsg('@' + botName, msgText, false, null, useHtml);
|
||||
lastDmPeer = json.from ? normFP(json.from) : '';
|
||||
return;
|
||||
}
|
||||
@@ -801,7 +803,7 @@ function formatSize(n) {
|
||||
return (n/1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function addMsg(from, text, isSelf, messageId) {
|
||||
function addMsg(from, text, isSelf, messageId, rawHtml) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
const color = isSelf ? '#4ade80' : peerColor(from);
|
||||
@@ -811,7 +813,8 @@ function addMsg(from, text, isSelf, messageId) {
|
||||
const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent';
|
||||
receiptHtml = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>';
|
||||
}
|
||||
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + makeAddressClickable(esc(from)) + '</span>: ' + makeAddressClickable(esc(text)) + receiptHtml;
|
||||
const bodyHtml = rawHtml ? text : makeAddressClickable(esc(text));
|
||||
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + makeAddressClickable(esc(from)) + '</span>: ' + bodyHtml + receiptHtml;
|
||||
// Attach click handler for .addr spans
|
||||
d.querySelectorAll('.addr').forEach(el => {
|
||||
el.addEventListener('click', () => handleAddrClick(el.dataset.addr));
|
||||
@@ -1202,6 +1205,22 @@ async function doSend() {
|
||||
|
||||
localStorage.setItem('wz-peer', $peerInput.value.trim());
|
||||
|
||||
// Check if peer is a bot — send plaintext instead of E2E
|
||||
let isBotPeer = false;
|
||||
try {
|
||||
const wr = await fetch(SERVER + '/v1/alias/whois/' + normFP(peer));
|
||||
const wd = await wr.json();
|
||||
if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot'))) isBotPeer = true;
|
||||
} catch(e) {}
|
||||
|
||||
if (isBotPeer) {
|
||||
const msgId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString();
|
||||
const botMsg = {type:'bot_message',id:msgId,from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:text,timestamp:Math.floor(Date.now()/1000)};
|
||||
await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:normFP(peer),from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(botMsg)))})});
|
||||
addMsg((myEthAddress ? myEthAddress.slice(0,12)+'...' : myFingerprint.slice(0,19)), text, true, msgId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const msgId = await sendEncrypted(peer, text);
|
||||
sentMsgReceipts[msgId] = { status: 'sent', el: null };
|
||||
|
||||
@@ -88,6 +88,7 @@ pub struct AppState {
|
||||
pub dedup: DedupTracker,
|
||||
pub active_calls: Arc<Mutex<HashMap<String, CallState>>>,
|
||||
pub federation: Option<crate::federation::FederationHandle>,
|
||||
pub bots_enabled: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -99,6 +100,7 @@ impl AppState {
|
||||
dedup: DedupTracker::new(),
|
||||
active_calls: Arc::new(Mutex::new(HashMap::new())),
|
||||
federation: None,
|
||||
bots_enabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -188,6 +190,21 @@ impl AppState {
|
||||
// 3. Queue in local DB
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = self.db.messages.insert(key.as_bytes(), message);
|
||||
|
||||
// 4. Try bot webhook delivery (async, does not block the caller)
|
||||
{
|
||||
let state = self.clone();
|
||||
let fp = to_fp.to_string();
|
||||
let queue_key = key.clone();
|
||||
let msg = message.to_vec();
|
||||
tokio::spawn(async move {
|
||||
if crate::routes::bot::try_bot_webhook(&state, &fp, &msg).await {
|
||||
// Webhook accepted -- remove from offline queue
|
||||
let _ = state.db.messages.remove(queue_key.as_bytes());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
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