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)
This commit is contained in:
Bentlybro
2026-03-31 13:39:29 +00:00
parent 77ebcfe55d
commit 9eaa903978
14 changed files with 773 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
# ── AutoGPT Platform API ──────────────────────────────────────────────
AUTOGPT_API_URL=http://localhost:8006
# Service API key for bot-facing endpoints (TODO: implement in backend)
# AUTOGPT_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,33 @@
/**
* 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";
import type { Chat } from "chat";
let _bot: ReturnType<typeof createBot> | null = null;
export function getBotInstance() {
if (!_bot) {
const config = loadConfig();
// In serverless, always use in-memory state unless Redis is configured
let stateAdapter;
if (config.redisUrl) {
const { createRedisState } = require("@chat-adapter/state-redis");
stateAdapter = createRedisState({ url: config.redisUrl });
} else {
const { createMemoryState } = require("@chat-adapter/state-memory");
stateAdapter = createMemoryState();
}
_bot = createBot(config, stateAdapter);
console.log("[bot] Instance created (serverless)");
}
return _bot;
}

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 = 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 = 10 * 60 * 1000; // 10 minutes
return (discord as any).startGatewayListener(
{},
durationMs,
undefined,
webhookUrl
);
}

View File

@@ -0,0 +1,22 @@
/**
* Discord webhook endpoint.
*
* Receives HTTP Interactions from Discord (button clicks, slash commands,
* verification pings). For regular messages, the Gateway WebSocket is
* used via the adapter's built-in connection.
*
* Deploy as: POST /api/webhooks/discord
*/
import { getBotInstance } from "../_bot.js";
export async function POST(request: Request) {
const bot = 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 = 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 = 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,215 @@
/**
* 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 } from "chat";
import type { StateAdapter } 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;
}
export function createBot(config: Config, stateAdapter: StateAdapter) {
const api = new PlatformAPI(config.autogptApiUrl);
// Build adapters based on config
const adapters: Record<string, any> = {};
if (config.discord) {
// Dynamic import to avoid loading unused adapters
const { createDiscordAdapter } = require("@chat-adapter/discord");
adapters.discord = createDiscordAdapter();
}
if (config.telegram) {
const { createTelegramAdapter } = require("@chat-adapter/telegram");
adapters.telegram = createTelegramAdapter();
}
if (config.slack) {
const { createSlackAdapter } = require("@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.
*/
function getAdapterName(thread: any): string {
// Thread ID format is "adapter:channel:thread"
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: any,
message: any,
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: any) {
if (err.message?.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: any,
text: string,
userId: string,
api: PlatformAPI
) {
const state = await thread.state;
let sessionId = state?.sessionId;
// TODO: For now, we need a way to get a user token to call the chat API.
// This will require either:
// 1. A service-to-service token exchange endpoint
// 2. Storing user tokens during the linking flow
// 3. A bot-specific chat endpoint that accepts user_id directly
//
// For the MVP, we'll echo back to prove the pipeline works.
// The CoPilot integration comes in the next iteration.
console.log(
`[bot] Message from user ${userId.slice(-8)}: ${text.slice(0, 100)}`
);
await thread.startTyping();
// MVP: Echo back with user info to prove linking works
await thread.post(
`✅ **Connected as AutoGPT user** \`${userId.slice(-8)}\`\n\n` +
`> ${text}\n\n` +
`_CoPilot integration coming soon. ` +
`Session: ${sessionId ?? "new"}_`
);
}

View File

@@ -0,0 +1,28 @@
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 {
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;
}

View File

@@ -0,0 +1,58 @@
/**
* CoPilot Bot — Entry point.
*
* Loads config, creates adapters, starts the bot.
* For serverless deployment (Vercel), see src/api/ routes.
* This file is for standalone / long-running deployment.
*/
import { loadConfig } from "./config.js";
import { createBot } from "./bot.js";
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"}\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 and start the bot
const bot = createBot(config, stateAdapter);
console.log("✅ CoPilot Bot ready.\n");
// Keep the process alive
process.on("SIGINT", () => {
console.log("\n🛑 Shutting down...");
process.exit(0);
});
process.on("SIGTERM", () => {
console.log("\n🛑 Shutting down...");
process.exit(0);
});
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,197 @@
/**
* 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" },
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" },
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`
);
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 user.
* Returns the session ID.
*/
async createChatSession(userToken: string): Promise<string> {
const res = await fetch(`${this.baseUrl}/api/chat/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${userToken}`,
},
});
if (!res.ok) {
throw new Error(
`Create chat session failed: ${res.status} ${await res.text()}`
);
}
const data = await res.json();
return data.id;
}
/**
* Stream a chat message to CoPilot and yield text chunks.
* Uses SSE (Server-Sent Events) to stream the response.
*/
async *streamChat(
sessionId: string,
message: string,
userToken: string
): AsyncGenerator<string> {
const res = await fetch(
`${this.baseUrl}/api/chat/sessions/${sessionId}/stream`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${userToken}`,
Accept: "text/event-stream",
},
body: JSON.stringify({
message,
is_user_message: true,
}),
}
);
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");
// Keep the last potentially incomplete line in the buffer
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);
// Extract text content from SSE events
if (parsed.type === "text" && parsed.content) {
yield parsed.content;
} else if (typeof parsed === "string") {
yield parsed;
}
} catch {
// Non-JSON data line, yield as-is if it has content
if (data && data !== "[DONE]") {
yield data;
}
}
}
}
}
}
}

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"]
}