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)
This commit is contained in:
Bentlybro
2026-03-31 12:10:15 +00:00
parent 17e78ca382
commit c7d5c1c844
6 changed files with 522 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,391 @@
"""
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 secrets
from datetime import datetime, timedelta, timezone
from typing import Annotated
from autogpt_libs import auth
from fastapi import APIRouter, Depends, HTTPException, Security
from pydantic import BaseModel, Field
import backend.data.db
logger = logging.getLogger(__name__)
router = APIRouter()
LINK_TOKEN_EXPIRY_MINUTES = 30
# ── Request / Response Models ──────────────────────────────────────────
class CreateLinkTokenRequest(BaseModel):
"""Request from the bot service to create a linking token."""
platform: str = Field(
description="Platform name: DISCORD, TELEGRAM, SLACK, TEAMS, WHATSAPP, GITHUB, LINEAR"
)
platform_user_id: str = Field(description="The user's ID on the platform")
platform_username: str | None = Field(
default=None, description="Display name (best effort)"
)
channel_id: str | None = Field(
default=None, description="Channel ID for sending confirmation back"
)
class LinkTokenResponse(BaseModel):
token: str
expires_at: datetime
link_url: str
class LinkTokenStatusResponse(BaseModel):
status: str # "pending", "linked", "expired"
user_id: str | None = None
class ResolveRequest(BaseModel):
"""Resolve a platform identity to an AutoGPT user."""
platform: str
platform_user_id: str
class ResolveResponse(BaseModel):
linked: bool
user_id: str | None = None
platform_username: 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
# ── 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,
) -> 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.
TODO: Add API key auth for bot service (for now, open for development).
"""
platform = request.platform.upper()
_validate_platform(platform)
prisma = backend.data.db.get_prisma()
# Check if already linked
existing = await prisma.platformlink.find_unique(
where={
"platform_platformUserId": {
"platform": platform,
"platformUserId": request.platform_user_id,
}
}
)
if existing:
raise HTTPException(
status_code=409,
detail=f"Platform user {request.platform_user_id} on {platform} "
f"is already linked to an account.",
)
# Generate token
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(
minutes=LINK_TOKEN_EXPIRY_MINUTES
)
await prisma.platformlinktoken.create(
data={
"token": token,
"platform": platform,
"platformUserId": request.platform_user_id,
"platformUsername": request.platform_username,
"channelId": request.channel_id,
"expiresAt": expires_at,
}
)
logger.info(
f"Created link token for {platform}:{request.platform_user_id} "
f"(expires {expires_at.isoformat()})"
)
# TODO: Make base URL configurable
link_url = f"https://platform.agpt.co/link/{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: str) -> LinkTokenStatusResponse:
"""
Called by the bot service to check if a user has completed linking.
"""
prisma = backend.data.db.get_prisma()
link_token = await prisma.platformlinktoken.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 prisma.platformlink.find_unique(
where={
"platform_platformUserId": {
"platform": link_token.platform,
"platformUserId": link_token.platformUserId,
}
}
)
return LinkTokenStatusResponse(
status="linked",
user_id=link.userId if link else None,
)
if link_token.expiresAt < 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,
) -> ResolveResponse:
"""
Called by the bot service on every incoming message to check if
the platform user has a linked AutoGPT account.
"""
platform = request.platform.upper()
_validate_platform(platform)
prisma = backend.data.db.get_prisma()
link = await prisma.platformlink.find_unique(
where={
"platform_platformUserId": {
"platform": platform,
"platformUserId": request.platform_user_id,
}
}
)
if not link:
return ResolveResponse(linked=False)
return ResolveResponse(
linked=True,
user_id=link.userId,
platform_username=link.platformUsername,
)
# ── 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: str,
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.
"""
prisma = backend.data.db.get_prisma()
link_token = await prisma.platformlinktoken.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="Token already used")
if link_token.expiresAt < datetime.now(timezone.utc):
raise HTTPException(status_code=410, detail="Token expired")
# Check if this platform identity is already linked to someone else
existing = await prisma.platformlink.find_unique(
where={
"platform_platformUserId": {
"platform": link_token.platform,
"platformUserId": link_token.platformUserId,
}
}
)
if existing:
if existing.userId == user_id:
raise HTTPException(
status_code=409,
detail="This platform account is already linked to your account.",
)
raise HTTPException(
status_code=409,
detail="This platform account is already linked to another user.",
)
# Create the link
await prisma.platformlink.create(
data={
"userId": user_id,
"platform": link_token.platform,
"platformUserId": link_token.platformUserId,
"platformUsername": link_token.platformUsername,
}
)
# Mark token as used
await prisma.platformlinktoken.update(
where={"token": token},
data={"usedAt": datetime.now(timezone.utc)},
)
logger.info(
f"Linked {link_token.platform}:{link_token.platformUserId} "
f"to user {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.
"""
prisma = backend.data.db.get_prisma()
links = await prisma.platformlink.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}",
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)],
) -> dict:
"""
Removes a platform link. The user will need to re-link if they
want to use the bot on that platform again.
"""
prisma = backend.data.db.get_prisma()
link = await prisma.platformlink.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 prisma.platformlink.delete(where={"id": link_id})
logger.info(
f"Unlinked {link.platform}:{link.platformUserId} from user {user_id[-8:]}"
)
return {"success": True}
# ── Helpers ────────────────────────────────────────────────────────────
VALID_PLATFORMS = {"DISCORD", "TELEGRAM", "SLACK", "TEAMS", "WHATSAPP", "GITHUB", "LINEAR"}
def _validate_platform(platform: str) -> None:
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=400,
detail=f"Invalid platform '{platform}'. Must be one of: {', '.join(sorted(VALID_PLATFORMS))}",
)

View File

@@ -0,0 +1,26 @@
"""Tests for platform bot linking API routes."""
import pytest
from datetime import datetime, timezone
from backend.api.features.platform_linking.routes import (
VALID_PLATFORMS,
_validate_platform,
)
from fastapi import HTTPException
class TestValidatePlatform:
def test_valid_platforms(self):
for platform in VALID_PLATFORMS:
# Should not raise
_validate_platform(platform)
def test_invalid_platform(self):
with pytest.raises(HTTPException) as exc_info:
_validate_platform("INVALID")
assert exc_info.value.status_code == 400
def test_lowercase_rejected(self):
with pytest.raises(HTTPException):
_validate_platform("discord")

View File

@@ -29,6 +29,7 @@ import backend.api.features.library.model
import backend.api.features.library.routes
import backend.api.features.mcp.routes as mcp_routes
import backend.api.features.oauth
import backend.api.features.platform_linking.routes
import backend.api.features.otto.routes
import backend.api.features.postmark.postmark
import backend.api.features.store.model
@@ -361,6 +362,11 @@ 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.mount("/external-api", external_api)

View File

@@ -0,0 +1,47 @@
-- 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_token_idx" 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,51 @@ 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([token])
@@index([expiresAt])
}