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>
176 lines
6.3 KiB
Python
Executable File
176 lines
6.3 KiB
Python
Executable File
#!/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()
|