mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 13:17:40 +02:00
[shawn] feat: official Telegram bot mode — DM your @BotFather bot with plain text, no slash
This commit is contained in:
@@ -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 <prompt>` — 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 <user_id>` — 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 <text>` 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 <numeric_user_id>` 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}")
|
||||
|
||||
@@ -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 <user_id> 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.
|
||||
|
||||
@@ -333,6 +333,34 @@ const INTEGRATIONS: Integration[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="12" fill="#229ED9" />
|
||||
<path d="M7 11.5l9.7-3.8c.45-.17.85.1.7.78l-1.65 7.8c-.1.5-.4.62-.83.4l-2.3-1.7-1.1 1.07c-.13.13-.23.23-.47.23l.17-2.43 4.43-4c.2-.17-.04-.27-.3-.1L9.9 11.2l-2.35-.73c-.5-.16-.51-.5.12-.74z" fill="#fff"/>
|
||||
<circle cx="18" cy="6" r="3.5" fill="#fff" stroke="#229ED9" strokeWidth="0.5" />
|
||||
<path d="M16.5 6.7l1 1 2-2" stroke="#229ED9" strokeWidth="0.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user