mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Compare commits
22 Commits
feat/keep-
...
feat/copil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96ca4398c5 | ||
|
|
8aeb782ccb | ||
|
|
3806f58e6b | ||
|
|
93345ff840 | ||
|
|
c1d6689215 | ||
|
|
652c12768e | ||
|
|
7a7912aed3 | ||
|
|
7ffcd704c9 | ||
|
|
9eaa903978 | ||
|
|
77ebcfe55d | ||
|
|
db029f0b49 | ||
|
|
a268291585 | ||
|
|
9caca6d899 | ||
|
|
481f704317 | ||
|
|
781224f8d5 | ||
|
|
8a91bd8a99 | ||
|
|
7bf10c605a | ||
|
|
7778de1d7b | ||
|
|
015a626379 | ||
|
|
be6501f10e | ||
|
|
32f6ef0a45 | ||
|
|
c7d5c1c844 |
@@ -0,0 +1 @@
|
||||
# Platform bot linking API
|
||||
@@ -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.")
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
24
autogpt_platform/copilot-bot/.env.example
Normal file
24
autogpt_platform/copilot-bot/.env.example
Normal 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.
|
||||
4
autogpt_platform/copilot-bot/.gitignore
vendored
Normal file
4
autogpt_platform/copilot-bot/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
73
autogpt_platform/copilot-bot/README.md
Normal file
73
autogpt_platform/copilot-bot/README.md
Normal 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
|
||||
27
autogpt_platform/copilot-bot/package.json
Normal file
27
autogpt_platform/copilot-bot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
31
autogpt_platform/copilot-bot/src/api/_bot.ts
Normal file
31
autogpt_platform/copilot-bot/src/api/_bot.ts
Normal 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;
|
||||
}
|
||||
39
autogpt_platform/copilot-bot/src/api/gateway/discord.ts
Normal file
39
autogpt_platform/copilot-bot/src/api/gateway/discord.ts
Normal 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
|
||||
);
|
||||
}
|
||||
17
autogpt_platform/copilot-bot/src/api/webhooks/discord.ts
Normal file
17
autogpt_platform/copilot-bot/src/api/webhooks/discord.ts
Normal 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);
|
||||
}
|
||||
17
autogpt_platform/copilot-bot/src/api/webhooks/slack.ts
Normal file
17
autogpt_platform/copilot-bot/src/api/webhooks/slack.ts
Normal 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);
|
||||
}
|
||||
17
autogpt_platform/copilot-bot/src/api/webhooks/telegram.ts
Normal file
17
autogpt_platform/copilot-bot/src/api/webhooks/telegram.ts
Normal 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);
|
||||
}
|
||||
232
autogpt_platform/copilot-bot/src/bot.ts
Normal file
232
autogpt_platform/copilot-bot/src/bot.ts
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
54
autogpt_platform/copilot-bot/src/config.ts
Normal file
54
autogpt_platform/copilot-bot/src/config.ts
Normal 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;
|
||||
}
|
||||
171
autogpt_platform/copilot-bot/src/index.ts
Normal file
171
autogpt_platform/copilot-bot/src/index.ts
Normal 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);
|
||||
});
|
||||
219
autogpt_platform/copilot-bot/src/platform-api.ts
Normal file
219
autogpt_platform/copilot-bot/src/platform-api.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
19
autogpt_platform/copilot-bot/tsconfig.json
Normal file
19
autogpt_platform/copilot-bot/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user