v0.0.29: BotFather — create bots by messaging @botfather
Built-in BotFather (Rust, server-side): - Intercepts messages to @botfather in deliver_or_queue - Commands: /newbot <name>, /mybots, /deletebot <name>, /token <name> - Creates bot with fingerprint, token, alias, tracks ownership - Replies via push_to_client or queue (works offline) - Only active when --enable-bots is set Standalone BotFather (Python): - tools/botfather.py: uses bot API (getUpdates/sendMessage) - Delegates core ops to built-in handler - Extensible for additional features - Reads token from BOTFATHER_TOKEN env or .botfather_token file Flow: User messages @botfather → "/newbot MyBot" → gets token back 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.28"
|
version = "0.0.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -2989,7 +2989,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-mule"
|
name = "warzone-mule"
|
||||||
version = "0.0.28"
|
version = "0.0.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2998,7 +2998,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.28"
|
version = "0.0.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
@@ -3023,7 +3023,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-server"
|
name = "warzone-server"
|
||||||
version = "0.0.28"
|
version = "0.0.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3053,7 +3053,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-wasm"
|
name = "warzone-wasm"
|
||||||
version = "0.0.28"
|
version = "0.0.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.28"
|
version = "0.0.29"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.28"
|
version = "0.0.29"
|
||||||
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)"
|
||||||
|
|||||||
280
warzone/crates/warzone-server/src/botfather.rs
Normal file
280
warzone/crates/warzone-server/src/botfather.rs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
//! Built-in BotFather: processes messages to @botfather and manages bot lifecycle.
|
||||||
|
//!
|
||||||
|
//! Supports: /start, /newbot, /mybots, /deletebot, /help
|
||||||
|
//! Runs as a server-side handler — no external process needed.
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const BOTFATHER_FP: &str = "0000000000000000botfather00000000";
|
||||||
|
|
||||||
|
/// Check if a message is destined for BotFather and handle it.
|
||||||
|
/// Called from deliver_or_queue when the recipient is the BotFather fingerprint.
|
||||||
|
/// Returns true if handled (message consumed).
|
||||||
|
pub async fn handle_botfather_message(state: &AppState, from_fp: &str, message: &[u8]) -> bool {
|
||||||
|
if !state.bots_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as plaintext bot_message JSON
|
||||||
|
let bot_msg: serde_json::Value = match serde_json::from_slice(message) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return false, // Encrypted messages can't be processed by built-in handler
|
||||||
|
};
|
||||||
|
|
||||||
|
if bot_msg.get("type").and_then(|v| v.as_str()) != Some("bot_message") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = bot_msg
|
||||||
|
.get("text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
let from_name = bot_msg
|
||||||
|
.get("from_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(from_fp);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"BotFather: message from {} ({}): {}",
|
||||||
|
from_fp,
|
||||||
|
from_name,
|
||||||
|
text
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = match text {
|
||||||
|
"/start" | "/help" => {
|
||||||
|
"Welcome to BotFather! I can help you create and manage bots.\n\n\
|
||||||
|
Commands:\n\
|
||||||
|
/newbot - Create a new bot\n\
|
||||||
|
/mybots - List your bots\n\
|
||||||
|
/deletebot <name> - Delete a bot\n\
|
||||||
|
/token <name> - Get bot token\n\
|
||||||
|
/help - Show this message"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
t if t.starts_with("/newbot") => handle_newbot(state, from_fp, t).await,
|
||||||
|
t if t.starts_with("/deletebot") => handle_deletebot(state, from_fp, t).await,
|
||||||
|
"/mybots" => handle_mybots(state, from_fp).await,
|
||||||
|
t if t.starts_with("/token") => handle_token(state, from_fp, t).await,
|
||||||
|
_ => "I don't understand that command. Try /help".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send response back to the user
|
||||||
|
send_botfather_reply(state, from_fp, &response).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_newbot(state: &AppState, owner_fp: &str, text: &str) -> String {
|
||||||
|
// Parse: /newbot <name>
|
||||||
|
let name = text.strip_prefix("/newbot").unwrap_or("").trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return "Usage: /newbot <botname>\n\nExample: /newbot WeatherBot\n\n\
|
||||||
|
The name must end with 'bot' or 'Bot'."
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name
|
||||||
|
if name.len() > 32 || name.len() < 3 {
|
||||||
|
return "Bot name must be 3-32 characters.".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let name_lower = name.to_lowercase();
|
||||||
|
if !name_lower.ends_with("bot") {
|
||||||
|
return "Bot name must end with 'bot' or 'Bot'. Example: WeatherBot, my_bot".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if alias is taken
|
||||||
|
let alias_key = format!("a:{}", name_lower);
|
||||||
|
if state
|
||||||
|
.db
|
||||||
|
.aliases
|
||||||
|
.get(alias_key.as_bytes())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return format!(
|
||||||
|
"Sorry, @{} is already taken. Try a different name.",
|
||||||
|
name_lower
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate fingerprint and token
|
||||||
|
let fp_bytes: [u8; 16] = rand::random();
|
||||||
|
let fp = hex::encode(fp_bytes);
|
||||||
|
let token_rand: [u8; 16] = rand::random();
|
||||||
|
let token = format!("{}:{}", &fp[..16], hex::encode(token_rand));
|
||||||
|
|
||||||
|
// Store bot info
|
||||||
|
let bot_info = serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"fingerprint": fp,
|
||||||
|
"token": token,
|
||||||
|
"owner": owner_fp,
|
||||||
|
"e2e": false,
|
||||||
|
"created_at": chrono::Utc::now().timestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let bot_key = format!("bot:{}", token);
|
||||||
|
let _ = state.db.tokens.insert(
|
||||||
|
bot_key.as_bytes(),
|
||||||
|
serde_json::to_vec(&bot_info).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let fp_key = format!("bot_fp:{}", fp);
|
||||||
|
let _ = state.db.tokens.insert(fp_key.as_bytes(), token.as_bytes());
|
||||||
|
|
||||||
|
// Register alias
|
||||||
|
let _ = state
|
||||||
|
.db
|
||||||
|
.aliases
|
||||||
|
.insert(alias_key.as_bytes(), fp.as_bytes());
|
||||||
|
let _ = state
|
||||||
|
.db
|
||||||
|
.aliases
|
||||||
|
.insert(format!("fp:{}", fp).as_bytes(), name_lower.as_bytes());
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"BotFather: created bot @{} for owner {}",
|
||||||
|
name_lower,
|
||||||
|
owner_fp
|
||||||
|
);
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"Done! Your new bot @{} is ready.\n\n\
|
||||||
|
Token: {}\n\n\
|
||||||
|
Keep this token secret! Use it to access the Bot API.\n\n\
|
||||||
|
API endpoint: /v1/bot/{}/getUpdates",
|
||||||
|
name_lower, token, token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_deletebot(state: &AppState, owner_fp: &str, text: &str) -> String {
|
||||||
|
let name = text
|
||||||
|
.strip_prefix("/deletebot")
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_lowercase();
|
||||||
|
if name.is_empty() {
|
||||||
|
return "Usage: /deletebot <botname>".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the bot
|
||||||
|
let alias_key = format!("a:{}", name);
|
||||||
|
let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() {
|
||||||
|
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||||
|
None => return format!("Bot @{} not found.", name),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get bot info to verify ownership
|
||||||
|
let token_key = format!("bot_fp:{}", bot_fp);
|
||||||
|
let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() {
|
||||||
|
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||||
|
None => return format!("Bot @{} not found in registry.", name),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bot_key = format!("bot:{}", token);
|
||||||
|
if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() {
|
||||||
|
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&info_bytes) {
|
||||||
|
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if owner != owner_fp && owner != "system" {
|
||||||
|
return format!("You don't own @{}. Only the owner can delete it.", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete everything
|
||||||
|
let _ = state.db.tokens.remove(bot_key.as_bytes());
|
||||||
|
let _ = state.db.tokens.remove(token_key.as_bytes());
|
||||||
|
let _ = state.db.aliases.remove(alias_key.as_bytes());
|
||||||
|
let _ = state
|
||||||
|
.db
|
||||||
|
.aliases
|
||||||
|
.remove(format!("fp:{}", bot_fp).as_bytes());
|
||||||
|
let _ = state.db.keys.remove(bot_fp.as_bytes());
|
||||||
|
|
||||||
|
tracing::info!("BotFather: deleted bot @{} by owner {}", name, owner_fp);
|
||||||
|
format!("Bot @{} has been deleted.", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_mybots(state: &AppState, owner_fp: &str) -> String {
|
||||||
|
let mut bots = Vec::new();
|
||||||
|
|
||||||
|
for item in state.db.tokens.iter().flatten() {
|
||||||
|
let key = String::from_utf8_lossy(&item.0).to_string();
|
||||||
|
if !key.starts_with("bot:") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&item.1) {
|
||||||
|
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if owner == owner_fp {
|
||||||
|
let name = info.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let e2e = info.get("e2e").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let mode = if e2e { "E2E" } else { "plaintext" };
|
||||||
|
bots.push(format!(" @{} ({})", name.to_lowercase(), mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bots.is_empty() {
|
||||||
|
"You have no bots. Use /newbot <name> to create one.".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Your bots ({}):\n{}", bots.len(), bots.join("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_token(state: &AppState, owner_fp: &str, text: &str) -> String {
|
||||||
|
let name = text
|
||||||
|
.strip_prefix("/token")
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_lowercase();
|
||||||
|
if name.is_empty() {
|
||||||
|
return "Usage: /token <botname>".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let alias_key = format!("a:{}", name);
|
||||||
|
let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() {
|
||||||
|
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||||
|
None => return format!("Bot @{} not found.", name),
|
||||||
|
};
|
||||||
|
|
||||||
|
let token_key = format!("bot_fp:{}", bot_fp);
|
||||||
|
let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() {
|
||||||
|
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||||
|
None => return format!("Token not found for @{}.", name),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
let bot_key = format!("bot:{}", token);
|
||||||
|
if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() {
|
||||||
|
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&info_bytes) {
|
||||||
|
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if owner != owner_fp {
|
||||||
|
return format!("You don't own @{}.", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("Token for @{}:\n{}", name, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a reply from BotFather to a user.
|
||||||
|
async fn send_botfather_reply(state: &AppState, to_fp: &str, text: &str) {
|
||||||
|
let msg = serde_json::json!({
|
||||||
|
"type": "bot_message",
|
||||||
|
"id": uuid::Uuid::new_v4().to_string(),
|
||||||
|
"from": BOTFATHER_FP,
|
||||||
|
"from_name": "BotFather",
|
||||||
|
"text": text,
|
||||||
|
"timestamp": chrono::Utc::now().timestamp(),
|
||||||
|
});
|
||||||
|
let msg_bytes = serde_json::to_vec(&msg).unwrap_or_default();
|
||||||
|
|
||||||
|
// Deliver directly (don't go through deliver_or_queue to avoid recursion)
|
||||||
|
if !state.push_to_client(to_fp, &msg_bytes).await {
|
||||||
|
// Queue for offline pickup
|
||||||
|
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||||
|
let _ = state.db.messages.insert(key.as_bytes(), msg_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod auth_middleware;
|
pub mod auth_middleware;
|
||||||
|
pub mod botfather;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
|
mod botfather;
|
||||||
pub mod auth_middleware;
|
pub mod auth_middleware;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
|
|||||||
@@ -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-v10';
|
const CACHE = 'wz-v11';
|
||||||
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.28';
|
const VERSION = '0.0.29';
|
||||||
let DEBUG = true; // toggle with /debug command
|
let DEBUG = true; // toggle with /debug command
|
||||||
|
|
||||||
// ── Receipt tracking ──
|
// ── Receipt tracking ──
|
||||||
|
|||||||
@@ -173,6 +173,19 @@ impl AppState {
|
|||||||
/// Try to deliver a message: local push → federation forward → DB queue.
|
/// Try to deliver a message: local push → federation forward → DB queue.
|
||||||
/// Returns true if delivered instantly (local or remote).
|
/// Returns true if delivered instantly (local or remote).
|
||||||
pub async fn deliver_or_queue(&self, to_fp: &str, message: &[u8]) -> bool {
|
pub async fn deliver_or_queue(&self, to_fp: &str, message: &[u8]) -> bool {
|
||||||
|
// BotFather: intercept messages to @botfather
|
||||||
|
if self.bots_enabled && to_fp == "0000000000000000botfather00000000" {
|
||||||
|
// Extract sender from message
|
||||||
|
if let Ok(msg) = serde_json::from_slice::<serde_json::Value>(message) {
|
||||||
|
let from = msg.get("from").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if !from.is_empty() {
|
||||||
|
if crate::botfather::handle_botfather_message(self, from, message).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Try local WebSocket push
|
// 1. Try local WebSocket push
|
||||||
if self.push_to_client(to_fp, message).await {
|
if self.push_to_client(to_fp, message).await {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
195
warzone/tools/botfather.py
Executable file
195
warzone/tools/botfather.py
Executable file
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
featherChat BotFather (Standalone)
|
||||||
|
|
||||||
|
A Telegram-style BotFather that manages bot creation via chat.
|
||||||
|
Uses the featherChat Bot API — runs as a regular bot process.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python botfather.py --server http://localhost:7700
|
||||||
|
|
||||||
|
On first run, it registers itself as @botfather if not already registered.
|
||||||
|
Subsequent runs reuse the stored token from .botfather_token file.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
/start, /help - Show help
|
||||||
|
/newbot <name> - Create a new bot (name must end with bot/Bot)
|
||||||
|
/mybots - List your bots
|
||||||
|
/deletebot <n> - Delete a bot you own
|
||||||
|
/token <name> - Show token for your bot
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import URLError
|
||||||
|
|
||||||
|
TOKEN_FILE = ".botfather_token"
|
||||||
|
|
||||||
|
|
||||||
|
def api(server, token, method, data=None):
|
||||||
|
"""Call a bot API method."""
|
||||||
|
url = f"{server}/v1/bot/{token}/{method}"
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
req = Request(url, data=body, method="POST" if body else "GET")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=60) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except URLError as e:
|
||||||
|
print(f"API error ({method}): {e}")
|
||||||
|
return {"ok": False}
|
||||||
|
|
||||||
|
|
||||||
|
def send(server, token, chat_id, text):
|
||||||
|
"""Send a message."""
|
||||||
|
return api(server, token, "sendMessage", {"chat_id": chat_id, "text": text})
|
||||||
|
|
||||||
|
|
||||||
|
def register_botfather(server):
|
||||||
|
"""Register BotFather with the server. Returns token."""
|
||||||
|
# BotFather registers itself — it needs the built-in BotFather token
|
||||||
|
# to authorize. Read it from the server's initial log or pass via env.
|
||||||
|
builtin_token = os.environ.get("BOTFATHER_TOKEN", "")
|
||||||
|
if not builtin_token:
|
||||||
|
print("ERROR: Set BOTFATHER_TOKEN env var to the token from server logs")
|
||||||
|
print(" (printed on first --enable-bots start)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Use the built-in token directly
|
||||||
|
return builtin_token
|
||||||
|
|
||||||
|
|
||||||
|
def handle_message(server, token, msg):
|
||||||
|
"""Process a message and respond."""
|
||||||
|
text = (msg.get("text") or "").strip()
|
||||||
|
chat_id = msg.get("chat", {}).get("id", "")
|
||||||
|
from_id = msg.get("from", {}).get("id_str") or str(msg.get("from", {}).get("id", ""))
|
||||||
|
|
||||||
|
if not text or not chat_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"[{from_id[:16]}] {text}")
|
||||||
|
|
||||||
|
if text in ("/start", "/help"):
|
||||||
|
send(server, token, chat_id,
|
||||||
|
"Welcome to BotFather! I manage bots on featherChat.\n\n"
|
||||||
|
"Commands:\n"
|
||||||
|
"/newbot <name> - Create a bot (name must end with bot/Bot)\n"
|
||||||
|
"/mybots - List your bots\n"
|
||||||
|
"/deletebot <name> - Delete your bot\n"
|
||||||
|
"/token <name> - Get bot token\n"
|
||||||
|
"/help - Show this message")
|
||||||
|
|
||||||
|
elif text.startswith("/newbot"):
|
||||||
|
name = text.replace("/newbot", "").strip()
|
||||||
|
if not name:
|
||||||
|
send(server, token, chat_id, "Usage: /newbot <botname>\nExample: /newbot WeatherBot")
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(name) < 3 or len(name) > 32:
|
||||||
|
send(server, token, chat_id, "Bot name must be 3-32 characters.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not name.lower().endswith("bot"):
|
||||||
|
send(server, token, chat_id, "Bot name must end with 'bot' or 'Bot'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create the bot via internal API
|
||||||
|
fp = os.urandom(16).hex()
|
||||||
|
resp = api(server, token, "../register", {
|
||||||
|
"name": name,
|
||||||
|
"fingerprint": fp,
|
||||||
|
"botfather_token": token,
|
||||||
|
"owner": from_id
|
||||||
|
})
|
||||||
|
|
||||||
|
if resp.get("ok"):
|
||||||
|
result = resp["result"]
|
||||||
|
send(server, token, chat_id,
|
||||||
|
f"Done! Your new bot @{result.get('alias', name.lower())} is ready.\n\n"
|
||||||
|
f"Token: {result['token']}\n\n"
|
||||||
|
f"Keep this token secret!")
|
||||||
|
else:
|
||||||
|
send(server, token, chat_id, f"Failed: {resp.get('description', 'unknown error')}")
|
||||||
|
|
||||||
|
elif text == "/mybots":
|
||||||
|
send(server, token, chat_id,
|
||||||
|
"Use the built-in /mybots via chat with @botfather.\n"
|
||||||
|
"(The built-in handler tracks ownership.)")
|
||||||
|
|
||||||
|
elif text.startswith("/deletebot"):
|
||||||
|
name = text.replace("/deletebot", "").strip()
|
||||||
|
if not name:
|
||||||
|
send(server, token, chat_id, "Usage: /deletebot <botname>")
|
||||||
|
return
|
||||||
|
send(server, token, chat_id,
|
||||||
|
f"Use the built-in /deletebot {name} via chat with @botfather.\n"
|
||||||
|
"(The built-in handler verifies ownership.)")
|
||||||
|
|
||||||
|
elif text.startswith("/token"):
|
||||||
|
name = text.replace("/token", "").strip()
|
||||||
|
if not name:
|
||||||
|
send(server, token, chat_id, "Usage: /token <botname>")
|
||||||
|
return
|
||||||
|
send(server, token, chat_id,
|
||||||
|
f"Use the built-in /token {name} via chat with @botfather.\n"
|
||||||
|
"(The built-in handler verifies ownership.)")
|
||||||
|
|
||||||
|
else:
|
||||||
|
send(server, token, chat_id, "Unknown command. Try /help")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="featherChat BotFather (standalone)")
|
||||||
|
parser.add_argument("--server", required=True, help="featherChat server URL")
|
||||||
|
parser.add_argument("--token", help="BotFather token (or set BOTFATHER_TOKEN env)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
token = args.token or os.environ.get("BOTFATHER_TOKEN", "")
|
||||||
|
|
||||||
|
# Try loading from file
|
||||||
|
if not token and os.path.exists(TOKEN_FILE):
|
||||||
|
token = open(TOKEN_FILE).read().strip()
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
token = register_botfather(args.server)
|
||||||
|
|
||||||
|
# Save token
|
||||||
|
with open(TOKEN_FILE, "w") as f:
|
||||||
|
f.write(token)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
me = api(args.server, token, "getMe")
|
||||||
|
if not me.get("ok"):
|
||||||
|
print(f"ERROR: Invalid token. Delete {TOKEN_FILE} and retry.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
bot_name = me["result"].get("first_name", "BotFather")
|
||||||
|
print(f"BotFather ({bot_name}) running")
|
||||||
|
print(f"Server: {args.server}")
|
||||||
|
print(f"Polling for messages...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
resp = api(args.server, token, "getUpdates", {"offset": offset, "timeout": 30})
|
||||||
|
for update in resp.get("result", []):
|
||||||
|
offset = update["update_id"] + 1
|
||||||
|
msg = update.get("message", {})
|
||||||
|
if msg:
|
||||||
|
handle_message(args.server, token, msg)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nBotFather stopped.")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user