feat: MCP bridge - Telegram group logger + FastMCP HTTP server
Single-process Python app that: - Runs a Telegram bot in a group chat, logging all messages/files to libsql - Exposes send_message, pull_updates, queue_status MCP tools over HTTP - Downloads and stores file attachments with Telegram file_id + local path - Accessible via NetBird mesh at mgmt.mg:8321 (no auth needed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c339980411
commit
1cb16e6e8f
13 changed files with 1249 additions and 1 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
media/
|
||||
credentials
|
||||
.build-complete
|
||||
heartbeat.sh
|
||||
165
README.md
165
README.md
|
|
@ -1,2 +1,165 @@
|
|||
# telegram-bot-mcp
|
||||
# Nexus MCP Bridge
|
||||
|
||||
MCP server that bridges claude.ai to a homelab Telegram group chat. Logs all messages and files to libsql, exposes three MCP tools over HTTP on the NetBird mesh.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
claude.ai ──HTTP──► MCP Bridge (mgmt.mg:8321) ──Telegram API──► Group Chat
|
||||
│ │
|
||||
libsql (local) Mikkel + Homelab Bot + MCP Bot
|
||||
media/ (files)
|
||||
```
|
||||
|
||||
Single Python process running:
|
||||
- **Telegram bot** — polls group chat, logs everything to libsql, sends outbound messages
|
||||
- **FastMCP HTTP server** — exposes `send_message`, `pull_updates`, `queue_status` tools
|
||||
|
||||
## Setup Guide
|
||||
|
||||
### Step 1: Create a new Telegram bot
|
||||
|
||||
1. Open Telegram, message [@BotFather](https://t.me/BotFather)
|
||||
2. Send `/newbot`
|
||||
3. Name: `Nexus MCP Bridge` (or whatever you like)
|
||||
4. Username: `nexus_mcp_bot` (must be unique, pick something available)
|
||||
5. Copy the **bot token** BotFather gives you
|
||||
|
||||
### Step 2: Create the Telegram group
|
||||
|
||||
1. In Telegram, create a **new group**
|
||||
2. Name: `Homelab Bridge` (or your preference)
|
||||
3. Add members:
|
||||
- Your existing homelab bot (`@georgsen_homelab_bot`)
|
||||
- The new MCP bot (search by the username you chose)
|
||||
4. **Important:** Make both bots **group admins** so they can read all messages
|
||||
- Tap group name → Edit → Administrators → Add both bots
|
||||
- Bots need at minimum: "Read messages" permission (enabled by default for admins)
|
||||
|
||||
### Step 3: Get the group chat ID
|
||||
|
||||
Option A — Send a message in the group, then check:
|
||||
```bash
|
||||
curl -s "https://api.telegram.org/bot<MCP_BOT_TOKEN>/getUpdates" | python3 -m json.tool
|
||||
```
|
||||
Look for `"chat": {"id": -100XXXXXXXXXX}` — the negative number is the group chat ID.
|
||||
|
||||
Option B — The bridge logs the chat ID on startup. You can start it once, send a message in the group, and check the logs.
|
||||
|
||||
### Step 4: Configure credentials
|
||||
|
||||
```bash
|
||||
cd ~/repos/telegram-bot-mcp
|
||||
cp credentials.example credentials
|
||||
```
|
||||
|
||||
Edit `credentials`:
|
||||
```
|
||||
MCP_BOT_TOKEN=<token from BotFather>
|
||||
GROUP_CHAT_ID=<negative number from step 3>
|
||||
HOMELAB_BOT_ID=8521598773
|
||||
```
|
||||
|
||||
### Step 5: Install and test
|
||||
|
||||
```bash
|
||||
cd ~/repos/telegram-bot-mcp
|
||||
|
||||
# Create venv (if not already done)
|
||||
python3 -m venv .venv
|
||||
|
||||
# Install dependencies
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
|
||||
# Test run (Ctrl+C to stop)
|
||||
.venv/bin/python -m mcp_bridge
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
Database initialized at /home/mikkel/repos/telegram-bot-mcp/data/bridge.db
|
||||
MCP server starting on 0.0.0.0:8321
|
||||
Telegram bot polling started
|
||||
MCP Bridge bot started as @nexus_mcp_bot (ID: XXXXXXX)
|
||||
Monitoring group chat: -100XXXXXXXXXX
|
||||
```
|
||||
|
||||
Send a message in the group — you should see it logged.
|
||||
|
||||
### Step 6: Install systemd service
|
||||
|
||||
```bash
|
||||
cp mcp-bridge.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now mcp-bridge
|
||||
systemctl --user status mcp-bridge
|
||||
```
|
||||
|
||||
View logs:
|
||||
```bash
|
||||
journalctl --user -u mcp-bridge -f
|
||||
```
|
||||
|
||||
### Step 7: Configure claude.ai MCP connection
|
||||
|
||||
In claude.ai settings, add a new MCP server:
|
||||
|
||||
- **URL:** `http://mgmt.mg:8321/mcp`
|
||||
- **Transport:** Streamable HTTP
|
||||
|
||||
This works because mgmt.mg resolves via NetBird mesh — no public exposure needed.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### send_message
|
||||
Send a message to the group, attributed as `[claude.ai]`.
|
||||
|
||||
```json
|
||||
{"message": "Fix the nexus.mg DNS record to 100.79.65.206"}
|
||||
```
|
||||
|
||||
### pull_updates
|
||||
Get conversation messages with cursor-based pagination.
|
||||
|
||||
```json
|
||||
{"since_id": 0, "limit": 50}
|
||||
```
|
||||
|
||||
Returns messages from all participants with attachment metadata. Use the `cursor` value from the response as `since_id` in the next call.
|
||||
|
||||
### queue_status
|
||||
Quick summary: total messages, last activity, pending outbound count.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
├── mcp_bridge/
|
||||
│ ├── __main__.py # Entry point (bot + MCP server)
|
||||
│ ├── config.py # Configuration loader
|
||||
│ ├── db.py # libsql database layer
|
||||
│ ├── telegram_bot.py # Telegram bot (logging + sending)
|
||||
│ ├── mcp_server.py # FastMCP tool definitions
|
||||
│ └── models.py # Data models
|
||||
├── data/ # libsql database (auto-created)
|
||||
├── media/ # Downloaded attachments (auto-created)
|
||||
├── credentials # Bot token + chat ID (not in git)
|
||||
├── credentials.example # Template
|
||||
├── requirements.txt
|
||||
└── mcp-bridge.service # systemd unit file
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Bot doesn't see group messages:**
|
||||
- Ensure bot is a group admin
|
||||
- Check BotFather: `/mybots` → Bot Settings → Group Privacy → Turn OFF
|
||||
(bots have "privacy mode" ON by default — they only see commands unless it's disabled)
|
||||
|
||||
**Can't get group chat ID:**
|
||||
- Make sure you sent a message AFTER adding the bot
|
||||
- For supergroups, the ID starts with `-100`
|
||||
|
||||
**MCP server unreachable from claude.ai:**
|
||||
- Verify NetBird is connected: `netbird status`
|
||||
- Test locally: `curl http://mgmt.mg:8321/mcp`
|
||||
- Check firewall: port 8321 must be open
|
||||
|
|
|
|||
13
credentials.example
Normal file
13
credentials.example
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# MCP Bridge Bot credentials
|
||||
# Copy to 'credentials' and fill in values
|
||||
|
||||
# Bot token from BotFather (create a NEW bot for the MCP bridge)
|
||||
MCP_BOT_TOKEN=
|
||||
|
||||
# Telegram group chat ID (negative number for groups)
|
||||
# Send a message in the group, then check: https://api.telegram.org/bot<TOKEN>/getUpdates
|
||||
GROUP_CHAT_ID=
|
||||
|
||||
# (Optional) Bot ID of the existing homelab bot, for sender classification
|
||||
# Find it: https://api.telegram.org/bot<HOMELAB_TOKEN>/getMe
|
||||
HOMELAB_BOT_ID=8521598773
|
||||
237
docs/2026-03-30-nexus-mcp-bridge-design.md
Normal file
237
docs/2026-03-30-nexus-mcp-bridge-design.md
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
# Nexus MCP Bridge — Design Spec
|
||||
|
||||
**Date:** 2026-03-30
|
||||
**Status:** Approved (user greenlit Approach 1, all design questions resolved)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
A single-process Python application that:
|
||||
1. Runs a **Telegram bot** in a group chat, logging all messages/files to **libsql**
|
||||
2. Exposes an **MCP server** (FastMCP over HTTP) on the NetBird mesh for claude.ai to query
|
||||
|
||||
This lets claude.ai dispatch tasks to the homelab agent and pull conversation updates — without Mikkel being the copy-paste middleman.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Question | Decision | Rationale |
|
||||
|----------|----------|-----------|
|
||||
| Telegram conflict | New bot + group chat | Each bot has own token, no polling conflict |
|
||||
| What to capture | Everything (all participants + files) | Full replay capability |
|
||||
| File storage | Download to disk + store file_id | Durability + convenience |
|
||||
| Auth | None (NetBird mesh is trust boundary) | Only accessible from enrolled peers |
|
||||
| Database | libsql embedded (local file) | Single process, no extra service |
|
||||
| Scope | Single group chat (homelab) for v1 | Nail core loop, extend later |
|
||||
| Bot personality | Attributed relay `[claude.ai] ...` | Clear attribution in group |
|
||||
| Architecture | Single process (FastMCP + telegram bot) | Simple, systemd restart covers failures |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ HTTP (NetBird) ┌─────────────────────────────┐
|
||||
│ claude.ai │ ◄──────────────────── │ MCP Bridge Process │
|
||||
│ MCP client │ ────────────────────► │ │
|
||||
└──────────────┘ │ ┌─────────┐ ┌──────────┐ │
|
||||
│ │ FastMCP │ │ Telegram │ │
|
||||
│ │ HTTP │ │ Bot │ │
|
||||
│ │ Server │ │ (polling)│ │
|
||||
│ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ┌─────▼─────┐ │
|
||||
│ │ libsql │ │
|
||||
│ │ (embed) │ │
|
||||
│ └───────────┘ │
|
||||
│ │
|
||||
│ media/ (downloaded files) │
|
||||
└─────────────────────────────┘
|
||||
│
|
||||
Telegram Group Chat
|
||||
┌─────▼─────┐
|
||||
│ Mikkel │
|
||||
│ Homelab ♦ │ (existing bot)
|
||||
│ MCP ♦ │ (new bot)
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (libsql)
|
||||
|
||||
```sql
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
telegram_message_id INTEGER NOT NULL,
|
||||
chat_id INTEGER NOT NULL,
|
||||
sender_type TEXT NOT NULL, -- 'user', 'homelab_bot', 'mcp_bot', 'unknown'
|
||||
sender_id INTEGER,
|
||||
sender_name TEXT,
|
||||
content TEXT, -- message text (nullable for media-only)
|
||||
reply_to_message_id INTEGER, -- telegram reply reference
|
||||
has_attachment INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL, -- ISO 8601
|
||||
UNIQUE(chat_id, telegram_message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL REFERENCES messages(id),
|
||||
file_type TEXT NOT NULL, -- 'photo', 'document', 'video', 'voice', 'sticker'
|
||||
file_id TEXT NOT NULL, -- telegram file_id
|
||||
file_unique_id TEXT NOT NULL, -- telegram file_unique_id
|
||||
file_name TEXT, -- original filename
|
||||
mime_type TEXT,
|
||||
file_size INTEGER,
|
||||
local_path TEXT, -- path under media/
|
||||
caption TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE outbound_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
attribution TEXT DEFAULT 'claude.ai', -- prefix for the message
|
||||
status TEXT DEFAULT 'pending', -- 'pending', 'sent', 'failed'
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT
|
||||
);
|
||||
|
||||
-- Index for delta queries
|
||||
CREATE INDEX idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX idx_messages_chat_id ON messages(chat_id);
|
||||
CREATE INDEX idx_outbound_status ON outbound_queue(status);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### send_message
|
||||
```
|
||||
Send a message to the homelab group chat, attributed as [claude.ai].
|
||||
|
||||
Input: { "message": "Fix the nexus.mg DNS record to 100.79.65.206" }
|
||||
Output: { "sent": true, "id": 42 }
|
||||
```
|
||||
|
||||
### pull_updates
|
||||
```
|
||||
Get conversation messages since a cursor (message ID or timestamp).
|
||||
Returns messages from all participants with attachment metadata.
|
||||
|
||||
Input: { "since_id": 150 } or { "since": "2026-03-30T01:00:00Z" } or {}
|
||||
Output: {
|
||||
"messages": [
|
||||
{
|
||||
"id": 151,
|
||||
"sender": "mikkel",
|
||||
"sender_type": "user",
|
||||
"content": "Can you check the DNS?",
|
||||
"attachments": [],
|
||||
"created_at": "2026-03-30T01:02:15Z"
|
||||
},
|
||||
{
|
||||
"id": 152,
|
||||
"sender": "homelab_bot",
|
||||
"sender_type": "homelab_bot",
|
||||
"content": "Checking Technitium... record found.",
|
||||
"attachments": [{"file_type": "photo", "file_name": "dns-screenshot.png"}],
|
||||
"created_at": "2026-03-30T01:02:45Z"
|
||||
}
|
||||
],
|
||||
"cursor": 152
|
||||
}
|
||||
```
|
||||
|
||||
### queue_status
|
||||
```
|
||||
Current state summary.
|
||||
|
||||
Input: {}
|
||||
Output: {
|
||||
"total_messages": 152,
|
||||
"last_message_at": "2026-03-30T01:02:45Z",
|
||||
"last_sender": "homelab_bot",
|
||||
"pending_outbound": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Telegram Bot Behavior
|
||||
|
||||
- **Joins group chat** with Mikkel + existing homelab bot
|
||||
- **Logs everything**: text, photos, documents, voice, stickers, replies
|
||||
- **Downloads attachments** to `media/<YYYY-MM-DD>/<file_unique_id>_<filename>`
|
||||
- **Sends outbound** messages prefixed with `[claude.ai]` in bold
|
||||
- **No commands** — this bot is a silent logger + relay, not interactive
|
||||
- **Ignores private messages** — only operates in the configured group
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
~/repos/telegram-bot-mcp/
|
||||
├── docs/ # Specs and design docs
|
||||
├── mcp_bridge/
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py # Entry point: runs both bot + MCP server
|
||||
│ ├── config.py # Configuration (env vars, paths)
|
||||
│ ├── db.py # libsql database layer
|
||||
│ ├── telegram_bot.py # Telegram bot (polling, logging, sending)
|
||||
│ ├── mcp_server.py # FastMCP tool definitions
|
||||
│ └── models.py # Shared data models
|
||||
├── media/ # Downloaded attachments
|
||||
├── data/ # libsql database file
|
||||
├── credentials # BOT_TOKEN, GROUP_CHAT_ID (created during setup)
|
||||
├── requirements.txt
|
||||
├── heartbeat.sh
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment/file based:
|
||||
- `credentials` file: `MCP_BOT_TOKEN=...`, `GROUP_CHAT_ID=...`, `HOMELAB_BOT_ID=...`
|
||||
- MCP server binds to `0.0.0.0:8321` (accessible via NetBird at `mgmt.mg:8321`)
|
||||
- Database at `data/bridge.db`
|
||||
- Media at `media/`
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
- **systemd user service** (`mcp-bridge.service`)
|
||||
- Uses project-local venv at `.venv/`
|
||||
- `Restart=always`, `RestartSec=5`
|
||||
- Bind to `0.0.0.0:8321` (NetBird interface)
|
||||
|
||||
---
|
||||
|
||||
## Setup Steps (for user)
|
||||
|
||||
1. Create new Telegram bot via BotFather → get token
|
||||
2. Create Telegram group → add Mikkel + homelab bot + MCP bot
|
||||
3. Get group chat ID (bot will log it on first message)
|
||||
4. Fill in `credentials` file
|
||||
5. `systemctl --user enable --now mcp-bridge`
|
||||
6. Add MCP URL `http://mgmt.mg:8321/mcp` in claude.ai settings
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
- Multi-group support (per-project chats with `target` parameter)
|
||||
- Session tracking (detect agent restarts)
|
||||
- File content search across attachments
|
||||
- Message threading/reply chain reconstruction
|
||||
19
mcp-bridge.service
Normal file
19
mcp-bridge.service
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=Nexus MCP Bridge (Telegram + MCP Server)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/mikkel/repos/telegram-bot-mcp
|
||||
ExecStart=/home/mikkel/repos/telegram-bot-mcp/.venv/bin/python -m mcp_bridge
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=15
|
||||
|
||||
Environment=PATH=/home/mikkel/.local/bin:/home/mikkel/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
0
mcp_bridge/__init__.py
Normal file
0
mcp_bridge/__init__.py
Normal file
84
mcp_bridge/__main__.py
Normal file
84
mcp_bridge/__main__.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Entry point: runs both Telegram bot and MCP server in one process."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
|
||||
from .config import MCP_HOST, MCP_PORT, MEDIA_DIR, DB_PATH
|
||||
from .db import Database
|
||||
from .telegram_bot import BridgeBot
|
||||
from .mcp_server import mcp, init as init_mcp
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("mcp_bridge")
|
||||
|
||||
|
||||
async def run_telegram_bot(bot: BridgeBot):
|
||||
"""Run the telegram bot polling loop."""
|
||||
app = bot.build_application()
|
||||
await app.initialize()
|
||||
await app.start()
|
||||
updater = app.updater
|
||||
await updater.start_polling(drop_pending_updates=True)
|
||||
logger.info("Telegram bot polling started")
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Telegram bot shutting down...")
|
||||
await updater.stop()
|
||||
await app.stop()
|
||||
await app.shutdown()
|
||||
|
||||
|
||||
async def run_mcp_server():
|
||||
"""Run the FastMCP HTTP server using its built-in runner."""
|
||||
logger.info(f"MCP server starting on {MCP_HOST}:{MCP_PORT}")
|
||||
await mcp.run_http_async(
|
||||
host=MCP_HOST,
|
||||
port=MCP_PORT,
|
||||
log_level="info",
|
||||
show_banner=False,
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Start both services."""
|
||||
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
db = Database()
|
||||
logger.info(f"Database initialized at {DB_PATH}")
|
||||
|
||||
init_mcp(db)
|
||||
|
||||
bot = BridgeBot(db)
|
||||
|
||||
telegram_task = asyncio.create_task(run_telegram_bot(bot))
|
||||
mcp_task = asyncio.create_task(run_mcp_server())
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def handle_signal():
|
||||
logger.info("Received shutdown signal")
|
||||
telegram_task.cancel()
|
||||
mcp_task.cancel()
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, handle_signal)
|
||||
|
||||
try:
|
||||
await asyncio.gather(telegram_task, mcp_task)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("MCP Bridge stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
49
mcp_bridge/config.py
Normal file
49
mcp_bridge/config.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Configuration loader for MCP Bridge."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
CREDENTIALS_FILE = BASE_DIR / "credentials"
|
||||
DB_PATH = BASE_DIR / "data" / "bridge.db"
|
||||
MEDIA_DIR = BASE_DIR / "media"
|
||||
|
||||
# MCP server settings
|
||||
MCP_HOST = "0.0.0.0"
|
||||
MCP_PORT = 8321
|
||||
|
||||
|
||||
def load_credentials() -> dict[str, str]:
|
||||
"""Load credentials from KEY=VALUE file."""
|
||||
config = {}
|
||||
if CREDENTIALS_FILE.exists():
|
||||
with open(CREDENTIALS_FILE) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if "=" in line and not line.startswith("#"):
|
||||
key, value = line.split("=", 1)
|
||||
config[key.strip()] = value.strip()
|
||||
return config
|
||||
|
||||
|
||||
def get_bot_token() -> str:
|
||||
creds = load_credentials()
|
||||
token = creds.get("MCP_BOT_TOKEN", "")
|
||||
if not token:
|
||||
raise RuntimeError("MCP_BOT_TOKEN not set in credentials file")
|
||||
return token
|
||||
|
||||
|
||||
def get_group_chat_id() -> int:
|
||||
creds = load_credentials()
|
||||
chat_id = creds.get("GROUP_CHAT_ID", "")
|
||||
if not chat_id:
|
||||
raise RuntimeError("GROUP_CHAT_ID not set in credentials file")
|
||||
return int(chat_id)
|
||||
|
||||
|
||||
def get_homelab_bot_id() -> int | None:
|
||||
"""Optional: ID of the existing homelab bot for sender_type classification."""
|
||||
creds = load_credentials()
|
||||
bot_id = creds.get("HOMELAB_BOT_ID", "")
|
||||
return int(bot_id) if bot_id else None
|
||||
241
mcp_bridge/db.py
Normal file
241
mcp_bridge/db.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""Database layer using libsql (embedded)."""
|
||||
|
||||
import libsql_experimental as libsql
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
telegram_message_id INTEGER NOT NULL,
|
||||
chat_id INTEGER NOT NULL,
|
||||
sender_type TEXT NOT NULL,
|
||||
sender_id INTEGER,
|
||||
sender_name TEXT,
|
||||
content TEXT,
|
||||
reply_to_message_id INTEGER,
|
||||
has_attachment INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(chat_id, telegram_message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL REFERENCES messages(id),
|
||||
file_type TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
file_unique_id TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
mime_type TEXT,
|
||||
file_size INTEGER,
|
||||
local_path TEXT,
|
||||
caption TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbound_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
attribution TEXT DEFAULT 'claude.ai',
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
sent_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbound_status ON outbound_queue(status);
|
||||
"""
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_path: Path | None = None):
|
||||
self.db_path = db_path or DB_PATH
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.conn = libsql.connect(str(self.db_path))
|
||||
self._init_schema()
|
||||
|
||||
def _init_schema(self):
|
||||
for statement in SCHEMA.split(";"):
|
||||
statement = statement.strip()
|
||||
if statement:
|
||||
self.conn.execute(statement)
|
||||
self.conn.commit()
|
||||
|
||||
def insert_message(
|
||||
self,
|
||||
telegram_message_id: int,
|
||||
chat_id: int,
|
||||
sender_type: str,
|
||||
sender_id: int | None,
|
||||
sender_name: str | None,
|
||||
content: str | None,
|
||||
reply_to_message_id: int | None,
|
||||
has_attachment: bool,
|
||||
created_at: str,
|
||||
) -> int | None:
|
||||
"""Insert a message. Returns row id, or None if duplicate."""
|
||||
try:
|
||||
cursor = self.conn.execute(
|
||||
"""INSERT INTO messages
|
||||
(telegram_message_id, chat_id, sender_type, sender_id,
|
||||
sender_name, content, reply_to_message_id, has_attachment, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
telegram_message_id, chat_id, sender_type, sender_id,
|
||||
sender_name, content, reply_to_message_id,
|
||||
1 if has_attachment else 0, created_at,
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
return cursor.lastrowid
|
||||
except Exception as e:
|
||||
if "UNIQUE" in str(e):
|
||||
return None
|
||||
raise
|
||||
|
||||
def insert_attachment(
|
||||
self,
|
||||
message_id: int,
|
||||
file_type: str,
|
||||
file_id: str,
|
||||
file_unique_id: str,
|
||||
file_name: str | None,
|
||||
mime_type: str | None,
|
||||
file_size: int | None,
|
||||
local_path: str | None,
|
||||
caption: str | None,
|
||||
) -> int:
|
||||
cursor = self.conn.execute(
|
||||
"""INSERT INTO attachments
|
||||
(message_id, file_type, file_id, file_unique_id, file_name,
|
||||
mime_type, file_size, local_path, caption, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
message_id, file_type, file_id, file_unique_id, file_name,
|
||||
mime_type, file_size, local_path, caption,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
def queue_outbound(self, chat_id: int, content: str, attribution: str = "claude.ai") -> int:
|
||||
cursor = self.conn.execute(
|
||||
"""INSERT INTO outbound_queue (chat_id, content, attribution, status, created_at)
|
||||
VALUES (?, ?, ?, 'pending', ?)""",
|
||||
(chat_id, content, attribution, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
self.conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_pending_outbound(self) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"SELECT id, chat_id, content, attribution FROM outbound_queue WHERE status = 'pending' ORDER BY id"
|
||||
).fetchall()
|
||||
return [
|
||||
{"id": r[0], "chat_id": r[1], "content": r[2], "attribution": r[3]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def mark_outbound_sent(self, outbound_id: int):
|
||||
self.conn.execute(
|
||||
"UPDATE outbound_queue SET status = 'sent', sent_at = ? WHERE id = ?",
|
||||
(datetime.now(timezone.utc).isoformat(), outbound_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def mark_outbound_failed(self, outbound_id: int):
|
||||
self.conn.execute(
|
||||
"UPDATE outbound_queue SET status = 'failed' WHERE id = ?",
|
||||
(outbound_id,),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_messages_since_id(self, since_id: int = 0, limit: int = 100) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"""SELECT m.id, m.telegram_message_id, m.sender_type, m.sender_name,
|
||||
m.content, m.has_attachment, m.created_at, m.reply_to_message_id
|
||||
FROM messages m
|
||||
WHERE m.id > ?
|
||||
ORDER BY m.id ASC
|
||||
LIMIT ?""",
|
||||
(since_id, limit),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"telegram_message_id": r[1],
|
||||
"sender_type": r[2],
|
||||
"sender": r[3] or r[2],
|
||||
"content": r[4],
|
||||
"has_attachment": bool(r[5]),
|
||||
"created_at": r[6],
|
||||
"reply_to_message_id": r[7],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def get_messages_since_timestamp(self, since: str, limit: int = 100) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"""SELECT m.id, m.telegram_message_id, m.sender_type, m.sender_name,
|
||||
m.content, m.has_attachment, m.created_at, m.reply_to_message_id
|
||||
FROM messages m
|
||||
WHERE m.created_at > ?
|
||||
ORDER BY m.id ASC
|
||||
LIMIT ?""",
|
||||
(since, limit),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"telegram_message_id": r[1],
|
||||
"sender_type": r[2],
|
||||
"sender": r[3] or r[2],
|
||||
"content": r[4],
|
||||
"has_attachment": bool(r[5]),
|
||||
"created_at": r[6],
|
||||
"reply_to_message_id": r[7],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def get_attachments_for_message(self, message_id: int) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"""SELECT file_type, file_id, file_unique_id, file_name,
|
||||
mime_type, file_size, local_path, caption
|
||||
FROM attachments WHERE message_id = ?""",
|
||||
(message_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"file_type": r[0],
|
||||
"file_id": r[1],
|
||||
"file_unique_id": r[2],
|
||||
"file_name": r[3],
|
||||
"mime_type": r[4],
|
||||
"file_size": r[5],
|
||||
"local_path": r[6],
|
||||
"caption": r[7],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def get_status(self) -> dict:
|
||||
total = self.conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
|
||||
last_row = self.conn.execute(
|
||||
"SELECT created_at, sender_name, sender_type FROM messages ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
pending = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM outbound_queue WHERE status = 'pending'"
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"total_messages": total,
|
||||
"last_message_at": last_row[0] if last_row else None,
|
||||
"last_sender": last_row[1] or last_row[2] if last_row else None,
|
||||
"pending_outbound": pending,
|
||||
}
|
||||
90
mcp_bridge/mcp_server.py
Normal file
90
mcp_bridge/mcp_server.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""FastMCP server exposing bridge tools to claude.ai."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from .db import Database
|
||||
from .config import get_group_chat_id
|
||||
|
||||
# Will be initialized in __main__ with shared db instance
|
||||
db: Database | None = None
|
||||
|
||||
mcp = FastMCP(
|
||||
name="homelab-bridge",
|
||||
instructions=(
|
||||
"This MCP server bridges claude.ai to a homelab Telegram group chat. "
|
||||
"Use pull_updates to read conversation history (supports cursor-based pagination). "
|
||||
"Use send_message to post messages to the group (attributed as [claude.ai]). "
|
||||
"Use queue_status for a quick summary."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def init(database: Database):
|
||||
"""Set the shared database instance."""
|
||||
global db
|
||||
db = database
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def send_message(message: str) -> str:
|
||||
"""Send a message to the homelab Telegram group chat.
|
||||
|
||||
The message will be posted with [claude.ai] attribution so participants
|
||||
know the message came from claude.ai.
|
||||
|
||||
Args:
|
||||
message: The text to send to the group chat.
|
||||
"""
|
||||
chat_id = get_group_chat_id()
|
||||
outbound_id = db.queue_outbound(chat_id, message)
|
||||
return json.dumps({"sent": True, "id": outbound_id})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def pull_updates(since_id: int = 0, since: str | None = None, limit: int = 50) -> str:
|
||||
"""Pull conversation messages from the Telegram group.
|
||||
|
||||
Returns messages from all participants (Mikkel, homelab bot, MCP bot).
|
||||
Supports cursor-based pagination: use the returned 'cursor' value as
|
||||
'since_id' in the next call to get only new messages.
|
||||
|
||||
Args:
|
||||
since_id: Return messages with id > this value. Use cursor from previous response.
|
||||
since: ISO 8601 timestamp. Alternative to since_id — returns messages after this time.
|
||||
limit: Maximum number of messages to return (default 50, max 200).
|
||||
"""
|
||||
limit = min(limit, 200)
|
||||
|
||||
if since:
|
||||
messages = db.get_messages_since_timestamp(since, limit)
|
||||
else:
|
||||
messages = db.get_messages_since_id(since_id, limit)
|
||||
|
||||
# Enrich with attachment info
|
||||
for msg in messages:
|
||||
if msg["has_attachment"]:
|
||||
msg["attachments"] = db.get_attachments_for_message(msg["id"])
|
||||
else:
|
||||
msg["attachments"] = []
|
||||
del msg["has_attachment"]
|
||||
|
||||
cursor = messages[-1]["id"] if messages else since_id
|
||||
|
||||
return json.dumps({
|
||||
"messages": messages,
|
||||
"cursor": cursor,
|
||||
"count": len(messages),
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def queue_status() -> str:
|
||||
"""Get current status of the bridge.
|
||||
|
||||
Returns message counts, last activity, and pending outbound messages.
|
||||
"""
|
||||
status = db.get_status()
|
||||
return json.dumps(status)
|
||||
43
mcp_bridge/models.py
Normal file
43
mcp_bridge/models.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Shared data models."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageRecord:
|
||||
id: int
|
||||
telegram_message_id: int
|
||||
chat_id: int
|
||||
sender_type: str
|
||||
sender_id: int | None
|
||||
sender_name: str | None
|
||||
content: str | None
|
||||
reply_to_message_id: int | None
|
||||
has_attachment: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttachmentRecord:
|
||||
id: int
|
||||
message_id: int
|
||||
file_type: str
|
||||
file_id: str
|
||||
file_unique_id: str
|
||||
file_name: str | None
|
||||
mime_type: str | None
|
||||
file_size: int | None
|
||||
local_path: str | None
|
||||
caption: str | None
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutboundMessage:
|
||||
id: int
|
||||
chat_id: int
|
||||
content: str
|
||||
attribution: str
|
||||
status: str
|
||||
created_at: str
|
||||
sent_at: str | None
|
||||
297
mcp_bridge/telegram_bot.py
Normal file
297
mcp_bridge/telegram_bot.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"""Telegram bot: logs group messages to libsql, sends outbound messages."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from telegram import Bot, Update
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
MessageHandler,
|
||||
filters,
|
||||
ContextTypes,
|
||||
)
|
||||
|
||||
from .config import get_bot_token, get_group_chat_id, get_homelab_bot_id, MEDIA_DIR
|
||||
from .db import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BridgeBot:
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
self.token = get_bot_token()
|
||||
self.group_chat_id = get_group_chat_id()
|
||||
self.homelab_bot_id = get_homelab_bot_id()
|
||||
self.bot: Bot | None = None
|
||||
self.app: Application | None = None
|
||||
self._outbound_task: asyncio.Task | None = None
|
||||
self._my_bot_id: int | None = None
|
||||
|
||||
def _classify_sender(self, user_id: int | None) -> str:
|
||||
"""Classify who sent a message."""
|
||||
if user_id is None:
|
||||
return "unknown"
|
||||
if user_id == self._my_bot_id:
|
||||
return "mcp_bot"
|
||||
if self.homelab_bot_id and user_id == self.homelab_bot_id:
|
||||
return "homelab_bot"
|
||||
# Check if user is a bot (we'll handle this in the handler with full user info)
|
||||
return "user"
|
||||
|
||||
async def _log_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Log any group message to the database."""
|
||||
message = update.effective_message
|
||||
if not message or message.chat_id != self.group_chat_id:
|
||||
return
|
||||
|
||||
user = message.from_user
|
||||
user_id = user.id if user else None
|
||||
sender_name = None
|
||||
if user:
|
||||
if user.is_bot:
|
||||
sender_name = user.first_name or user.username
|
||||
else:
|
||||
parts = [user.first_name or "", user.last_name or ""]
|
||||
sender_name = " ".join(p for p in parts if p) or user.username
|
||||
|
||||
sender_type = self._classify_sender(user_id)
|
||||
# Refine: if it's a bot we don't know, label it
|
||||
if sender_type == "user" and user and user.is_bot:
|
||||
sender_type = "bot"
|
||||
|
||||
reply_to_id = None
|
||||
if message.reply_to_message:
|
||||
reply_to_id = message.reply_to_message.message_id
|
||||
|
||||
# Determine if there are attachments
|
||||
has_attachment = bool(
|
||||
message.photo or message.document or message.video
|
||||
or message.voice or message.audio or message.sticker
|
||||
or message.video_note or message.animation
|
||||
)
|
||||
|
||||
# Get text content
|
||||
content = message.text or message.caption or None
|
||||
|
||||
# Parse message date
|
||||
msg_date = message.date
|
||||
if msg_date:
|
||||
created_at = msg_date.astimezone(timezone.utc).isoformat()
|
||||
else:
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Insert message
|
||||
msg_id = self.db.insert_message(
|
||||
telegram_message_id=message.message_id,
|
||||
chat_id=message.chat_id,
|
||||
sender_type=sender_type,
|
||||
sender_id=user_id,
|
||||
sender_name=sender_name,
|
||||
content=content,
|
||||
reply_to_message_id=reply_to_id,
|
||||
has_attachment=has_attachment,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
logger.debug(f"Duplicate message {message.message_id}, skipping")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Logged message {message.message_id} from {sender_name} ({sender_type})"
|
||||
)
|
||||
|
||||
# Process attachments
|
||||
if has_attachment:
|
||||
await self._process_attachments(message, msg_id)
|
||||
|
||||
async def _process_attachments(self, message, db_message_id: int):
|
||||
"""Download and log attachments."""
|
||||
attachments = []
|
||||
|
||||
if message.photo:
|
||||
# Get highest resolution photo
|
||||
photo = message.photo[-1]
|
||||
attachments.append(("photo", photo.file_id, photo.file_unique_id,
|
||||
None, "image/jpeg", photo.file_size, message.caption))
|
||||
|
||||
if message.document:
|
||||
doc = message.document
|
||||
attachments.append(("document", doc.file_id, doc.file_unique_id,
|
||||
doc.file_name, doc.mime_type, doc.file_size, message.caption))
|
||||
|
||||
if message.video:
|
||||
vid = message.video
|
||||
attachments.append(("video", vid.file_id, vid.file_unique_id,
|
||||
vid.file_name, vid.mime_type, vid.file_size, message.caption))
|
||||
|
||||
if message.voice:
|
||||
voice = message.voice
|
||||
attachments.append(("voice", voice.file_id, voice.file_unique_id,
|
||||
None, voice.mime_type, voice.file_size, None))
|
||||
|
||||
if message.audio:
|
||||
audio = message.audio
|
||||
attachments.append(("audio", audio.file_id, audio.file_unique_id,
|
||||
audio.file_name, audio.mime_type, audio.file_size, None))
|
||||
|
||||
if message.sticker:
|
||||
sticker = message.sticker
|
||||
attachments.append(("sticker", sticker.file_id, sticker.file_unique_id,
|
||||
None, None, sticker.file_size, None))
|
||||
|
||||
if message.animation:
|
||||
anim = message.animation
|
||||
attachments.append(("animation", anim.file_id, anim.file_unique_id,
|
||||
anim.file_name, anim.mime_type, anim.file_size, message.caption))
|
||||
|
||||
for file_type, file_id, file_unique_id, file_name, mime_type, file_size, caption in attachments:
|
||||
# Download file
|
||||
local_path = await self._download_file(file_id, file_unique_id, file_name, file_type)
|
||||
|
||||
self.db.insert_attachment(
|
||||
message_id=db_message_id,
|
||||
file_type=file_type,
|
||||
file_id=file_id,
|
||||
file_unique_id=file_unique_id,
|
||||
file_name=file_name,
|
||||
mime_type=mime_type,
|
||||
file_size=file_size,
|
||||
local_path=str(local_path) if local_path else None,
|
||||
caption=caption,
|
||||
)
|
||||
logger.info(f"Saved attachment: {file_type} -> {local_path}")
|
||||
|
||||
async def _download_file(
|
||||
self, file_id: str, file_unique_id: str, file_name: str | None, file_type: str
|
||||
) -> Path | None:
|
||||
"""Download a file from Telegram to local media directory."""
|
||||
try:
|
||||
tg_file = await self.bot.get_file(file_id)
|
||||
|
||||
# Organize by date
|
||||
date_dir = MEDIA_DIR / datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
date_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build filename
|
||||
if file_name:
|
||||
local_name = f"{file_unique_id}_{file_name}"
|
||||
else:
|
||||
ext_map = {
|
||||
"photo": ".jpg", "voice": ".ogg", "sticker": ".webp",
|
||||
"video": ".mp4", "animation": ".mp4", "audio": ".mp3",
|
||||
}
|
||||
ext = ext_map.get(file_type, "")
|
||||
local_name = f"{file_unique_id}{ext}"
|
||||
|
||||
local_path = date_dir / local_name
|
||||
await tg_file.download_to_drive(str(local_path))
|
||||
return local_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download file {file_id}: {e}")
|
||||
return None
|
||||
|
||||
async def send_to_group(self, text: str, attribution: str = "claude.ai"):
|
||||
"""Send an attributed message to the group chat."""
|
||||
formatted = f"*\\[{attribution}\\]* {self._escape_markdown(text)}"
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=self.group_chat_id,
|
||||
text=formatted,
|
||||
parse_mode="MarkdownV2",
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to plain text if markdown fails
|
||||
plain = f"[{attribution}] {text}"
|
||||
await self.bot.send_message(
|
||||
chat_id=self.group_chat_id,
|
||||
text=plain,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _escape_markdown(text: str) -> str:
|
||||
"""Escape MarkdownV2 special characters, preserving code blocks."""
|
||||
special = r"_*[]()~`>#+-=|{}.!"
|
||||
result = []
|
||||
i = 0
|
||||
in_code_block = False
|
||||
in_inline_code = False
|
||||
|
||||
while i < len(text):
|
||||
# Check for code block
|
||||
if text[i:i+3] == "```":
|
||||
in_code_block = not in_code_block
|
||||
result.append("```")
|
||||
i += 3
|
||||
continue
|
||||
# Check for inline code
|
||||
if text[i] == "`" and not in_code_block:
|
||||
in_inline_code = not in_inline_code
|
||||
result.append("`")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if not in_code_block and not in_inline_code and text[i] in special:
|
||||
result.append(f"\\{text[i]}")
|
||||
else:
|
||||
result.append(text[i])
|
||||
i += 1
|
||||
|
||||
return "".join(result)
|
||||
|
||||
async def _outbound_loop(self):
|
||||
"""Poll outbound queue and send messages."""
|
||||
while True:
|
||||
try:
|
||||
pending = self.db.get_pending_outbound()
|
||||
for msg in pending:
|
||||
try:
|
||||
await self.send_to_group(msg["content"], msg["attribution"])
|
||||
self.db.mark_outbound_sent(msg["id"])
|
||||
logger.info(f"Sent outbound message {msg['id']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send outbound {msg['id']}: {e}")
|
||||
self.db.mark_outbound_failed(msg["id"])
|
||||
except Exception as e:
|
||||
logger.error(f"Outbound loop error: {e}")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def _post_init(self, application: Application):
|
||||
"""Called after bot initialization."""
|
||||
self.bot = application.bot
|
||||
me = await self.bot.get_me()
|
||||
self._my_bot_id = me.id
|
||||
logger.info(f"MCP Bridge bot started as @{me.username} (ID: {me.id})")
|
||||
logger.info(f"Monitoring group chat: {self.group_chat_id}")
|
||||
|
||||
# Start outbound message loop
|
||||
self._outbound_task = asyncio.create_task(self._outbound_loop())
|
||||
|
||||
async def _post_shutdown(self, application: Application):
|
||||
"""Cleanup on shutdown."""
|
||||
if self._outbound_task:
|
||||
self._outbound_task.cancel()
|
||||
try:
|
||||
await self._outbound_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def build_application(self) -> Application:
|
||||
"""Build the telegram Application (but don't start it yet)."""
|
||||
builder = Application.builder().token(self.token)
|
||||
self.app = builder.build()
|
||||
|
||||
# Log ALL messages in the group (text, photos, documents, etc.)
|
||||
self.app.add_handler(
|
||||
MessageHandler(filters.ALL, self._log_message)
|
||||
)
|
||||
|
||||
self.app.post_init = self._post_init
|
||||
self.app.post_shutdown = self._post_shutdown
|
||||
|
||||
return self.app
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
fastmcp>=2.0.0
|
||||
python-telegram-bot>=21.0
|
||||
libsql-experimental>=0.0.50
|
||||
aiohttp>=3.9.0
|
||||
Loading…
Add table
Reference in a new issue