Compare commits

...

13 Commits

Author SHA1 Message Date
Bentlybro
77ebcfe55d ci: regenerate openapi.json after platform-linking schema changes 2026-04-02 15:54:21 +00:00
Bentlybro
db029f0b49 refactor(backend): split platform-linking routes into models, auth, routes, chat_proxy
- Split 628-line routes.py into 4 files under ~300 lines each:
  models.py (Pydantic models), auth.py (bot API key validation),
  routes.py (linking routes), chat_proxy.py (bot chat endpoints)
- Move all inline imports to top-level
- Read PLATFORM_BOT_API_KEY per-request instead of module-level global
- Read PLATFORM_LINK_BASE_URL per-request in create_link_token
- Use backend Settings().config.enable_auth for dev-mode bypass
  instead of raw ENV env var
- Add Path validation (max_length=64, regex) on token path params
- Update test imports to match new module paths
2026-04-01 11:38:56 +00:00
Bentlybro
a268291585 fix: Address review round 2 — all 4 blockers
1. **Broken bot auth (CRITICAL)** — Replaced `Depends(lambda req: ...)`
   with proper `async def get_bot_api_key(request: Request)` dependency.
   The lambda pattern caused FastAPI to interpret `req` as a required
   query parameter, making all bot endpoints return 422.

2. **Timing-safe key comparison** — Switched from `!=` to
   `hmac.compare_digest()` to prevent timing side-channel attacks.

3. **Removed dead code** — Deleted the unused `verify_bot_api_key`
   function that had a stub body passing all requests.

4. **TOCTOU race in confirm** — Wrapped `PlatformLink.prisma().create()`
   in try/except for unique constraint violations. Concurrent requests
   with different tokens for the same identity now get a clean 409
   instead of an unhandled 500.

Also: regenerated openapi.json (removes spurious `req` query parameter
that was leaking from the broken lambda pattern).
2026-03-31 16:03:08 +00:00
Bentlybro
9caca6d899 fix: correct unsubscribe_from_session parameter (subscriber_queue not user_id) 2026-03-31 15:56:09 +00:00
Bentlybro
481f704317 feat: Bot chat proxy — CoPilot streaming via bot API key
Adds two bot-facing endpoints that let the bot service call CoPilot
on behalf of linked users without needing their JWT:

- POST /api/platform-linking/chat/session — create a CoPilot session
- POST /api/platform-linking/chat/stream — send message + stream SSE response

Both authenticated via X-Bot-API-Key header. The bot passes user_id
from the platform-linking resolve step. Backend reuses the existing
CoPilot pipeline (enqueue_copilot_turn → Redis stream → SSE).

This is the last missing piece — the bot can now:
1. Resolve platform user → AutoGPT account (existing)
2. Create a chat session (new)
3. Stream CoPilot responses back to the user (new)
2026-03-31 15:48:31 +00:00
Bentlybro
781224f8d5 fix: use model_validate for invalid platform test (no type ignore) 2026-03-31 14:24:19 +00:00
Bentlybro
8a91bd8a99 fix: suppress pyright error on intentionally invalid test input 2026-03-31 14:22:55 +00:00
Bentlybro
7bf10c605a fix: Address review feedback on platform linking PR
Fixes all blockers and should-fix items from the automated review:

## Blockers fixed

1. **Bot-facing endpoint auth** — Added X-Bot-API-Key header auth for
   all bot-facing endpoints (POST /tokens, GET /tokens/{token}/status,
   POST /resolve). Configurable via PLATFORM_BOT_API_KEY env var.
   Dev mode allows keyless access.

2. **Race condition in confirm_link_token** — Replaced 5 separate DB
   calls with atomic update_many (WHERE token=X AND usedAt=NULL).
   If another request consumed the token first, returns 410 cleanly
   instead of a 500 unique constraint violation.

3. **Test coverage** — Added tests for: Platform enum, bot API key
   auth (valid/invalid/missing), request validation (empty IDs,
   too-long IDs, invalid platforms), response model typing.

## Should-fix items addressed

- **Enum duplication** — Created Python Platform(str, Enum) that
  mirrors the Prisma PlatformType. Used throughout request models
  for validation instead of bare strings.
- **Hardcoded base URL** — Now configurable via PLATFORM_LINK_BASE_URL
  env var. Defaults to https://platform.agpt.co/link.
- **Redundant @@index([token])** — Removed from schema and migration.
  The @unique already creates an index.
- **Literal status type** — LinkTokenStatusResponse.status is now
  Literal['pending', 'linked', 'expired'] not bare str.
- **delete_link returns dict** — Now returns DeleteLinkResponse model.
- **Input length validation** — Added min_length=1, max_length=255
  to platform_user_id and platform_username fields.
- **Token flooding** — Existing pending tokens are invalidated before
  creating a new one (only 1 active token per platform identity).
- **Resolve leaks user info** — Removed platform_username from
  resolve response (only returns user_id + linked boolean).
- **Error messages** — Sanitized to not expose internal IDs.
- **Logger** — Switched from f-strings to lazy %s formatting.
2026-03-31 14:10:53 +00:00
Bentlybro
7778de1d7b fix: import ordering (isort) 2026-03-31 13:00:41 +00:00
Bentlybro
015a626379 chore: update openapi.json with platform-linking endpoints 2026-03-31 12:41:49 +00:00
Bentlybro
be6501f10e fix: use Model.prisma() pattern for pyright compatibility
Switched from backend.data.db.get_prisma() to the standard
PlatformLink.prisma() / PlatformLinkToken.prisma() pattern
used throughout the codebase.
2026-03-31 12:25:26 +00:00
Bentlybro
32f6ef0a45 style: lint + format platform linking routes 2026-03-31 12:14:06 +00:00
Bentlybro
c7d5c1c844 feat: Platform bot linking API for multi-platform CoPilot
Adds the account linking system that enables CoPilot to work across
multiple chat platforms (Discord, Telegram, Slack, Teams, etc.) via
the Vercel Chat SDK.

## What this adds

### Database (Prisma + migration)
- PlatformType enum (DISCORD, TELEGRAM, SLACK, TEAMS, WHATSAPP, GITHUB, LINEAR)
- PlatformLink model - maps platform user IDs to AutoGPT accounts
  - Unique constraint on (platform, platformUserId)
  - One AutoGPT user can link multiple platforms
- PlatformLinkToken model - one-time tokens for the linking flow

### API endpoints (/api/platform-linking)

Bot-facing (called by the bot service):
- POST /tokens - Create a link token for an unlinked platform user
- GET /tokens/{token}/status - Check if linking is complete
- POST /resolve - Resolve platform identity → AutoGPT user ID

User-facing (JWT auth required):
- POST /tokens/{token}/confirm - Complete the link (user logs in first)
- GET /links - List all linked platform identities
- DELETE /links/{link_id} - Unlink a platform identity

## Linking flow
1. User messages bot on Discord/Telegram/etc
2. Bot calls POST /resolve → not linked
3. Bot calls POST /tokens → gets link URL
4. Bot sends user the link
5. User clicks → logs in to AutoGPT → frontend calls POST /confirm
6. Bot detects link on next message (or polls /status)
2026-03-31 12:10:15 +00:00
10 changed files with 1371 additions and 0 deletions

View File

@@ -0,0 +1 @@
# Platform bot linking API

View File

@@ -0,0 +1,35 @@
"""Bot API key authentication for platform linking endpoints."""
import hmac
import os
from fastapi import HTTPException, Request
from backend.util.settings import Settings
async def get_bot_api_key(request: Request) -> str | None:
"""Extract the bot API key from the X-Bot-API-Key header."""
return request.headers.get("x-bot-api-key")
def check_bot_api_key(api_key: str | None) -> None:
"""Validate the bot API key. Uses constant-time comparison.
Reads the key from env on each call so rotated secrets take effect
without restarting the process.
"""
configured_key = os.getenv("PLATFORM_BOT_API_KEY", "")
if not configured_key:
settings = Settings()
if settings.config.enable_auth:
raise HTTPException(
status_code=503,
detail="Bot API key not configured.",
)
# Auth disabled (local dev) — allow without key
return
if not api_key or not hmac.compare_digest(api_key, configured_key):
raise HTTPException(status_code=401, detail="Invalid bot API key.")

View File

@@ -0,0 +1,170 @@
"""
Bot Chat Proxy endpoints.
Allows the bot service to send messages to CoPilot on behalf of
linked users, authenticated via bot API key.
"""
import asyncio
import logging
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from prisma.models import PlatformLink
from backend.copilot import stream_registry
from backend.copilot.executor.utils import enqueue_copilot_turn
from backend.copilot.model import (
ChatMessage,
append_and_save_message,
create_chat_session,
get_chat_session,
)
from backend.copilot.response_model import StreamFinish
from .auth import check_bot_api_key, get_bot_api_key
from .models import BotChatRequest, BotChatSessionResponse
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/chat/session",
response_model=BotChatSessionResponse,
summary="Create a CoPilot session for a linked user (bot-facing)",
)
async def bot_create_session(
request: BotChatRequest,
x_bot_api_key: str | None = Depends(get_bot_api_key),
) -> BotChatSessionResponse:
"""Creates a new CoPilot chat session on behalf of a linked user."""
check_bot_api_key(x_bot_api_key)
link = await PlatformLink.prisma().find_first(where={"userId": request.user_id})
if not link:
raise HTTPException(status_code=404, detail="User has no platform links.")
session = await create_chat_session(request.user_id)
return BotChatSessionResponse(session_id=session.session_id)
@router.post(
"/chat/stream",
summary="Stream a CoPilot response for a linked user (bot-facing)",
)
async def bot_chat_stream(
request: BotChatRequest,
x_bot_api_key: str | None = Depends(get_bot_api_key),
):
"""
Send a message to CoPilot on behalf of a linked user and stream
the response back as Server-Sent Events.
The bot authenticates with its API key — no user JWT needed.
"""
check_bot_api_key(x_bot_api_key)
user_id = request.user_id
# Verify user has a platform link
link = await PlatformLink.prisma().find_first(where={"userId": user_id})
if not link:
raise HTTPException(status_code=404, detail="User has no platform links.")
# Get or create session
session_id = request.session_id
if session_id:
session = await get_chat_session(session_id, user_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found.")
else:
session = await create_chat_session(user_id)
session_id = session.session_id
# Save user message
message = ChatMessage(role="user", content=request.message)
await append_and_save_message(session_id, message)
# Create a turn and enqueue
turn_id = str(uuid4())
await stream_registry.create_session(
session_id=session_id,
user_id=user_id,
tool_call_id="chat_stream",
tool_name="chat",
turn_id=turn_id,
)
subscribe_from_id = "0-0"
await enqueue_copilot_turn(
session_id=session_id,
user_id=user_id,
message=request.message,
turn_id=turn_id,
is_user_message=True,
)
logger.info(
"Bot chat: user ...%s, session %s, turn %s",
user_id[-8:],
session_id,
turn_id,
)
async def event_generator():
subscriber_queue = None
try:
subscriber_queue = await stream_registry.subscribe_to_session(
session_id=session_id,
user_id=user_id,
last_message_id=subscribe_from_id,
)
if subscriber_queue is None:
yield StreamFinish().to_sse()
yield "data: [DONE]\n\n"
return
while True:
try:
chunk = await asyncio.wait_for(subscriber_queue.get(), timeout=30.0)
if isinstance(chunk, str):
yield chunk
else:
yield chunk.to_sse()
if isinstance(chunk, StreamFinish) or (
isinstance(chunk, str) and "[DONE]" in chunk
):
break
except asyncio.TimeoutError:
yield ": keepalive\n\n"
except Exception:
logger.exception("Bot chat stream error for session %s", session_id)
yield 'data: {"type": "error", "content": "Stream error"}\n\n'
yield "data: [DONE]\n\n"
finally:
if subscriber_queue is not None:
await stream_registry.unsubscribe_from_session(
session_id=session_id,
subscriber_queue=subscriber_queue,
)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Session-Id": session_id,
},
)

View File

@@ -0,0 +1,107 @@
"""Pydantic models for the platform bot linking API."""
from datetime import datetime
from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field
class Platform(str, Enum):
"""Supported platform types (mirrors Prisma PlatformType)."""
DISCORD = "DISCORD"
TELEGRAM = "TELEGRAM"
SLACK = "SLACK"
TEAMS = "TEAMS"
WHATSAPP = "WHATSAPP"
GITHUB = "GITHUB"
LINEAR = "LINEAR"
# ── Request Models ─────────────────────────────────────────────────────
class CreateLinkTokenRequest(BaseModel):
"""Request from the bot service to create a linking token."""
platform: Platform = Field(description="Platform name")
platform_user_id: str = Field(
description="The user's ID on the platform",
min_length=1,
max_length=255,
)
platform_username: str | None = Field(
default=None,
description="Display name (best effort)",
max_length=255,
)
channel_id: str | None = Field(
default=None,
description="Channel ID for sending confirmation back",
max_length=255,
)
class ResolveRequest(BaseModel):
"""Resolve a platform identity to an AutoGPT user."""
platform: Platform
platform_user_id: str = Field(min_length=1, max_length=255)
class BotChatRequest(BaseModel):
"""Request from the bot to chat as a linked user."""
user_id: str = Field(description="The linked AutoGPT user ID")
message: str = Field(
description="The user's message", min_length=1, max_length=32000
)
session_id: str | None = Field(
default=None,
description="Existing chat session ID. If omitted, a new session is created.",
)
# ── Response Models ────────────────────────────────────────────────────
class LinkTokenResponse(BaseModel):
token: str
expires_at: datetime
link_url: str
class LinkTokenStatusResponse(BaseModel):
status: Literal["pending", "linked", "expired"]
user_id: str | None = None
class ResolveResponse(BaseModel):
linked: bool
user_id: str | None = None
class PlatformLinkInfo(BaseModel):
id: str
platform: str
platform_user_id: str
platform_username: str | None
linked_at: datetime
class ConfirmLinkResponse(BaseModel):
success: bool
platform: str
platform_user_id: str
platform_username: str | None
class DeleteLinkResponse(BaseModel):
success: bool
class BotChatSessionResponse(BaseModel):
"""Returned when creating a new session via the bot proxy."""
session_id: str

View File

@@ -0,0 +1,340 @@
"""
Platform Bot Linking API routes.
Enables linking external chat platform identities (Discord, Telegram, Slack, etc.)
to AutoGPT user accounts. Used by the multi-platform CoPilot bot.
Flow:
1. Bot calls POST /api/platform-linking/tokens to create a link token
for an unlinked platform user.
2. Bot sends the user a link: {frontend}/link/{token}
3. User clicks the link, logs in to AutoGPT, and the frontend calls
POST /api/platform-linking/tokens/{token}/confirm to complete the link.
4. Bot can poll GET /api/platform-linking/tokens/{token}/status or just
check on next message via GET /api/platform-linking/resolve.
"""
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Annotated
from autogpt_libs import auth
from fastapi import APIRouter, Depends, HTTPException, Path, Security
from prisma.models import PlatformLink, PlatformLinkToken
from .auth import check_bot_api_key, get_bot_api_key
from .models import (
ConfirmLinkResponse,
CreateLinkTokenRequest,
DeleteLinkResponse,
LinkTokenResponse,
LinkTokenStatusResponse,
PlatformLinkInfo,
ResolveRequest,
ResolveResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter()
LINK_TOKEN_EXPIRY_MINUTES = 30
# Path parameter with validation for link tokens
TokenPath = Annotated[
str,
Path(max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
]
# ── Bot-facing endpoints (API key auth) ───────────────────────────────
@router.post(
"/tokens",
response_model=LinkTokenResponse,
summary="Create a link token for an unlinked platform user",
)
async def create_link_token(
request: CreateLinkTokenRequest,
x_bot_api_key: str | None = Depends(get_bot_api_key),
) -> LinkTokenResponse:
"""
Called by the bot service when it encounters an unlinked user.
Generates a one-time token the user can use to link their account.
"""
check_bot_api_key(x_bot_api_key)
platform = request.platform.value
# Check if already linked
existing = await PlatformLink.prisma().find_first(
where={
"platform": platform,
"platformUserId": request.platform_user_id,
}
)
if existing:
raise HTTPException(
status_code=409,
detail="This platform account is already linked.",
)
# Invalidate any existing pending tokens for this user
await PlatformLinkToken.prisma().update_many(
where={
"platform": platform,
"platformUserId": request.platform_user_id,
"usedAt": None,
},
data={"usedAt": datetime.now(timezone.utc)},
)
# Generate token
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(
minutes=LINK_TOKEN_EXPIRY_MINUTES
)
await PlatformLinkToken.prisma().create(
data={
"token": token,
"platform": platform,
"platformUserId": request.platform_user_id,
"platformUsername": request.platform_username,
"channelId": request.channel_id,
"expiresAt": expires_at,
}
)
logger.info(
"Created link token for %s (expires %s)",
platform,
expires_at.isoformat(),
)
link_base_url = os.getenv(
"PLATFORM_LINK_BASE_URL", "https://platform.agpt.co/link"
)
link_url = f"{link_base_url}/{token}"
return LinkTokenResponse(
token=token,
expires_at=expires_at,
link_url=link_url,
)
@router.get(
"/tokens/{token}/status",
response_model=LinkTokenStatusResponse,
summary="Check if a link token has been consumed",
)
async def get_link_token_status(
token: TokenPath,
x_bot_api_key: str | None = Depends(get_bot_api_key),
) -> LinkTokenStatusResponse:
"""
Called by the bot service to check if a user has completed linking.
"""
check_bot_api_key(x_bot_api_key)
link_token = await PlatformLinkToken.prisma().find_unique(where={"token": token})
if not link_token:
raise HTTPException(status_code=404, detail="Token not found")
if link_token.usedAt is not None:
# Token was used — find the linked account
link = await PlatformLink.prisma().find_first(
where={
"platform": link_token.platform,
"platformUserId": link_token.platformUserId,
}
)
return LinkTokenStatusResponse(
status="linked",
user_id=link.userId if link else None,
)
if link_token.expiresAt.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
return LinkTokenStatusResponse(status="expired")
return LinkTokenStatusResponse(status="pending")
@router.post(
"/resolve",
response_model=ResolveResponse,
summary="Resolve a platform identity to an AutoGPT user",
)
async def resolve_platform_user(
request: ResolveRequest,
x_bot_api_key: str | None = Depends(get_bot_api_key),
) -> ResolveResponse:
"""
Called by the bot service on every incoming message to check if
the platform user has a linked AutoGPT account.
"""
check_bot_api_key(x_bot_api_key)
link = await PlatformLink.prisma().find_first(
where={
"platform": request.platform.value,
"platformUserId": request.platform_user_id,
}
)
if not link:
return ResolveResponse(linked=False)
return ResolveResponse(linked=True, user_id=link.userId)
# ── User-facing endpoints (JWT auth) ──────────────────────────────────
@router.post(
"/tokens/{token}/confirm",
response_model=ConfirmLinkResponse,
dependencies=[Security(auth.requires_user)],
summary="Confirm a link token (user must be authenticated)",
)
async def confirm_link_token(
token: TokenPath,
user_id: Annotated[str, Security(auth.get_user_id)],
) -> ConfirmLinkResponse:
"""
Called by the frontend when the user clicks the link and is logged in.
Consumes the token and creates the platform link.
Uses atomic update_many to prevent race conditions on double-click.
"""
link_token = await PlatformLinkToken.prisma().find_unique(where={"token": token})
if not link_token:
raise HTTPException(status_code=404, detail="Token not found.")
if link_token.usedAt is not None:
raise HTTPException(status_code=410, detail="This link has already been used.")
if link_token.expiresAt.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
raise HTTPException(status_code=410, detail="This link has expired.")
# Atomically mark token as used (only if still unused)
updated = await PlatformLinkToken.prisma().update_many(
where={"token": token, "usedAt": None},
data={"usedAt": datetime.now(timezone.utc)},
)
if updated == 0:
raise HTTPException(status_code=410, detail="This link has already been used.")
# Check if this platform identity is already linked
existing = await PlatformLink.prisma().find_first(
where={
"platform": link_token.platform,
"platformUserId": link_token.platformUserId,
}
)
if existing:
detail = (
"This platform account is already linked to your account."
if existing.userId == user_id
else "This platform account is already linked to another user."
)
raise HTTPException(status_code=409, detail=detail)
# Create the link — catch unique constraint race condition
try:
await PlatformLink.prisma().create(
data={
"userId": user_id,
"platform": link_token.platform,
"platformUserId": link_token.platformUserId,
"platformUsername": link_token.platformUsername,
}
)
except Exception as exc:
if "unique" in str(exc).lower():
raise HTTPException(
status_code=409,
detail="This platform account was just linked by another request.",
) from exc
raise
logger.info(
"Linked %s:%s to user ...%s",
link_token.platform,
link_token.platformUserId,
user_id[-8:],
)
return ConfirmLinkResponse(
success=True,
platform=link_token.platform,
platform_user_id=link_token.platformUserId,
platform_username=link_token.platformUsername,
)
@router.get(
"/links",
response_model=list[PlatformLinkInfo],
dependencies=[Security(auth.requires_user)],
summary="List all platform links for the authenticated user",
)
async def list_my_links(
user_id: Annotated[str, Security(auth.get_user_id)],
) -> list[PlatformLinkInfo]:
"""Returns all platform identities linked to the current user's account."""
links = await PlatformLink.prisma().find_many(
where={"userId": user_id},
order={"linkedAt": "desc"},
)
return [
PlatformLinkInfo(
id=link.id,
platform=link.platform,
platform_user_id=link.platformUserId,
platform_username=link.platformUsername,
linked_at=link.linkedAt,
)
for link in links
]
@router.delete(
"/links/{link_id}",
response_model=DeleteLinkResponse,
dependencies=[Security(auth.requires_user)],
summary="Unlink a platform identity",
)
async def delete_link(
link_id: str,
user_id: Annotated[str, Security(auth.get_user_id)],
) -> DeleteLinkResponse:
"""
Removes a platform link. The user will need to re-link if they
want to use the bot on that platform again.
"""
link = await PlatformLink.prisma().find_unique(where={"id": link_id})
if not link:
raise HTTPException(status_code=404, detail="Link not found.")
if link.userId != user_id:
raise HTTPException(status_code=403, detail="Not your link.")
await PlatformLink.prisma().delete(where={"id": link_id})
logger.info(
"Unlinked %s:%s from user ...%s",
link.platform,
link.platformUserId,
user_id[-8:],
)
return DeleteLinkResponse(success=True)

View File

@@ -0,0 +1,138 @@
"""Tests for platform bot linking API routes."""
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from backend.api.features.platform_linking.auth import check_bot_api_key
from backend.api.features.platform_linking.models import (
ConfirmLinkResponse,
CreateLinkTokenRequest,
DeleteLinkResponse,
LinkTokenStatusResponse,
Platform,
ResolveRequest,
)
class TestPlatformEnum:
def test_all_platforms_exist(self):
assert Platform.DISCORD.value == "DISCORD"
assert Platform.TELEGRAM.value == "TELEGRAM"
assert Platform.SLACK.value == "SLACK"
assert Platform.TEAMS.value == "TEAMS"
assert Platform.WHATSAPP.value == "WHATSAPP"
assert Platform.GITHUB.value == "GITHUB"
assert Platform.LINEAR.value == "LINEAR"
class TestBotApiKeyAuth:
@patch.dict("os.environ", {"PLATFORM_BOT_API_KEY": ""}, clear=False)
@patch("backend.api.features.platform_linking.auth.Settings")
def test_no_key_configured_allows_when_auth_disabled(self, mock_settings_cls):
mock_settings_cls.return_value.config.enable_auth = False
check_bot_api_key(None)
@patch.dict("os.environ", {"PLATFORM_BOT_API_KEY": ""}, clear=False)
@patch("backend.api.features.platform_linking.auth.Settings")
def test_no_key_configured_rejects_when_auth_enabled(self, mock_settings_cls):
mock_settings_cls.return_value.config.enable_auth = True
with pytest.raises(HTTPException) as exc_info:
check_bot_api_key(None)
assert exc_info.value.status_code == 503
@patch.dict("os.environ", {"PLATFORM_BOT_API_KEY": "secret123"}, clear=False)
def test_valid_key(self):
check_bot_api_key("secret123")
@patch.dict("os.environ", {"PLATFORM_BOT_API_KEY": "secret123"}, clear=False)
def test_invalid_key_rejected(self):
with pytest.raises(HTTPException) as exc_info:
check_bot_api_key("wrong")
assert exc_info.value.status_code == 401
@patch.dict("os.environ", {"PLATFORM_BOT_API_KEY": "secret123"}, clear=False)
def test_missing_key_rejected(self):
with pytest.raises(HTTPException) as exc_info:
check_bot_api_key(None)
assert exc_info.value.status_code == 401
class TestCreateLinkTokenRequest:
def test_valid_request(self):
req = CreateLinkTokenRequest(
platform=Platform.DISCORD,
platform_user_id="353922987235213313",
)
assert req.platform == Platform.DISCORD
assert req.platform_user_id == "353922987235213313"
def test_empty_platform_user_id_rejected(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
CreateLinkTokenRequest(
platform=Platform.DISCORD,
platform_user_id="",
)
def test_too_long_platform_user_id_rejected(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
CreateLinkTokenRequest(
platform=Platform.DISCORD,
platform_user_id="x" * 256,
)
def test_invalid_platform_rejected(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
CreateLinkTokenRequest.model_validate(
{"platform": "INVALID", "platform_user_id": "123"}
)
class TestResolveRequest:
def test_valid_request(self):
req = ResolveRequest(
platform=Platform.TELEGRAM,
platform_user_id="123456789",
)
assert req.platform == Platform.TELEGRAM
def test_empty_id_rejected(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
ResolveRequest(
platform=Platform.SLACK,
platform_user_id="",
)
class TestResponseModels:
def test_link_token_status_literal(self):
resp = LinkTokenStatusResponse(status="pending")
assert resp.status == "pending"
resp = LinkTokenStatusResponse(status="linked", user_id="abc")
assert resp.status == "linked"
resp = LinkTokenStatusResponse(status="expired")
assert resp.status == "expired"
def test_confirm_link_response(self):
resp = ConfirmLinkResponse(
success=True,
platform="DISCORD",
platform_user_id="123",
platform_username="testuser",
)
assert resp.success is True
def test_delete_link_response(self):
resp = DeleteLinkResponse(success=True)
assert resp.success is True

View File

@@ -30,6 +30,8 @@ import backend.api.features.library.routes
import backend.api.features.mcp.routes as mcp_routes
import backend.api.features.oauth
import backend.api.features.otto.routes
import backend.api.features.platform_linking.chat_proxy
import backend.api.features.platform_linking.routes
import backend.api.features.postmark.postmark
import backend.api.features.store.model
import backend.api.features.store.routes
@@ -361,6 +363,16 @@ app.include_router(
tags=["oauth"],
prefix="/api/oauth",
)
app.include_router(
backend.api.features.platform_linking.routes.router,
tags=["platform-linking"],
prefix="/api/platform-linking",
)
app.include_router(
backend.api.features.platform_linking.chat_proxy.router,
tags=["platform-linking"],
prefix="/api/platform-linking",
)
app.mount("/external-api", external_api)

View File

@@ -0,0 +1,44 @@
-- CreateEnum
CREATE TYPE "PlatformType" AS ENUM ('DISCORD', 'TELEGRAM', 'SLACK', 'TEAMS', 'WHATSAPP', 'GITHUB', 'LINEAR');
-- CreateTable
CREATE TABLE "PlatformLink" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"platform" "PlatformType" NOT NULL,
"platformUserId" TEXT NOT NULL,
"platformUsername" TEXT,
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PlatformLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PlatformLinkToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"platform" "PlatformType" NOT NULL,
"platformUserId" TEXT NOT NULL,
"platformUsername" TEXT,
"channelId" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PlatformLinkToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PlatformLink_platform_platformUserId_key" ON "PlatformLink"("platform", "platformUserId");
-- CreateIndex
CREATE INDEX "PlatformLink_userId_idx" ON "PlatformLink"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "PlatformLinkToken_token_key" ON "PlatformLinkToken"("token");
-- CreateIndex
CREATE INDEX "PlatformLinkToken_expiresAt_idx" ON "PlatformLinkToken"("expiresAt");
-- AddForeignKey
ALTER TABLE "PlatformLink" ADD CONSTRAINT "PlatformLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -71,6 +71,9 @@ model User {
OAuthAuthorizationCodes OAuthAuthorizationCode[]
OAuthAccessTokens OAuthAccessToken[]
OAuthRefreshTokens OAuthRefreshToken[]
// Platform bot linking
PlatformLinks PlatformLink[]
}
enum OnboardingStep {
@@ -1302,3 +1305,50 @@ model OAuthRefreshToken {
@@index([userId, applicationId])
@@index([expiresAt]) // For cleanup
}
// ── Platform Bot Linking ──────────────────────────────────────────────
// Links external chat platform identities (Discord, Telegram, Slack, etc.)
// to AutoGPT user accounts, enabling the multi-platform CoPilot bot.
enum PlatformType {
DISCORD
TELEGRAM
SLACK
TEAMS
WHATSAPP
GITHUB
LINEAR
}
// Maps a platform user identity to an AutoGPT account.
// One AutoGPT user can have multiple platform links (e.g. Discord + Telegram).
// Each platform identity can only link to one AutoGPT account.
model PlatformLink {
id String @id @default(uuid())
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
platform PlatformType
platformUserId String // The user's ID on that platform
platformUsername String? // Display name (best-effort, may go stale)
linkedAt DateTime @default(now())
@@unique([platform, platformUserId])
@@index([userId])
}
// One-time tokens for the account linking flow.
// Generated when an unlinked user messages the bot; consumed when they
// complete the link on the AutoGPT web app.
model PlatformLinkToken {
id String @id @default(uuid())
token String @unique
platform PlatformType
platformUserId String
platformUsername String?
channelId String? // So the bot can send a confirmation message
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
@@index([expiresAt])
}

View File

@@ -5457,6 +5457,290 @@
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/platform-linking/chat/session": {
"post": {
"tags": ["platform-linking"],
"summary": "Create a CoPilot session for a linked user (bot-facing)",
"description": "Creates a new CoPilot chat session on behalf of a linked user.",
"operationId": "postPlatform-linkingCreate a copilot session for a linked user (bot-facing)",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/BotChatRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BotChatSessionResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/platform-linking/chat/stream": {
"post": {
"tags": ["platform-linking"],
"summary": "Stream a CoPilot response for a linked user (bot-facing)",
"description": "Send a message to CoPilot on behalf of a linked user and stream\nthe response back as Server-Sent Events.\n\nThe bot authenticates with its API key — no user JWT needed.",
"operationId": "postPlatform-linkingStream a copilot response for a linked user (bot-facing)",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/BotChatRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/platform-linking/links": {
"get": {
"tags": ["platform-linking"],
"summary": "List all platform links for the authenticated user",
"description": "Returns all platform identities linked to the current user's account.",
"operationId": "getPlatform-linkingList all platform links for the authenticated user",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": { "$ref": "#/components/schemas/PlatformLinkInfo" },
"type": "array",
"title": "Response Getplatform-Linkinglist All Platform Links For The Authenticated User"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/platform-linking/links/{link_id}": {
"delete": {
"tags": ["platform-linking"],
"summary": "Unlink a platform identity",
"description": "Removes a platform link. The user will need to re-link if they\nwant to use the bot on that platform again.",
"operationId": "deletePlatform-linkingUnlink a platform identity",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "link_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Link Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/DeleteLinkResponse" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/platform-linking/resolve": {
"post": {
"tags": ["platform-linking"],
"summary": "Resolve a platform identity to an AutoGPT user",
"description": "Called by the bot service on every incoming message to check if\nthe platform user has a linked AutoGPT account.",
"operationId": "postPlatform-linkingResolve a platform identity to an autogpt user",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ResolveRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ResolveResponse" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/platform-linking/tokens": {
"post": {
"tags": ["platform-linking"],
"summary": "Create a link token for an unlinked platform user",
"description": "Called by the bot service when it encounters an unlinked user.\nGenerates a one-time token the user can use to link their account.",
"operationId": "postPlatform-linkingCreate a link token for an unlinked platform user",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateLinkTokenRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LinkTokenResponse" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/platform-linking/tokens/{token}/confirm": {
"post": {
"tags": ["platform-linking"],
"summary": "Confirm a link token (user must be authenticated)",
"description": "Called by the frontend when the user clicks the link and is logged in.\nConsumes the token and creates the platform link.\nUses atomic update_many to prevent race conditions on double-click.",
"operationId": "postPlatform-linkingConfirm a link token (user must be authenticated)",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string",
"maxLength": 64,
"pattern": "^[A-Za-z0-9_-]+$",
"title": "Token"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ConfirmLinkResponse" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/platform-linking/tokens/{token}/status": {
"get": {
"tags": ["platform-linking"],
"summary": "Check if a link token has been consumed",
"description": "Called by the bot service to check if a user has completed linking.",
"operationId": "getPlatform-linkingCheck if a link token has been consumed",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string",
"maxLength": 64,
"pattern": "^[A-Za-z0-9_-]+$",
"title": "Token"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LinkTokenStatusResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/public/shared/{share_token}": {
"get": {
"tags": ["v1"],
@@ -8312,6 +8596,40 @@
"required": ["file"],
"title": "Body_postWorkspaceUpload file to workspace"
},
"BotChatRequest": {
"properties": {
"user_id": {
"type": "string",
"title": "User Id",
"description": "The linked AutoGPT user ID"
},
"message": {
"type": "string",
"maxLength": 32000,
"minLength": 1,
"title": "Message",
"description": "The user's message"
},
"session_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Session Id",
"description": "Existing chat session ID. If omitted, a new session is created."
}
},
"type": "object",
"required": ["user_id", "message"],
"title": "BotChatRequest",
"description": "Request from the bot to chat as a linked user."
},
"BotChatSessionResponse": {
"properties": {
"session_id": { "type": "string", "title": "Session Id" }
},
"type": "object",
"required": ["session_id"],
"title": "BotChatSessionResponse",
"description": "Returned when creating a new session via the bot proxy."
},
"BulkMoveAgentsRequest": {
"properties": {
"agent_ids": {
@@ -8427,6 +8745,25 @@
"title": "CoPilotUsageStatus",
"description": "Current usage status for a user across all windows."
},
"ConfirmLinkResponse": {
"properties": {
"success": { "type": "boolean", "title": "Success" },
"platform": { "type": "string", "title": "Platform" },
"platform_user_id": { "type": "string", "title": "Platform User Id" },
"platform_username": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Platform Username"
}
},
"type": "object",
"required": [
"success",
"platform",
"platform_user_id",
"platform_username"
],
"title": "ConfirmLinkResponse"
},
"ContentType": {
"type": "string",
"enum": [
@@ -8504,6 +8841,41 @@
"required": ["graph"],
"title": "CreateGraph"
},
"CreateLinkTokenRequest": {
"properties": {
"platform": {
"$ref": "#/components/schemas/Platform",
"description": "Platform name"
},
"platform_user_id": {
"type": "string",
"maxLength": 255,
"minLength": 1,
"title": "Platform User Id",
"description": "The user's ID on the platform"
},
"platform_username": {
"anyOf": [
{ "type": "string", "maxLength": 255 },
{ "type": "null" }
],
"title": "Platform Username",
"description": "Display name (best effort)"
},
"channel_id": {
"anyOf": [
{ "type": "string", "maxLength": 255 },
{ "type": "null" }
],
"title": "Channel Id",
"description": "Channel ID for sending confirmation back"
}
},
"type": "object",
"required": ["platform", "platform_user_id"],
"title": "CreateLinkTokenRequest",
"description": "Request from the bot service to create a linking token."
},
"CreateSessionResponse": {
"properties": {
"id": { "type": "string", "title": "Id" },
@@ -8686,6 +9058,12 @@
"required": ["version_counts"],
"title": "DeleteGraphResponse"
},
"DeleteLinkResponse": {
"properties": { "success": { "type": "boolean", "title": "Success" } },
"type": "object",
"required": ["success"],
"title": "DeleteLinkResponse"
},
"DiscoverToolsRequest": {
"properties": {
"server_url": {
@@ -10457,6 +10835,36 @@
"required": ["source_id", "sink_id", "source_name", "sink_name"],
"title": "Link"
},
"LinkTokenResponse": {
"properties": {
"token": { "type": "string", "title": "Token" },
"expires_at": {
"type": "string",
"format": "date-time",
"title": "Expires At"
},
"link_url": { "type": "string", "title": "Link Url" }
},
"type": "object",
"required": ["token", "expires_at", "link_url"],
"title": "LinkTokenResponse"
},
"LinkTokenStatusResponse": {
"properties": {
"status": {
"type": "string",
"enum": ["pending", "linked", "expired"],
"title": "Status"
},
"user_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Id"
}
},
"type": "object",
"required": ["status"],
"title": "LinkTokenStatusResponse"
},
"ListSessionsResponse": {
"properties": {
"sessions": {
@@ -11296,6 +11704,45 @@
"title": "PendingHumanReviewModel",
"description": "Response model for pending human review data.\n\nRepresents a human review request that is awaiting user action.\nContains all necessary information for a user to review and approve\nor reject data from a Human-in-the-Loop block execution.\n\nAttributes:\n id: Unique identifier for the review record\n user_id: ID of the user who must perform the review\n node_exec_id: ID of the node execution that created this review\n node_id: ID of the node definition (for grouping reviews from same node)\n graph_exec_id: ID of the graph execution containing the node\n graph_id: ID of the graph template being executed\n graph_version: Version number of the graph template\n payload: The actual data payload awaiting review\n instructions: Instructions or message for the reviewer\n editable: Whether the reviewer can edit the data\n status: Current review status (WAITING, APPROVED, or REJECTED)\n review_message: Optional message from the reviewer\n created_at: Timestamp when review was created\n updated_at: Timestamp when review was last modified\n reviewed_at: Timestamp when review was completed (if applicable)"
},
"Platform": {
"type": "string",
"enum": [
"DISCORD",
"TELEGRAM",
"SLACK",
"TEAMS",
"WHATSAPP",
"GITHUB",
"LINEAR"
],
"title": "Platform",
"description": "Supported platform types (mirrors Prisma PlatformType)."
},
"PlatformLinkInfo": {
"properties": {
"id": { "type": "string", "title": "Id" },
"platform": { "type": "string", "title": "Platform" },
"platform_user_id": { "type": "string", "title": "Platform User Id" },
"platform_username": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Platform Username"
},
"linked_at": {
"type": "string",
"format": "date-time",
"title": "Linked At"
}
},
"type": "object",
"required": [
"id",
"platform",
"platform_user_id",
"platform_username",
"linked_at"
],
"title": "PlatformLinkInfo"
},
"PostmarkBounceEnum": {
"type": "integer",
"enum": [
@@ -11818,6 +12265,33 @@
"required": ["credit_amount"],
"title": "RequestTopUp"
},
"ResolveRequest": {
"properties": {
"platform": { "$ref": "#/components/schemas/Platform" },
"platform_user_id": {
"type": "string",
"maxLength": 255,
"minLength": 1,
"title": "Platform User Id"
}
},
"type": "object",
"required": ["platform", "platform_user_id"],
"title": "ResolveRequest",
"description": "Resolve a platform identity to an AutoGPT user."
},
"ResolveResponse": {
"properties": {
"linked": { "type": "boolean", "title": "Linked" },
"user_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Id"
}
},
"type": "object",
"required": ["linked"],
"title": "ResolveResponse"
},
"ResponseType": {
"type": "string",
"enum": [