Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions autogpt_platform/backend/.env.default
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,13 @@ AUTOPILOT_BOT_SLACK_CLIENT_ID=
AUTOPILOT_BOT_SLACK_CLIENT_SECRET=
AUTOPILOT_BOT_SLACK_SIGNING_SECRET=
AUTOPILOT_BOT_SLACK_TOKEN=
# Telegram adapter — set the BotFather token + a webhook secret of your choice
# to mount the Telegram webhook route, then register the webhook once:
# curl "https://api.telegram.org/bot<TOKEN>/setWebhook" # -d "url=<PLATFORM_BASE_URL>/api/copilot-webhooks/telegram/updates" # -d "secret_token=<AUTOPILOT_BOT_TELEGRAM_WEBHOOK_SECRET>" # -d "allowed_updates=[\"message\",\"my_chat_member\"]"
# The username (without @) powers the "Add bot to Telegram" t.me link.
AUTOPILOT_BOT_TELEGRAM_TOKEN=
AUTOPILOT_BOT_TELEGRAM_USERNAME=
AUTOPILOT_BOT_TELEGRAM_WEBHOOK_SECRET=

# Communication Services
DISCORD_BOT_TOKEN=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from backend.copilot.bot.adapters.discord import config as discord_config
from backend.copilot.bot.adapters.slack import config as slack_config
from backend.copilot.bot.adapters.telegram import config as telegram_config
from backend.util.settings import Settings

# Backend route that starts the Slack "Add to Slack" OAuth install (kept in sync
Expand All @@ -37,7 +38,7 @@ def enabled_platforms() -> list[PlatformMeta]:
Platforms whose adapter isn't configured (missing credentials) are
omitted entirely so the Bots page hides them.
"""
all_platforms = [_discord_meta(), _slack_meta()]
all_platforms = [_discord_meta(), _slack_meta(), _telegram_meta()]
return [platform for platform in all_platforms if platform.enabled]


Expand Down Expand Up @@ -71,6 +72,27 @@ def _slack_meta() -> PlatformMeta:
)


def _telegram_meta() -> PlatformMeta:
# Enabled on the same gate the webhook adapter mounts on. The t.me
# startgroup deep link opens Telegram's own "add to group" picker, so the
# button only renders when the bot's public username is configured.
enabled = bool(
telegram_config.get_bot_token() and telegram_config.get_webhook_secret()
)
username = telegram_config.get_bot_username().lstrip("@")
return PlatformMeta(
platform="TELEGRAM",
display_name="Telegram",
icon="telegram.png",
enabled=enabled,
add_bot_url=(
f"https://t.me/{username}?startgroup=true"
if (enabled and username)
else None
),
)


def _slack_install_url() -> str | None:
base = Settings().config.platform_base_url.rstrip("/")
if not base:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,19 @@ def _discord_off():
return patch(f"{_REG}.discord_config.get_bot_token", return_value="")


def _telegram_off():
return patch(f"{_REG}.telegram_config.get_bot_token", return_value="")


def test_no_platforms_when_none_configured():
with _discord_off(), _slack_off():
with _discord_off(), _slack_off(), _telegram_off():
assert enabled_platforms() == []


def test_discord_appears_with_invite_url_when_client_id_set():
with (
_slack_off(),
_telegram_off(),
patch(f"{_REG}.discord_config.get_bot_token", return_value="token"),
patch(f"{_REG}.discord_config.get_client_id", return_value="my-client-id"),
patch(f"{_REG}.discord_config.get_invite_permissions", return_value="123"),
Expand All @@ -51,6 +56,7 @@ def test_discord_appears_with_invite_url_when_client_id_set():
def test_discord_appears_without_invite_url_when_client_id_missing():
with (
_slack_off(),
_telegram_off(),
patch(f"{_REG}.discord_config.get_bot_token", return_value="token"),
patch(f"{_REG}.discord_config.get_client_id", return_value=""),
):
Expand All @@ -66,6 +72,7 @@ def test_slack_appears_in_single_workspace_mode_without_install_url():
oauth_id_off, oauth_secret_off = _slack_oauth_off()
with (
_discord_off(),
_telegram_off(),
patch(f"{_REG}.slack_config.get_bot_token", return_value="xoxb-x"),
patch(f"{_REG}.slack_config.get_signing_secret", return_value="secret"),
oauth_id_off,
Expand All @@ -84,6 +91,7 @@ def test_slack_appears_in_single_workspace_mode_without_install_url():
def test_slack_add_to_slack_url_when_oauth_configured():
with (
_discord_off(),
_telegram_off(),
patch(f"{_REG}.slack_config.get_bot_token", return_value=""),
patch(f"{_REG}.slack_config.get_signing_secret", return_value="secret"),
patch(f"{_REG}.slack_config.get_client_id", return_value="cid"),
Expand All @@ -105,6 +113,7 @@ def test_slack_add_to_slack_url_when_oauth_configured():
def test_slack_hidden_when_signing_secret_missing():
with (
_discord_off(),
_telegram_off(),
patch(f"{_REG}.slack_config.get_bot_token", return_value="xoxb-x"),
patch(f"{_REG}.slack_config.get_signing_secret", return_value=""),
):
Expand All @@ -114,6 +123,7 @@ def test_slack_hidden_when_signing_secret_missing():
def test_both_platforms_when_both_configured():
oauth_id_off, oauth_secret_off = _slack_oauth_off()
with (
_telegram_off(),
patch(f"{_REG}.discord_config.get_bot_token", return_value="token"),
patch(f"{_REG}.discord_config.get_client_id", return_value=""),
patch(f"{_REG}.slack_config.get_bot_token", return_value="xoxb-x"),
Expand All @@ -124,3 +134,44 @@ def test_both_platforms_when_both_configured():
platforms = enabled_platforms()

assert {p.platform for p in platforms} == {"DISCORD", "SLACK"}


def test_telegram_appears_with_tme_link_when_username_set():
with (
_discord_off(),
_slack_off(),
patch(f"{_REG}.telegram_config.get_bot_token", return_value="123:abc"),
patch(f"{_REG}.telegram_config.get_webhook_secret", return_value="s3cret"),
patch(f"{_REG}.telegram_config.get_bot_username", return_value="AutoGPTBot"),
):
platforms = enabled_platforms()

assert [p.platform for p in platforms] == ["TELEGRAM"]
telegram = platforms[0]
assert telegram.display_name == "Telegram"
assert telegram.icon == "telegram.png"
assert telegram.add_bot_url == "https://t.me/AutoGPTBot?startgroup=true"


def test_telegram_without_username_has_no_add_bot_url():
with (
_discord_off(),
_slack_off(),
patch(f"{_REG}.telegram_config.get_bot_token", return_value="123:abc"),
patch(f"{_REG}.telegram_config.get_webhook_secret", return_value="s3cret"),
patch(f"{_REG}.telegram_config.get_bot_username", return_value=""),
):
platforms = enabled_platforms()

assert [p.platform for p in platforms] == ["TELEGRAM"]
assert platforms[0].add_bot_url is None


def test_telegram_hidden_without_webhook_secret():
with (
_discord_off(),
_slack_off(),
patch(f"{_REG}.telegram_config.get_bot_token", return_value="123:abc"),
patch(f"{_REG}.telegram_config.get_webhook_secret", return_value=""),
):
assert enabled_platforms() == []
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@

from autogpt_libs import auth
from fastapi import APIRouter, HTTPException, Path, Security
from pydantic import BaseModel, Field

from backend.copilot.bot.adapters.telegram import config as telegram_config
from backend.copilot.bot.adapters.telegram.login import verify_login
from backend.data.db_accessors import platform_linking_db
from backend.platform_linking.models import (
BotPlatformInfo,
Expand Down Expand Up @@ -36,6 +39,26 @@
]


class ConfirmLinkRequest(BaseModel):
"""Optional confirm payload. ``telegram_auth`` carries the signed identity
Telegram appends when the user reached this page via a login_url button —
when present it must verify, and the link token must belong to that same
Telegram user."""

telegram_auth: dict[str, str] | None = Field(default=None)


def _verified_platform_user(body: ConfirmLinkRequest | None) -> str | None:
if body is None or not body.telegram_auth:
return None
verified = verify_login(body.telegram_auth, telegram_config.get_bot_token())
if verified is None:
raise HTTPException(
status_code=403, detail="Telegram login data failed verification."
)
return verified


def _translate(exc: Exception) -> HTTPException:
if isinstance(exc, NotFoundError):
return HTTPException(status_code=404, detail=str(exc))
Expand Down Expand Up @@ -72,11 +95,15 @@ async def get_link_token_info_route(token: TokenPath) -> LinkTokenInfoResponse:
async def confirm_link_token(
token: TokenPath,
user_id: Annotated[str, Security(auth.get_user_id)],
body: ConfirmLinkRequest | None = None,
) -> ConfirmLinkResponse:
try:
return await platform_linking_db().confirm_server_link(token, user_id)
return await platform_linking_db().confirm_server_link(
token, user_id, verified_platform_user_id=_verified_platform_user(body)
)
except (
NotFoundError,
NotAuthorizedError,
LinkFlowMismatchError,
Comment thread
Bentlybro marked this conversation as resolved.
LinkTokenExpiredError,
LinkAlreadyExistsError,
Expand All @@ -93,11 +120,15 @@ async def confirm_link_token(
async def confirm_user_link_token(
token: TokenPath,
user_id: Annotated[str, Security(auth.get_user_id)],
body: ConfirmLinkRequest | None = None,
) -> ConfirmUserLinkResponse:
try:
return await platform_linking_db().confirm_user_link(token, user_id)
return await platform_linking_db().confirm_user_link(
token, user_id, verified_platform_user_id=_verified_platform_user(body)
)
except (
NotFoundError,
NotAuthorizedError,
LinkFlowMismatchError,
LinkTokenExpiredError,
LinkAlreadyExistsError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,94 @@ async def test_omits_platforms_whose_adapter_isnt_configured(self):
result = await list_bot_platforms(user_id="u1")

assert result == []


class TestTelegramLoginVerification:
@pytest.mark.asyncio
async def test_invalid_telegram_auth_is_403_before_any_db_call(self):
from backend.api.features.platform_linking.routes import (
ConfirmLinkRequest,
confirm_user_link_token,
)

confirm = AsyncMock()
db = _db_mock(confirm_user_link=confirm)
with (
patch(
"backend.api.features.platform_linking.routes.platform_linking_db",
return_value=db,
),
patch(
"backend.api.features.platform_linking.routes.verify_login",
return_value=None,
),
):
with pytest.raises(HTTPException) as exc:
await confirm_user_link_token(
"tok",
"user-1",
body=ConfirmLinkRequest(telegram_auth={"id": "1", "hash": "x"}),
)
assert exc.value.status_code == 403
confirm.assert_not_awaited()

@pytest.mark.asyncio
async def test_verified_identity_is_forwarded_to_confirm(self):
from backend.api.features.platform_linking.routes import (
ConfirmLinkRequest,
confirm_user_link_token,
)

confirm = AsyncMock(return_value=MagicMock())
db = _db_mock(confirm_user_link=confirm)
with (
patch(
"backend.api.features.platform_linking.routes.platform_linking_db",
return_value=db,
),
patch(
"backend.api.features.platform_linking.routes.verify_login",
return_value="424242",
),
):
await confirm_user_link_token(
"tok",
"user-1",
body=ConfirmLinkRequest(telegram_auth={"id": "424242", "hash": "x"}),
)
confirm.assert_awaited_once_with(
"tok", "user-1", verified_platform_user_id="424242"
)

@pytest.mark.asyncio
async def test_no_body_confirms_without_verification(self):
from backend.api.features.platform_linking.routes import (
confirm_user_link_token,
)

confirm = AsyncMock(return_value=MagicMock())
db = _db_mock(confirm_user_link=confirm)
with patch(
"backend.api.features.platform_linking.routes.platform_linking_db",
return_value=db,
):
await confirm_user_link_token("tok", "user-1", body=None)
confirm.assert_awaited_once_with(
"tok", "user-1", verified_platform_user_id=None
)

@pytest.mark.asyncio
async def test_identity_mismatch_from_db_maps_to_403(self):
from backend.api.features.platform_linking.routes import (
confirm_user_link_token,
)

confirm = AsyncMock(side_effect=NotAuthorizedError("different user"))
db = _db_mock(confirm_user_link=confirm)
with patch(
"backend.api.features.platform_linking.routes.platform_linking_db",
return_value=db,
):
with pytest.raises(HTTPException) as exc:
await confirm_user_link_token("tok", "user-1", body=None)
assert exc.value.status_code == 403
23 changes: 15 additions & 8 deletions autogpt_platform/backend/backend/copilot/bot/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CoPilot Bot

Multi-platform chat bot that bridges AutoPilot to Discord (and later Telegram, Slack, etc).
Multi-platform chat bot that bridges AutoGPT to Discord, Slack, and Telegram (Teams/WhatsApp next).

## Running

Expand Down Expand Up @@ -38,6 +38,7 @@ See `backend/.env.default` for the full list with documentation. Minimum setup:
|----------|---------|
| `AUTOPILOT_BOT_DISCORD_TOKEN` | Discord bot token — enables the Discord (socket) adapter |
| `AUTOPILOT_BOT_SLACK_TOKEN` + `AUTOPILOT_BOT_SLACK_SIGNING_SECRET` | Slack bot token + signing secret — set **both** to mount the Slack (webhook / Events API) adapter on the main backend API |
| `AUTOPILOT_BOT_TELEGRAM_TOKEN` + `AUTOPILOT_BOT_TELEGRAM_WEBHOOK_SECRET` | Telegram BotFather token + webhook secret — set **both** to mount the Telegram (webhook / Bot API) adapter, then register the webhook once with `setWebhook` (see `.env.default`). Also disable group privacy mode via BotFather `/setprivacy` or the bot can't see @mentions in groups |
| `FRONTEND_BASE_URL` | Frontend base URL for link confirmation pages (shared with the rest of the backend) |
| `REDIS_HOST` / `REDIS_PORT` | Session + thread subscription state + copilot stream subscription (inherited from the shared backend config) |
| `PLATFORMLINKINGMANAGER_HOST` | DNS name of the `PlatformLinkingManager` service pod (cluster-internal RPC) |
Expand All @@ -64,13 +65,19 @@ bot/
│ ├── adapter.py # Gateway connection, events, sends, thread creation
│ ├── commands.py # Slash commands (/setup, /help, /unlink)
│ └── config.py # Discord token + platform limits
└── slack/ # WebhookAdapter — Slack Events API
├── adapter.py # Inbound event/command routes, sends, mrkdwn, attachments
├── commands.py # Slash commands (/setup, /help, /unlink)
├── config.py # Slack token + signing secret + platform limits
├── signing.py # HMAC-SHA256 request signature verification
├── text.py # CommonMark → Slack mrkdwn
└── app-manifest.yaml # Importable Slack app definition (scopes, events, commands)
├── slack/ # WebhookAdapter — Slack Events API
│ ├── adapter.py # Inbound event/command routes, sends, mrkdwn, attachments
│ ├── commands.py # Slash commands (/setup, /help, /unlink)
│ ├── config.py # Slack token + signing secret + platform limits
│ ├── signing.py # HMAC-SHA256 request signature verification
│ ├── text.py # CommonMark → Slack mrkdwn
│ └── app-manifest.yaml # Importable Slack app definition (scopes, events, commands)
└── telegram/ # WebhookAdapter — Telegram Bot API
├── adapter.py # Inbound updates route, sends, chat-model mapping
├── api_client.py # Thin httpx Bot API client (JSON + multipart + getFile)
├── commands.py # Bot commands (/setup, /help, /unlink)
├── config.py # BotFather token + webhook secret + platform limits
└── text.py # CommonMark → Telegram HTML
```

**Connector taxonomy.** `PlatformAdapter` is the outbound contract the core
Expand Down
Loading
Loading