Files
openswarm/backend/apps/telegram_mcp/server.py
T

342 lines
12 KiB
Python

"""Telegram MCP server (vendored into OpenSwarm).
MTProto user-account access via Telethon. Auth is driven by the OpenSwarm
backend's /credentials/telegram/* endpoints before this server ever starts;
this process just loads the prebuilt session.
Tools target four use cases:
- send DMs and channel messages (send_message, send_file, send_voice)
- read and summarize inbox (list_dialogs, get_messages, search_messages)
- forward / filter rules (forward_message)
- diagnostics (get_me, close_session)
Rate limiting is enforced per-tool via @rate_limited from .rate_limiter so
no single agent run can exceed the daily caps that keep Telegram from
flagging the connected account.
Env contract (set by OpenSwarm at spawn time):
TELEGRAM_PHONE E.164 phone identifying which session to load
OPENSWARM_TELEGRAM_API_ID app credentials (registered once by the team
OPENSWARM_TELEGRAM_API_HASH at https://my.telegram.org/apps)
"""
from __future__ import annotations
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from mcp.server.fastmcp import FastMCP
from telethon import TelegramClient
from telethon.tl.types import Message
from .rate_limiter import rate_limited
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
SESSION_DIR = Path.home() / ".telegram_mcp" / "sessions"
INSTRUCTIONS = """
Telegram via Telethon (MTProto user account). 9 tools cover send, read,
search, forward, and diagnostics. Per-category rate limits are enforced
server-side to protect the connected account from spam bans.
"""
mcp = FastMCP(name="Telegram", instructions=INSTRUCTIONS)
def _sanitize_phone(phone: str) -> str:
"""Phone-as-filename: strip leading + and any non-digit so the SQLite
session file is always a portable bare-digits name."""
return re.sub(r"\D", "", phone or "")
def _session_path(phone: str) -> Path:
return SESSION_DIR / _sanitize_phone(phone) # Telethon appends .session
_client: Optional[TelegramClient] = None
def _get_client() -> TelegramClient:
"""Lazy-init the singleton client. FastMCP runs the server in its own
event loop and awaits async tools, so we share one Telethon client
across calls — no per-call connect/disconnect tax."""
global _client
if _client is not None:
return _client
phone = os.getenv("TELEGRAM_PHONE", "").strip()
api_id_raw = os.getenv("OPENSWARM_TELEGRAM_API_ID", "").strip()
api_hash = os.getenv("OPENSWARM_TELEGRAM_API_HASH", "").strip()
if not phone or not api_id_raw or not api_hash:
raise RuntimeError(
"Telegram MCP missing env: TELEGRAM_PHONE, OPENSWARM_TELEGRAM_API_ID, OPENSWARM_TELEGRAM_API_HASH "
"must all be set. Connect Telegram via the OpenSwarm Tools page first."
)
try:
api_id = int(api_id_raw)
except ValueError as exc:
raise RuntimeError(f"OPENSWARM_TELEGRAM_API_ID must be an integer, got {api_id_raw!r}") from exc
SESSION_DIR.mkdir(parents=True, exist_ok=True)
session_file = _session_path(phone)
if not session_file.with_suffix(".session").exists():
raise RuntimeError(
f"No Telegram session at {session_file}.session. Connect Telegram via the OpenSwarm Tools page first."
)
_client = TelegramClient(str(session_file), api_id, api_hash)
return _client
async def _ensure_connected() -> TelegramClient:
client = _get_client()
if not client.is_connected():
await client.connect()
if not await client.is_user_authorized():
raise RuntimeError(
"Telegram session exists but is not authorized. Disconnect and reconnect via the OpenSwarm Tools page."
)
return client
def _message_summary(m: Message) -> Dict[str, Any]:
return {
"id": m.id,
"date": m.date.isoformat() if m.date else None,
"from_id": getattr(m.from_id, "user_id", None) if m.from_id else None,
"text": (m.message or "")[:2000],
"has_media": bool(m.media),
"reply_to": m.reply_to_msg_id,
}
def _dialog_summary(d: Any) -> Dict[str, Any]:
entity = d.entity
return {
"id": d.id,
"name": d.name,
"is_user": d.is_user,
"is_group": d.is_group,
"is_channel": d.is_channel,
"unread_count": d.unread_count,
"username": getattr(entity, "username", None),
"last_message": (d.message.message or "")[:500] if d.message and d.message.message else None,
}
# ---- send -------------------------------------------------------------------
@mcp.tool()
@rate_limited("send")
async def send_message(chat: str, message: str, reply_to: Optional[int] = None) -> Dict[str, Any]:
"""Send a text message to a Telegram user, group, or channel.
Args:
chat: Username (with or without leading @), phone number, or numeric
chat ID. "me" sends to your own Saved Messages.
message: Text body. Telegram-flavored markdown is supported.
reply_to: Optional message ID to reply to in the target chat.
Returns:
Dictionary with success flag and the sent message's id.
"""
try:
client = await _ensure_connected()
sent = await client.send_message(chat, message, reply_to=reply_to)
return {"success": True, "message_id": sent.id, "chat": chat}
except Exception as e: # noqa: BLE001
return {"success": False, "message": str(e)}
@mcp.tool()
@rate_limited("send")
async def send_file(chat: str, file_path: str, caption: Optional[str] = None) -> Dict[str, Any]:
"""Send a photo, video, document, or any file to a Telegram chat.
Args:
chat: Same identifier shape as send_message.
file_path: Absolute path to a file on disk.
caption: Optional caption shown under the media.
Returns:
Dictionary with success flag and the sent message's id.
"""
if not os.path.exists(file_path):
return {"success": False, "message": f"File not found: {file_path}"}
try:
client = await _ensure_connected()
sent = await client.send_file(chat, file_path, caption=caption)
return {"success": True, "message_id": sent.id, "chat": chat}
except Exception as e: # noqa: BLE001
return {"success": False, "message": str(e)}
@mcp.tool()
@rate_limited("send")
async def send_voice(chat: str, file_path: str, caption: Optional[str] = None) -> Dict[str, Any]:
"""Send a voice note (.ogg, .opus, .mp3) to a Telegram chat.
Args:
chat: Same identifier shape as send_message.
file_path: Absolute path to an audio file.
caption: Optional caption.
Returns:
Dictionary with success flag and the sent message's id.
"""
if not os.path.exists(file_path):
return {"success": False, "message": f"File not found: {file_path}"}
try:
client = await _ensure_connected()
sent = await client.send_file(chat, file_path, caption=caption, voice_note=True)
return {"success": True, "message_id": sent.id, "chat": chat}
except Exception as e: # noqa: BLE001
return {"success": False, "message": str(e)}
# ---- forward ---------------------------------------------------------------
@mcp.tool()
@rate_limited("forward")
async def forward_message(from_chat: str, message_id: int, to_chat: str) -> Dict[str, Any]:
"""Forward a single message from one chat to another.
Args:
from_chat: Source chat identifier.
message_id: Numeric message ID in the source chat.
to_chat: Destination chat identifier.
Returns:
Dictionary with success flag and the forwarded message id.
"""
try:
client = await _ensure_connected()
sent = await client.forward_messages(to_chat, message_id, from_chat)
new_id = sent.id if not isinstance(sent, list) else (sent[0].id if sent else None)
return {"success": True, "new_message_id": new_id, "to": to_chat}
except Exception as e: # noqa: BLE001
return {"success": False, "message": str(e)}
# ---- read / inbox ----------------------------------------------------------
@mcp.tool()
@rate_limited("lookup")
async def list_dialogs(limit: int = 20, archived: bool = False) -> Dict[str, Any]:
"""List your recent Telegram dialogs (chats, groups, channels).
Args:
limit: Max number of dialogs to return (default 20).
archived: If True, fetch from the archive folder instead of the main inbox.
Returns:
Dictionary with success flag and a list of dialog summaries.
"""
try:
client = await _ensure_connected()
dialogs = await client.get_dialogs(limit=limit, archived=archived)
return {"success": True, "dialogs": [_dialog_summary(d) for d in dialogs]}
except Exception as e: # noqa: BLE001
return {"success": False, "message": str(e)}
@mcp.tool()
@rate_limited("lookup")
async def get_messages(chat: str, limit: int = 20, offset_id: int = 0) -> Dict[str, Any]:
"""Read recent messages from a Telegram chat.
Args:
chat: Same identifier shape as send_message.
limit: Max number of messages (default 20, max ~100 per call).
offset_id: Pagination cursor (message id); 0 means newest.
Returns:
Dictionary with success flag and a list of message summaries
(newest first).
"""
try:
client = await _ensure_connected()
messages = await client.get_messages(chat, limit=limit, offset_id=offset_id)
return {"success": True, "messages": [_message_summary(m) for m in messages]}
except Exception as e: # noqa: BLE001
return {"success": False, "message": str(e)}
@mcp.tool()
@rate_limited("search")
async def search_messages(query: str, chat: Optional[str] = None, limit: int = 20) -> Dict[str, Any]:
"""Full-text search across messages.
Args:
query: Search string (Telegram's server-side text search).
chat: Optional chat to scope the search to. Omit to search global inbox.
limit: Max results (default 20).
Returns:
Dictionary with success flag and a list of matching message summaries.
"""
try:
client = await _ensure_connected()
messages = await client.get_messages(chat, search=query, limit=limit)
return {"success": True, "messages": [_message_summary(m) for m in messages]}
except Exception as e: # noqa: BLE001
return {"success": False, "message": str(e)}
# ---- plumbing --------------------------------------------------------------
@mcp.tool()
@rate_limited("lookup")
async def get_me() -> Dict[str, Any]:
"""Return profile info for the currently signed-in Telegram account.
Useful as a connectivity check before running other tools.
"""
try:
client = await _ensure_connected()
me = await client.get_me()
return {
"success": True,
"user_id": me.id,
"username": me.username,
"first_name": me.first_name,
"last_name": me.last_name,
"phone": me.phone,
"is_bot": me.bot,
"is_premium": getattr(me, "premium", False),
}
except Exception as e: # noqa: BLE001
return {"success": False, "message": str(e)}
@mcp.tool()
async def close_session() -> Dict[str, Any]:
"""Cleanly disconnect the Telegram client. Good agent hygiene at end of task."""
global _client
try:
if _client and _client.is_connected():
await _client.disconnect()
_client = None
return {"success": True}
except Exception as e: # noqa: BLE001
return {"success": False, "message": str(e)}
def main() -> None:
"""Spawn entrypoint. Validate session presence then start stdio loop."""
try:
# Trigger lazy-init's preflight checks (env vars + session file) so
# we fail fast at startup instead of mid-tool-call.
_get_client()
except RuntimeError as e:
logger.error(str(e))
sys.exit(1)
logger.info(f"Telegram MCP ready for phone ending …{(os.getenv('TELEGRAM_PHONE','') or '')[-4:]}")
mcp.run(transport="stdio")
if __name__ == "__main__":
main()