From a30a05f1cd2b9e253067d92204b8de8f1081d7a9 Mon Sep 17 00:00:00 2001 From: TheAchiever6823 <61914223+ShawnMadadha@users.noreply.github.com> Date: Mon, 18 May 2026 01:06:15 -0700 Subject: [PATCH] =?UTF-8?q?[shawn]=20feat:=20official=20Telegram=20bot=20m?= =?UTF-8?q?ode=20=E2=80=94=20DM=20your=20@BotFather=20bot=20with=20plain?= =?UTF-8?q?=20text,=20no=20slash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/telegram_bot/listener.py | 246 ++++++++++++++++++++++--- backend/apps/tools_lib/tools_lib.py | 52 ++++++ frontend/src/app/pages/Tools/Tools.tsx | 55 +++++- 3 files changed, 322 insertions(+), 31 deletions(-) diff --git a/backend/apps/telegram_bot/listener.py b/backend/apps/telegram_bot/listener.py index 45e3f7d1..8a612267 100644 --- a/backend/apps/telegram_bot/listener.py +++ b/backend/apps/telegram_bot/listener.py @@ -45,19 +45,48 @@ _TASK_TIMEOUT_S = 30 * 60 _listener_task: Optional[asyncio.Task] = None _client: Optional[TelegramClient] = None +_mode: Optional[str] = None # "bot" or "user" +_bot_tool_id: Optional[str] = None # set when running in bot mode, for /authorize persistence + + +def _connection() -> Optional[tuple[str, dict]]: + """Pick which Telegram tile drives the listener. + + Bot mode preferred when connected — cleaner UX (plain text = task, + no Saved Messages pollution, multi-user via /authorize). + Falls back to user-account/Saved Messages mode if only that tile is + connected. Returns (mode, tool_dict_subset) or None. + """ + try: + from backend.apps.tools_lib.tools_lib import _load_all + bot_tool = None + user_tool = None + for tool in _load_all(): + name = (tool.name or "").lower() + if tool.auth_status != "connected": + continue + if name == "telegram bot" or name == "telegram-bot": + bot_tool = tool + elif name == "telegram": + user_tool = tool + if bot_tool: + token = (bot_tool.credentials or {}).get("TELEGRAM_BOT_TOKEN", "").strip() + if token: + return ("bot", {"token": token, "tool_id": bot_tool.id, "tool": bot_tool}) + if user_tool: + phone = (user_tool.credentials or {}).get("TELEGRAM_PHONE", "").strip() + if phone: + return ("user", {"phone": phone}) + except Exception as exc: # noqa: BLE001 + logger.warning(f"telegram-bot: could not read tool config: {exc}") + return None def _connected_phone() -> Optional[str]: - """Read which Telegram is currently connected, from the tool config.""" - try: - from backend.apps.tools_lib.tools_lib import _load_all - for tool in _load_all(): - if (tool.name or "").lower() == "telegram" and tool.auth_status == "connected": - phone = (tool.credentials or {}).get("TELEGRAM_PHONE", "").strip() - if phone: - return phone - except Exception as exc: # noqa: BLE001 - logger.warning(f"telegram-bot: could not read tool config: {exc}") + """Backwards-compat helper used by the user-account path.""" + conn = _connection() + if conn and conn[0] == "user": + return conn[1].get("phone") return None @@ -92,25 +121,65 @@ def _ensure_listener_session(phone: str) -> Path: return side.with_suffix("") -HELP_TEXT = ( - "*OpenSwarm Telegram bot*\n\n" +HELP_TEXT_USER_MODE = ( + "*OpenSwarm — Saved Messages mode*\n\n" "Commands:\n" " `/task ` — run an agent task\n" " `/status` — list running sessions\n" " `/help` — this message\n\n" - "Messages without a `/` prefix are ignored, so you can keep using " + "Messages without a `/` prefix are ignored so you can keep using " "Saved Messages normally." ) +HELP_TEXT_BOT_MODE = ( + "*OpenSwarm bot*\n\n" + "Just type any task in plain English — no slash needed:\n" + " _'summarize my LinkedIn inbox'_\n" + " _'DM @joe on telegram saying running late'_\n" + " _'what's in my GitHub notifications?'_\n\n" + "Commands:\n" + " `/status` — list running sessions\n" + " `/authorize ` — let another Telegram user drive this bot\n" + " `/help` — this message" +) -async def _route(event, text: str) -> None: - """Dispatch a recognized command.""" + +def _authorized_ids(tool) -> set[int]: + raw = (tool.credentials or {}).get("AUTHORIZED_USER_IDS", "") or "" + out: set[int] = set() + for part in raw.split(","): + part = part.strip() + if part: + try: out.add(int(part)) + except ValueError: pass + return out + + +def _save_authorized(tool, ids: set[int]) -> None: + from backend.apps.tools_lib.tools_lib import _save + tool.credentials["AUTHORIZED_USER_IDS"] = ",".join(str(i) for i in sorted(ids)) + _save(tool) + + +async def _route(event, text: str, mode: str) -> None: + """Dispatch a recognized command. + + In bot mode, plain text (no slash) is treated as `/task ` so users + can DM the bot naturally. In user-account mode (Saved Messages), only + slash-prefixed commands are honored to avoid hijacking note-taking. + """ if text.startswith("/help"): - await event.respond(HELP_TEXT) + await event.respond(HELP_TEXT_BOT_MODE if mode == "bot" else HELP_TEXT_USER_MODE) return if text.startswith("/status"): await _handle_status(event) return + if text.startswith("/authorize "): + if mode != "bot": + await event.respond("`/authorize` is only available in bot mode.") + return + await _handle_authorize(event, text[len("/authorize "):].strip()) + return if text.startswith("/task "): prompt = text[len("/task "):].strip() if not prompt: @@ -119,7 +188,46 @@ async def _route(event, text: str) -> None: await _handle_task(event, prompt) return if text.startswith("/"): - await event.respond(f"Unknown command. /help for the list.") + await event.respond("Unknown command. /help for the list.") + return + # Plain text path + if mode == "bot": + await _handle_task(event, text) + + +async def _handle_authorize(event, arg: str) -> None: + """Add a Telegram user ID (or @username) to the bot's authorized list.""" + global _bot_tool_id + if not _bot_tool_id: + await event.respond("No bot tool id loaded — restart OpenSwarm.") + return + from backend.apps.tools_lib.tools_lib import _load + tool = _load(_bot_tool_id) + + target_id: Optional[int] = None + if arg.startswith("@"): + try: + entity = await _client.get_entity(arg) + target_id = getattr(entity, "id", None) + except Exception as exc: # noqa: BLE001 + await event.respond(f"Couldn't resolve {arg}: {exc}") + return + else: + try: target_id = int(arg) + except ValueError: + await event.respond("Usage: `/authorize ` or `/authorize @username`") + return + + if not target_id: + await event.respond("Could not resolve target user.") + return + ids = _authorized_ids(tool) + if target_id in ids: + await event.respond(f"User `{target_id}` is already authorized.") + return + ids.add(target_id) + _save_authorized(tool, ids) + await event.respond(f"Authorized `{target_id}`. They can now message this bot.") async def _handle_status(event) -> None: @@ -225,21 +333,103 @@ def _extract_last_assistant_text(session_id: str) -> str: async def _listener_main() -> None: - """Run-forever loop. Tolerates the no-Telegram-connected case by idling.""" - global _client + """Run-forever loop. Picks bot mode if connected, else user-account mode, + else idles. Tolerates no-Telegram-connected case by logging once.""" + global _client, _mode, _bot_tool_id - phone = _connected_phone() - if not phone: - logger.info("telegram-bot: no connected Telegram — listener idle.") + conn = _connection() + if not conn: + logger.info("telegram-bot: no connected Telegram tile — listener idle.") return api_id, api_hash = _api_creds() if api_id is None: logger.warning( - "telegram-bot: OPENSWARM_TELEGRAM_API_ID/_API_HASH not set in backend env — listener idle." + "telegram-bot: OPENSWARM_TELEGRAM_API_ID/_API_HASH not set in backend/.env — listener idle." ) return + mode, payload = conn + _mode = mode + + if mode == "bot": + await _run_bot_mode(api_id, api_hash, payload) + else: + await _run_user_mode(api_id, api_hash, payload["phone"]) + + +async def _run_bot_mode(api_id: int, api_hash: str, payload: dict) -> None: + """Bot listener: any message to @YourBot becomes a task (no slash).""" + global _client, _bot_tool_id + + from telethon.sessions import StringSession + + _bot_tool_id = payload["tool_id"] + tool = payload["tool"] + bot_token = payload["token"] + + _client = TelegramClient(StringSession(), api_id, api_hash) + try: + await _client.start(bot_token=bot_token) + except Exception as exc: # noqa: BLE001 + logger.warning(f"telegram-bot: bot.start failed, listener idle: {exc}") + return + + me = await _client.get_me() + logger.info( + f"telegram-bot: bot listener active for @{me.username} (id={me.id}). " + f"DM the bot in Telegram to drive OpenSwarm. First message auto-authorizes the sender." + ) + + @_client.on(events.NewMessage(incoming=True)) + async def _on_message(event): + sender = await event.get_sender() + sender_id = getattr(sender, "id", None) + if not sender_id: + return + # Reload tool each tick so /authorize-added users take effect without a restart. + try: + from backend.apps.tools_lib.tools_lib import _load + current_tool = _load(_bot_tool_id) if _bot_tool_id else tool + except Exception: # noqa: BLE001 + current_tool = tool + authorized = _authorized_ids(current_tool) + if not authorized: + # Trust-on-first-use: first message becomes the owner. + authorized.add(sender_id) + _save_authorized(current_tool, authorized) + await event.respond( + f"👋 Hi! You're now authorized as the owner of this OpenSwarm bot (id `{sender_id}`).\n\n" + f"Send any message in plain English to run an agent task, or `/help` for commands." + ) + return + if sender_id not in authorized: + await event.respond( + "This bot is private. The owner has to `/authorize` you before you can use it." + ) + return + text = (event.message.message or "").strip() + if not text: + return + try: + await _route(event, text, mode="bot") + except Exception as exc: # noqa: BLE001 + logger.exception(f"telegram-bot: route failed: {exc}") + try: await event.respond(f"Listener error: {exc}") + except Exception: pass + + try: + await _client.run_until_disconnected() + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.exception(f"telegram-bot: bot listener crashed: {exc}") + + +async def _run_user_mode(api_id: int, api_hash: str, phone: str) -> None: + """Legacy listener: own Saved Messages, slash-prefixed commands only.""" + global _client + session_base = _ensure_listener_session(phone) _client = TelegramClient(str(session_base), api_id, api_hash) try: @@ -247,7 +437,6 @@ async def _listener_main() -> None: except Exception as exc: # noqa: BLE001 logger.warning(f"telegram-bot: connect failed, listener idle: {exc}") return - if not await _client.is_user_authorized(): logger.warning( "telegram-bot: listener session not authorized — disconnect and reconnect Telegram in OpenSwarm. Idling." @@ -259,22 +448,19 @@ async def _listener_main() -> None: me = await _client.get_me() my_id = me.id logger.info( - f"telegram-bot: listener active for @{me.username or me.phone} (id={my_id}). " + f"telegram-bot: user-mode listener active for @{me.username or me.phone} (id={my_id}). " f"DM yourself in Saved Messages with /help to start." ) @_client.on(events.NewMessage(from_users="me")) async def _on_message(event): - # Hard authorization: only your own Saved Messages chat. event.chat_id - # for Saved Messages equals your own user id, and from_users='me' is - # already enforced by the decorator. Defense in depth here. if event.chat_id != my_id: return text = (event.message.message or "").strip() - if not text or not text.startswith("/"): + if not text: return try: - await _route(event, text) + await _route(event, text, mode="user") except Exception as exc: # noqa: BLE001 logger.exception(f"telegram-bot: route failed: {exc}") try: await event.respond(f"Listener error: {exc}") diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index 96c318fa..ff91e643 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -1618,6 +1618,58 @@ async def telegram_verify(payload: dict) -> dict: return {"ok": True} +@tools_lib.router.post("/credentials/telegram_bot/validate") +async def telegram_bot_validate(payload: dict) -> dict: + """Validate a bot token from @BotFather and persist it as a connected tool. + + Body: {tool_id: str, bot_token: str} + The bot doesn't have user-style sessions; the token IS the credential. + We do verify it by signing in once via Telethon to make sure it's real, + then disconnect and save. + """ + tool_id = payload.get("tool_id") or "" + token = (payload.get("bot_token") or "").strip() + if not tool_id or not token: + return {"ok": False, "error": "tool_id and bot_token are required"} + tool = _load(tool_id) + try: + api_id, api_hash = _tg_app_creds() + except ValueError as e: + return {"ok": False, "error": str(e)} + + from telethon import TelegramClient + from telethon.sessions import StringSession + + client = TelegramClient(StringSession(), api_id, api_hash) + try: + await client.connect() + try: + await client.sign_in(bot_token=token) + except Exception as e: # noqa: BLE001 + return {"ok": False, "error": (str(e) or "invalid bot token")[:300]} + me = await client.get_me() + if not me or not getattr(me, "bot", False): + return {"ok": False, "error": "token authenticated but the account is not a bot"} + bot_username = me.username or "" + bot_id = me.id + finally: + try: await client.disconnect() + except Exception: pass + + # First-time setup: empty authorized list. The first incoming message + # to the bot will auto-authorize the sender (TOFU). The bot owner can + # /authorize additional people from any authorized session. + tool.credentials = { + "TELEGRAM_BOT_TOKEN": token, + "AUTHORIZED_USER_IDS": "", + } + tool.auth_type = "env_vars" + tool.auth_status = "connected" + tool.connected_account_email = f"@{bot_username}" if bot_username else f"bot_{bot_id}" + _save(tool) + return {"ok": True, "username": bot_username, "bot_id": bot_id} + + @tools_lib.router.post("/credentials/telegram/password") async def telegram_password(payload: dict) -> dict: """Step 3 (only if account has cloud 2FA): submit the 2FA password. diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index 4eed8f6c..f76a72c5 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -333,6 +333,34 @@ const INTEGRATIONS: Integration[] = [ ), }, + { + id: 'telegram-bot', + name: 'Telegram Bot', + description: + 'Drive OpenSwarm from any Telegram client by DM-ing your own bot. Type a task in plain English (no slash needed) — the bot runs it on this OpenSwarm install and replies with the result. Great for kicking off agents from your phone while away from the desktop. Two-minute setup via @BotFather.', + mcp_config: { + // No MCP server — the bot runs as a backend listener (telegram_bot.listener). + // This empty config keeps the tile in the curated list without spawning anything. + type: 'stdio', + command: 'true', + args: [], + }, + color: '#229ED9', + website: 'https://core.telegram.org/bots', + connectLabel: 'Connect Telegram Bot', + connectInstructions: 'Open Telegram → message @BotFather → send `/newbot` → pick a name and username for your bot (e.g. ShawnsOpenSwarmBot). @BotFather will reply with a token like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`. Paste that token below. After connecting, DM your bot anything and OpenSwarm runs it as an agent task. First message auto-authorizes you as owner.', + credentialFields: [ + { key: 'TELEGRAM_BOT_TOKEN', label: 'Bot Token (from @BotFather)', placeholder: '123456:ABC-DEF1234ghIkl-...', type: 'password' }, + ], + icon: ( + + + + + + + ), + }, { id: 'telegram', name: 'Telegram', @@ -686,7 +714,7 @@ const Tools: React.FC = () => { // browse the long tail. const CURATED_MCP_NAMES = useMemo(() => new Set([ 'google-workspace', 'microsoft-365', 'slack', 'discord', - 'notion', 'airtable', 'hubspot', 'reddit', 'youtube', 'instagram', 'linkedin', 'github', 'telegram', + 'notion', 'airtable', 'hubspot', 'reddit', 'youtube', 'instagram', 'linkedin', 'github', 'telegram', 'telegram-bot', ]), []); const regServers = useMemo(() => { if (regSource !== 'curated') return regServersRaw; @@ -1475,6 +1503,31 @@ const Tools: React.FC = () => { return; } + // Telegram Bot: validate the bot token against Telethon before saving. + // Bot tokens don't expire and there's no OTP — single field, one call. + if (credDialogIntegration.id === 'telegram-bot') { + const token = (credDialogValues['TELEGRAM_BOT_TOKEN'] || '').trim(); + if (!token.includes(':')) { + setSnackbar({ open: true, message: 'Bot token should look like `123456:ABC-DEF...` (number:hash). Get it from @BotFather.', severity: 'error' }); + return; + } + const resp = await fetch(`${API_BASE}/tools/credentials/telegram_bot/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tool_id: credDialogToolId, bot_token: token }), + }); + const data = await resp.json().catch(() => ({ ok: false, error: `bad response (${resp.status})` })); + if (!data.ok) { + setSnackbar({ open: true, message: `Bot token invalid: ${data.error || 'unknown error'}`, severity: 'error' }); + return; + } + await dispatch(fetchToolStatus(credDialogToolId)); + setCredDialogOpen(false); + setSnackbar({ open: true, message: `Connected @${data.username || 'bot'}! DM your bot to start. First message auto-authorizes you as owner. Restart OpenSwarm to start the listener.` }); + dispatch(discoverTools(credDialogToolId)); + return; + } + // Telegram: phone -> /start -> wait for OTP -> /verify -> (optional 2FA) -> /password. // We keep the credentials dialog open and morph its credentialFields between // steps so the user never sees a fresh modal between steps. Backend caches