Compare commits

...

22 Commits

Author SHA1 Message Date
Bentlybro
96ca4398c5 fix(platform): SSE type uses hyphen not underscore (text-delta not text_delta) 2026-04-02 15:54:30 +00:00
Bentlybro
8aeb782ccb fix(platform): fix SSE parser and prevent empty Discord messages
- Fix streamChat SSE parser: backend sends type=text_delta with delta
  field, not type=text with content field
- Collect full response before posting to Discord — empty async
  generators caused 'Cannot send empty message' spam that hit rate limits
- Show fallback message if CoPilot returns no text content
2026-04-02 15:54:30 +00:00
Bentlybro
3806f58e6b fix(platform): address review feedback on copilot-bot service
- Fix .env.example: AUTOGPT_BOT_API_KEY → PLATFORM_BOT_API_KEY
- Replace all any types with proper Chat SDK types (Thread, Message, Adapter)
- Fix gateway duration: 10min → 9min to match cron interval
- Add startup validation for critical env vars in production
- Fix error handling: use unknown catch type with instanceof checks
2026-04-02 15:54:29 +00:00
Bentlybro
93345ff840 fix: add bot API key header to ALL bot-facing requests + fix dotenv loading
Two bugs:
1. resolve(), createLinkToken(), and getLinkTokenStatus() were missing
   the X-Bot-API-Key header — only createChatSession and streamChat
   had it. All bot-facing requests now include this.botHeaders().

2. dotenv was imported in config.ts but Chat SDK adapters read env vars
   at import time (before config.ts runs). Moved dotenv.config() to
   the very top of index.ts so .env is loaded before anything else.
2026-04-02 15:54:29 +00:00
Bentlybro
c1d6689215 fix: provide waitUntil to Gateway listener so it stays alive
The startGatewayListener returns immediately without waitUntil because
it's designed for serverless. In standalone mode we need to provide
a waitUntil function that tracks background tasks and wait for them.
2026-04-02 15:54:29 +00:00
Bentlybro
652c12768e fix: standalone mode — start HTTP server + auto-connect Discord Gateway
The bot now:
1. Starts an HTTP server on PORT (default 3001) for webhook callbacks
2. Auto-connects to Discord Gateway WebSocket
3. Gateway forwards messages to the local webhook server
4. Auto-restarts Gateway on disconnect (10 min cycle)

Previously: bot started, said 'ready', did nothing.
2026-04-02 15:54:29 +00:00
Bentlybro
7a7912aed3 fix: replace require() with dynamic import() for ESM compatibility
The project is type=module (ESM), so require() is not available.
Switched all dynamic adapter/state imports to await import().
Made createBot and getBotInstance async accordingly.
2026-04-02 15:54:29 +00:00
Bentlybro
7ffcd704c9 feat: Wire bot to real CoPilot streaming via bot chat proxy
Replaces the echo handler with actual CoPilot integration:

- platform-api.ts: now calls /api/platform-linking/chat/session and
  /api/platform-linking/chat/stream (bot API key auth, no user JWT)
- bot.ts: creates sessions on first message, streams CoPilot responses
  directly to the chat platform via thread.post(stream)
- Adds PLATFORM_BOT_API_KEY support in bot headers

The full flow now works:
  User messages bot → resolve user → create session → stream CoPilot → post response
2026-04-02 15:54:29 +00:00
Bentlybro
9eaa903978 feat: CoPilot bot service using Vercel Chat SDK
Multi-platform bot service that deploys CoPilot to Discord, Telegram,
and Slack from a single codebase using Vercel's Chat SDK.

## What's included

### Core bot (src/bot.ts)
- Chat SDK instance with dynamic adapter loading
- onNewMention: resolves platform user → AutoGPT account
- Unlinked users get a link prompt via the platform-linking API
- Subscribed message handler with state management
- MVP echo response (CoPilot API integration next)

### Platform API client (src/platform-api.ts)
- Calls /api/platform-linking/resolve on every message
- Creates link tokens for unlinked users
- Checks link token status
- Chat session creation and SSE streaming (prepared for CoPilot)

### Serverless routes (src/api/)
- POST /api/webhooks/discord — Discord interactions endpoint
- POST /api/webhooks/telegram — Telegram updates
- POST /api/webhooks/slack — Slack events
- GET /api/gateway/discord — Gateway cron for Discord messages

### Standalone mode (src/index.ts)
- Long-running process for Docker/PM2 deployment
- Auto-detects enabled adapters from env vars
- Redis or in-memory state

## Stacked on
- feat/platform-bot-linking (PR #12615)
2026-04-02 15:54:29 +00:00
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
24 changed files with 2315 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

@@ -0,0 +1,24 @@
# ── AutoGPT Platform API ──────────────────────────────────────────────
AUTOGPT_API_URL=http://localhost:8006
# Service API key for bot-facing endpoints (must match backend PLATFORM_BOT_API_KEY)
# PLATFORM_BOT_API_KEY=
# ── Discord ───────────────────────────────────────────────────────────
DISCORD_BOT_TOKEN=
DISCORD_PUBLIC_KEY=
DISCORD_APPLICATION_ID=
# Optional: comma-separated role IDs that trigger mentions
# DISCORD_MENTION_ROLE_IDS=
# ── Telegram ──────────────────────────────────────────────────────────
# TELEGRAM_BOT_TOKEN=
# ── Slack ─────────────────────────────────────────────────────────────
# SLACK_BOT_TOKEN=
# SLACK_SIGNING_SECRET=
# SLACK_APP_TOKEN=
# ── State ─────────────────────────────────────────────────────────────
# For production, use Redis:
# REDIS_URL=redis://localhost:6379
# For development, in-memory state is used by default.

View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log

View File

@@ -0,0 +1,73 @@
# CoPilot Bot
Multi-platform bot service for AutoGPT CoPilot, built with [Vercel Chat SDK](https://chat-sdk.dev).
Deploys CoPilot to Discord, Telegram, Slack, and more from a single codebase.
## How it works
1. User messages the bot on any platform (Discord, Telegram, Slack)
2. Bot checks if the platform user is linked to an AutoGPT account
3. If not linked → sends a one-time link URL
4. User clicks → logs in to AutoGPT → accounts are linked
5. Future messages are forwarded to CoPilot and responses streamed back
## Setup
```bash
# Install dependencies
npm install
# Copy env template
cp .env.example .env
# Configure at least one platform adapter (e.g. Discord)
# Edit .env with your bot tokens
# Run in development
npm run dev
```
## Architecture
```
src/
├── index.ts # Standalone entry point
├── config.ts # Environment-based configuration
├── bot.ts # Core bot logic (Chat SDK handlers)
├── platform-api.ts # AutoGPT platform API client
└── api/ # Serverless API routes (Vercel)
├── _bot.ts # Singleton bot instance
├── webhooks/ # Platform webhook endpoints
│ ├── discord.ts
│ ├── telegram.ts
│ └── slack.ts
└── gateway/
└── discord.ts # Gateway cron for Discord messages
```
## Deployment
### Standalone (Docker/PM2)
```bash
npm run build
npm start
```
### Serverless (Vercel)
Deploy to Vercel. Webhook URLs:
- Discord: `https://your-app.vercel.app/api/webhooks/discord`
- Telegram: `https://your-app.vercel.app/api/webhooks/telegram`
- Slack: `https://your-app.vercel.app/api/webhooks/slack`
For Discord messages (Gateway), add a cron job in `vercel.json`:
```json
{
"crons": [{ "path": "/api/gateway/discord", "schedule": "*/9 * * * *" }]
}
```
## Dependencies
- [Chat SDK](https://chat-sdk.dev) — Cross-platform bot abstraction
- AutoGPT Platform API — Account linking + CoPilot chat

View File

@@ -0,0 +1,27 @@
{
"name": "@autogpt/copilot-bot",
"version": "0.1.0",
"description": "Multi-platform CoPilot bot service using Vercel Chat SDK",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc",
"lint": "tsc --noEmit"
},
"dependencies": {
"chat": "^4.23.0",
"@chat-adapter/discord": "^4.23.0",
"@chat-adapter/telegram": "^4.23.0",
"@chat-adapter/slack": "^4.23.0",
"@chat-adapter/state-memory": "^4.23.0",
"@chat-adapter/state-redis": "^4.23.0",
"dotenv": "^16.4.0",
"eventsource-parser": "^3.0.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,31 @@
/**
* Singleton bot instance for serverless environments.
*
* In serverless (Vercel), each request may hit a cold or warm instance.
* We create the bot once per instance and reuse it across requests.
*/
import { loadConfig } from "../config.js";
import { createBot } from "../bot.js";
let _botPromise: ReturnType<typeof createBot> | null = null;
export async function getBotInstance() {
if (!_botPromise) {
const config = loadConfig();
let stateAdapter;
if (config.redisUrl) {
const { createRedisState } = await import("@chat-adapter/state-redis");
stateAdapter = createRedisState({ url: config.redisUrl });
} else {
const { createMemoryState } = await import("@chat-adapter/state-memory");
stateAdapter = createMemoryState();
}
_botPromise = createBot(config, stateAdapter);
console.log("[bot] Instance created (serverless)");
}
return _botPromise;
}

View File

@@ -0,0 +1,39 @@
/**
* Discord Gateway cron endpoint.
*
* In serverless environments, Discord's Gateway WebSocket needs a
* persistent connection to receive messages. This endpoint is called
* by a cron job every 9 minutes to maintain the connection.
*/
import { getBotInstance } from "../_bot.js";
export async function GET(request: Request) {
// Verify cron secret in production
const authHeader = request.headers.get("authorization");
if (
process.env.CRON_SECRET &&
authHeader !== `Bearer ${process.env.CRON_SECRET}`
) {
return new Response("Unauthorized", { status: 401 });
}
const bot = await getBotInstance();
await bot.initialize();
const discord = bot.getAdapter("discord");
if (!discord) {
return new Response("Discord adapter not configured", { status: 404 });
}
const baseUrl = process.env.WEBHOOK_BASE_URL ?? "http://localhost:3000";
const webhookUrl = `${baseUrl}/api/webhooks/discord`;
const durationMs = 9 * 60 * 1000; // 9 minutes (matches cron interval)
return (discord as any).startGatewayListener(
{},
durationMs,
undefined,
webhookUrl
);
}

View File

@@ -0,0 +1,17 @@
/**
* Discord webhook endpoint.
* Deploy as: POST /api/webhooks/discord
*/
import { getBotInstance } from "../_bot.js";
export async function POST(request: Request) {
const bot = await getBotInstance();
const handler = bot.webhooks.discord;
if (!handler) {
return new Response("Discord adapter not configured", { status: 404 });
}
return handler(request);
}

View File

@@ -0,0 +1,17 @@
/**
* Slack webhook endpoint.
* Deploy as: POST /api/webhooks/slack
*/
import { getBotInstance } from "../_bot.js";
export async function POST(request: Request) {
const bot = await getBotInstance();
const handler = bot.webhooks.slack;
if (!handler) {
return new Response("Slack adapter not configured", { status: 404 });
}
return handler(request);
}

View File

@@ -0,0 +1,17 @@
/**
* Telegram webhook endpoint.
* Deploy as: POST /api/webhooks/telegram
*/
import { getBotInstance } from "../_bot.js";
export async function POST(request: Request) {
const bot = await getBotInstance();
const handler = bot.webhooks.telegram;
if (!handler) {
return new Response("Telegram adapter not configured", { status: 404 });
}
return handler(request);
}

View File

@@ -0,0 +1,232 @@
/**
* CoPilot Bot — Multi-platform bot using Vercel Chat SDK.
*
* Handles:
* - Account linking (prompts unlinked users to link)
* - Message routing to CoPilot API
* - Streaming responses back to the user
*/
import { Chat, Message } from "chat";
import type { Adapter, StateAdapter, Thread } from "chat";
import { PlatformAPI } from "./platform-api.js";
import type { Config } from "./config.js";
// Thread state persisted across messages
export interface BotThreadState {
/** Linked AutoGPT user ID */
userId?: string;
/** CoPilot chat session ID for this thread */
sessionId?: string;
/** Pending link token (if user hasn't linked yet) */
pendingLinkToken?: string;
}
type BotThread = Thread<BotThreadState>;
export async function createBot(config: Config, stateAdapter: StateAdapter) {
const api = new PlatformAPI(config.autogptApiUrl);
// Build adapters based on config
const adapters: Record<string, Adapter> = {};
if (config.discord) {
const { createDiscordAdapter } = await import("@chat-adapter/discord");
adapters.discord = createDiscordAdapter();
}
if (config.telegram) {
const { createTelegramAdapter } = await import("@chat-adapter/telegram");
adapters.telegram = createTelegramAdapter();
}
if (config.slack) {
const { createSlackAdapter } = await import("@chat-adapter/slack");
adapters.slack = createSlackAdapter();
}
if (Object.keys(adapters).length === 0) {
throw new Error(
"No adapters enabled. Set at least one of: " +
"DISCORD_BOT_TOKEN, TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN"
);
}
const bot = new Chat<typeof adapters, BotThreadState>({
userName: "copilot",
adapters,
state: stateAdapter,
streamingUpdateIntervalMs: 500,
fallbackStreamingPlaceholderText: "Thinking...",
});
// ── New mention (first message in a thread) ──────────────────────
bot.onNewMention(async (thread, message) => {
const adapterName = getAdapterName(thread);
const platformUserId = message.author.userId;
console.log(
`[bot] New mention from ${adapterName}:${platformUserId} in ${thread.id}`
);
// Check if user is linked
const resolved = await api.resolve(adapterName, platformUserId);
if (!resolved.linked) {
await handleUnlinkedUser(thread, message, adapterName, api);
return;
}
// User is linked — subscribe and handle the message
await thread.subscribe();
await thread.setState({ userId: resolved.user_id });
await handleCoPilotMessage(thread, message.text, resolved.user_id!, api);
});
// ── Subscribed messages (follow-ups in a thread) ─────────────────
bot.onSubscribedMessage(async (thread, message) => {
const state = await thread.state;
if (!state?.userId) {
// Somehow lost state — re-resolve
const adapterName = getAdapterName(thread);
const resolved = await api.resolve(adapterName, message.author.userId);
if (!resolved.linked) {
await handleUnlinkedUser(thread, message, adapterName, api);
return;
}
await thread.setState({ userId: resolved.user_id });
await handleCoPilotMessage(
thread,
message.text,
resolved.user_id!,
api
);
return;
}
await handleCoPilotMessage(thread, message.text, state.userId, api);
});
return bot;
}
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Get the adapter/platform name from a thread.
* Thread ID format is "adapter:channel:thread".
*/
function getAdapterName(thread: BotThread): string {
const parts = thread.id.split(":");
return parts[0] ?? "unknown";
}
/**
* Handle an unlinked user — create a link token and send them a prompt.
*/
async function handleUnlinkedUser(
thread: BotThread,
message: Message,
platform: string,
api: PlatformAPI
) {
console.log(
`[bot] Unlinked user ${platform}:${message.author.userId}, sending link prompt`
);
try {
const linkResult = await api.createLinkToken({
platform,
platformUserId: message.author.userId,
platformUsername: message.author.fullName ?? message.author.userName,
});
await thread.post(
`👋 To use CoPilot, link your AutoGPT account first.\n\n` +
`🔗 **Link your account:** ${linkResult.link_url}\n\n` +
`_This link expires in 30 minutes._`
);
// Store the pending token so we could poll later if needed
await thread.setState({ pendingLinkToken: linkResult.token });
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("409")) {
// Already linked (race condition) — retry resolve
const resolved = await api.resolve(platform, message.author.userId);
if (resolved.linked) {
await thread.subscribe();
await thread.setState({ userId: resolved.user_id });
await handleCoPilotMessage(
thread,
message.text,
resolved.user_id!,
api
);
return;
}
}
console.error("[bot] Failed to create link token:", err);
await thread.post(
"Sorry, I couldn't set up account linking right now. Please try again later."
);
}
}
/**
* Forward a message to CoPilot and stream the response back.
*/
async function handleCoPilotMessage(
thread: BotThread,
text: string,
userId: string,
api: PlatformAPI
) {
const state = await thread.state;
let sessionId = state?.sessionId;
console.log(
`[bot] Message from user ${userId.slice(-8)}: ${text.slice(0, 100)}`
);
await thread.startTyping();
try {
// Create a session if we don't have one
if (!sessionId) {
sessionId = await api.createChatSession(userId);
await thread.setState({ ...state, sessionId });
console.log(`[bot] Created session ${sessionId} for user ${userId.slice(-8)}`);
}
// Stream CoPilot response — collect chunks, then post.
// We collect first because thread.post() with an empty stream
// causes Discord "Cannot send an empty message" errors.
const stream = api.streamChat(userId, text, sessionId);
let response = "";
for await (const chunk of stream) {
response += chunk;
}
if (response.trim()) {
await thread.post(response);
} else {
await thread.post(
"I processed your message but didn't generate a response. Please try again."
);
}
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error(`[bot] CoPilot error for user ${userId.slice(-8)}:`, errMsg);
await thread.post(
"Sorry, I ran into an issue processing your message. Please try again."
);
}
}

View File

@@ -0,0 +1,54 @@
import "dotenv/config";
export interface Config {
/** AutoGPT platform API base URL */
autogptApiUrl: string;
/** Whether each adapter is enabled (based on env vars being set) */
discord: boolean;
telegram: boolean;
slack: boolean;
/** Use Redis for state (production) or in-memory (dev) */
redisUrl?: string;
}
export function loadConfig(): Config {
const isProduction = process.env.NODE_ENV === "production";
if (isProduction) {
requireEnv("PLATFORM_BOT_API_KEY");
const hasAdapter =
!!process.env.DISCORD_BOT_TOKEN ||
!!process.env.TELEGRAM_BOT_TOKEN ||
!!process.env.SLACK_BOT_TOKEN;
if (!hasAdapter) {
throw new Error(
"Production requires at least one adapter token: " +
"DISCORD_BOT_TOKEN, TELEGRAM_BOT_TOKEN, or SLACK_BOT_TOKEN"
);
}
}
return {
autogptApiUrl: env("AUTOGPT_API_URL", "http://localhost:8006"),
discord: !!process.env.DISCORD_BOT_TOKEN,
telegram: !!process.env.TELEGRAM_BOT_TOKEN,
slack: !!process.env.SLACK_BOT_TOKEN,
redisUrl: process.env.REDIS_URL,
};
}
function env(key: string, fallback: string): string {
return process.env[key] ?? fallback;
}
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}

View File

@@ -0,0 +1,171 @@
/**
* CoPilot Bot — Entry point (standalone / long-running).
*
* Starts an HTTP server for webhook handling and connects to
* the Discord Gateway for receiving messages.
*/
// Load .env BEFORE any other imports so env vars are available
// when Chat SDK adapters auto-detect credentials at import time.
import { config } from "dotenv";
config();
import { loadConfig } from "./config.js";
import { createBot } from "./bot.js";
const PORT = parseInt(process.env.PORT ?? "3001", 10);
async function main() {
console.log("🤖 CoPilot Bot starting...\n");
const config = loadConfig();
// Log which adapters are enabled
const enabled = [
config.discord && "Discord",
config.telegram && "Telegram",
config.slack && "Slack",
].filter(Boolean);
console.log(`📡 Adapters: ${enabled.join(", ") || "none"}`);
console.log(`🔗 API: ${config.autogptApiUrl}`);
console.log(`💾 State: ${config.redisUrl ? "Redis" : "In-memory"}`);
console.log(`🌐 Port: ${PORT}\n`);
// Create state adapter
let stateAdapter;
if (config.redisUrl) {
const { createRedisState } = await import("@chat-adapter/state-redis");
stateAdapter = createRedisState({ url: config.redisUrl });
} else {
const { createMemoryState } = await import("@chat-adapter/state-memory");
stateAdapter = createMemoryState();
}
// Create the bot
const bot = await createBot(config, stateAdapter);
// Start HTTP server for webhooks
await startNodeServer(bot, PORT);
// Start Discord Gateway if enabled
if (config.discord) {
await bot.initialize();
const discord = bot.getAdapter("discord") as any;
if (discord?.startGatewayListener) {
const webhookUrl = `http://localhost:${PORT}/api/webhooks/discord`;
console.log(`🔌 Starting Discord Gateway → ${webhookUrl}`);
// Run gateway in background, restart on disconnect
const runGateway = async () => {
while (true) {
try {
// Track background tasks so the listener stays alive
const pendingTasks: Promise<unknown>[] = [];
const waitUntil = (task: Promise<unknown>) => {
pendingTasks.push(task);
};
const response = await discord.startGatewayListener(
{ waitUntil },
10 * 60 * 1000, // 10 minutes
undefined,
webhookUrl
);
// Wait for any background tasks to complete
if (pendingTasks.length > 0) {
await Promise.allSettled(pendingTasks);
}
console.log("[gateway] Listener ended, restarting...");
} catch (err) {
console.error("[gateway] Error, restarting in 5s:", err);
await new Promise((r) => setTimeout(r, 5000));
}
}
};
// Don't await — run in background
runGateway();
}
}
console.log("\n✅ CoPilot Bot ready.\n");
// Graceful shutdown
process.on("SIGINT", () => {
console.log("\n🛑 Shutting down...");
process.exit(0);
});
process.on("SIGTERM", () => {
console.log("\n🛑 Shutting down...");
process.exit(0);
});
}
/**
* Start a simple HTTP server using Node's built-in http module.
* Routes webhook requests to the Chat SDK bot.
*/
async function startNodeServer(bot: any, port: number) {
const { createServer } = await import("http");
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
// Collect body
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
const body = Buffer.concat(chunks);
// Build a standard Request object for Chat SDK
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value) headers.set(key, Array.isArray(value) ? value[0] : value);
}
const request = new Request(url.toString(), {
method: req.method ?? "POST",
headers,
body: req.method !== "GET" && req.method !== "HEAD" ? body : undefined,
});
// Route to the correct adapter webhook
// URL: /api/webhooks/{platform}
const parts = url.pathname.split("/");
const platform = parts[parts.length - 1];
const handler = platform ? (bot.webhooks as any)[platform] : undefined;
if (!handler) {
res.writeHead(404);
res.end("Not found");
return;
}
try {
const response: Response = await handler(request);
res.writeHead(response.status, Object.fromEntries(response.headers));
const responseBody = await response.arrayBuffer();
res.end(Buffer.from(responseBody));
} catch (err) {
console.error(`[http] Error handling ${url.pathname}:`, err);
res.writeHead(500);
res.end("Internal error");
}
});
server.listen(port, () => {
console.log(`🌐 HTTP server listening on http://localhost:${port}`);
});
return server;
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,219 @@
/**
* Client for the AutoGPT Platform Linking & Chat APIs.
*
* Handles:
* - Resolving platform users → AutoGPT accounts
* - Creating link tokens for unlinked users
* - Checking link token status
* - Creating chat sessions and streaming messages
*/
export interface ResolveResult {
linked: boolean;
user_id?: string;
platform_username?: string;
}
export interface LinkTokenResult {
token: string;
expires_at: string;
link_url: string;
}
export interface LinkTokenStatus {
status: "pending" | "linked" | "expired";
user_id?: string;
}
export class PlatformAPI {
constructor(private baseUrl: string) {}
/**
* Check if a platform user is linked to an AutoGPT account.
*/
async resolve(
platform: string,
platformUserId: string
): Promise<ResolveResult> {
const res = await fetch(`${this.baseUrl}/api/platform-linking/resolve`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...this.botHeaders(),
},
body: JSON.stringify({
platform: platform.toUpperCase(),
platform_user_id: platformUserId,
}),
});
if (!res.ok) {
throw new Error(
`Platform resolve failed: ${res.status} ${await res.text()}`
);
}
return res.json();
}
/**
* Create a link token for an unlinked platform user.
*/
async createLinkToken(params: {
platform: string;
platformUserId: string;
platformUsername?: string;
channelId?: string;
}): Promise<LinkTokenResult> {
const res = await fetch(`${this.baseUrl}/api/platform-linking/tokens`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...this.botHeaders(),
},
body: JSON.stringify({
platform: params.platform.toUpperCase(),
platform_user_id: params.platformUserId,
platform_username: params.platformUsername,
channel_id: params.channelId,
}),
});
if (!res.ok) {
throw new Error(
`Create link token failed: ${res.status} ${await res.text()}`
);
}
return res.json();
}
/**
* Check if a link token has been consumed.
*/
async getLinkTokenStatus(token: string): Promise<LinkTokenStatus> {
const res = await fetch(
`${this.baseUrl}/api/platform-linking/tokens/${token}/status`,
{ headers: this.botHeaders() }
);
if (!res.ok) {
throw new Error(
`Link token status failed: ${res.status} ${await res.text()}`
);
}
return res.json();
}
/**
* Create a new CoPilot chat session for a linked user.
* Uses the bot chat proxy (no user JWT needed).
*/
async createChatSession(userId: string): Promise<string> {
const res = await fetch(
`${this.baseUrl}/api/platform-linking/chat/session`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...this.botHeaders(),
},
body: JSON.stringify({
user_id: userId,
message: "session_init",
}),
}
);
if (!res.ok) {
throw new Error(
`Create chat session failed: ${res.status} ${await res.text()}`
);
}
const data = await res.json();
return data.session_id;
}
/**
* Stream a chat message to CoPilot on behalf of a linked user.
* Uses the bot chat proxy — authenticated via bot API key.
* Yields text chunks from the SSE stream.
*/
async *streamChat(
userId: string,
message: string,
sessionId?: string
): AsyncGenerator<string> {
const res = await fetch(
`${this.baseUrl}/api/platform-linking/chat/stream`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
...this.botHeaders(),
},
body: JSON.stringify({
user_id: userId,
message,
session_id: sessionId,
}),
}
);
if (!res.ok) {
throw new Error(
`Stream chat failed: ${res.status} ${await res.text()}`
);
}
if (!res.body) {
throw new Error("No response body for SSE stream");
}
// Parse SSE stream
const decoder = new TextDecoder();
const reader = res.body.getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6).trim();
if (data === "[DONE]") return;
try {
const parsed = JSON.parse(data);
// Backend sends: text-delta (streaming chunks), text-start/text-end,
// start/finish (lifecycle), start-step/finish-step, error, etc.
if (parsed.type === "text-delta" && parsed.delta) {
yield parsed.delta;
} else if (parsed.type === "error" && parsed.content) {
yield `Error: ${parsed.content}`;
}
// Ignore start/finish/step lifecycle events — they carry no text
} catch {
// Non-JSON data line — skip
}
}
}
}
}
private botHeaders(): Record<string, string> {
const key = process.env.PLATFORM_BOT_API_KEY;
if (key) {
return { "X-Bot-API-Key": key };
}
return {};
}
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"jsxImportSource": "chat"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

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": [