Files
vercel__chat/PLAN.md
T
Malte Ubl 42c705b245 rename
2025-12-22 18:21:52 -08:00

14 KiB

Implementation Plan

Package Structure

Monorepo with separate publishable packages:

packages/
  chat-sdk/              # Core library
  adapter-slack/         # @chat-sdk/slack
  adapter-teams/         # @chat-sdk/teams
  adapter-gchat/         # @chat-sdk/gchat
  adapter-discord/       # @chat-sdk/discord (lower priority)
  state-redis/           # @chat-sdk/state-redis
  state-memory/          # @chat-sdk/state-memory (for dev/testing)

Tooling: pnpm workspaces, tsup for builds, vitest for testing.


Phase 1: Core Abstractions

1.1 Define Core Interfaces

// packages/chat-sdk/src/types.ts

interface ChatConfig {
  userName: string;
  adapters: Record<string, Adapter>;
  state: StateAdapter;
}

interface Adapter {
  readonly name: string;
  readonly userName: string; // Can override global userName

  // Lifecycle
  initialize(chat: ChatInstance): Promise<void>;

  // Webhook handling
  handleWebhook(request: Request): Promise<Response>;

  // Actions
  postMessage(threadId: string, message: PostableMessage): Promise<Message>;
  editMessage(messageId: string, message: PostableMessage): Promise<Message>;
  deleteMessage(messageId: string): Promise<void>;
  addReaction(messageId: string, emoji: string): Promise<void>;
  removeReaction(messageId: string, emoji: string): Promise<void>;
  startTyping(threadId: string): Promise<void>;
  fetchMessages(threadId: string, options: FetchOptions): Promise<Message[]>;
  fetchThread(threadId: string): Promise<ThreadInfo>;

  // Platform-specific thread ID encoding/decoding
  encodeThreadId(platformData: unknown): string;
  decodeThreadId(threadId: string): unknown;
}

interface StateAdapter {
  // Subscriptions
  subscribe(threadId: string): Promise<void>;
  unsubscribe(threadId: string): Promise<void>;
  isSubscribed(threadId: string): Promise<boolean>;

  // Locking
  acquireLock(threadId: string, ttlMs: number): Promise<Lock | null>;
  releaseLock(lock: Lock): Promise<void>;
}

interface Lock {
  threadId: string;
  token: string;
  expiresAt: number;
}

1.2 Thread and Message Types

// Thread ID format: "adapter:channel:thread"
// e.g., "slack:C1234567:1234567890.123456"

interface Thread {
  readonly id: string;
  readonly adapter: Adapter;
  readonly channelId: string;

  // Cached messages from initial webhook + any fetched
  recentMessages: Message[];

  // Lazy async iterator for full history
  allMessages: AsyncIterable<Message>;

  // Actions
  subscribe(): Promise<void>;
  unsubscribe(): Promise<void>;
  post(message: string | PostableMessage): Promise<SentMessage>;
  startTyping(): Promise<void>;
  refresh(): Promise<void>;
}

interface Message {
  readonly id: string;
  readonly threadId: string;

  text: string;      // Plain text (markdown stripped)
  markdown: string;  // Normalized markdown representation
  raw: unknown;      // Platform-specific original (escape hatch)

  author: Author;
  metadata: MessageMetadata;
}

interface Author {
  userId: string;
  userName: string;
  fullName: string;
  isBot: boolean | "unknown";
  isMe: boolean; // Is this the bot itself?
}

interface MessageMetadata {
  dateSent: Date;
  edited: boolean;
  editedAt?: Date;
}

// Returned from thread.post() - allows editing/deleting
interface SentMessage extends Message {
  edit(newText: string | PostableMessage): Promise<SentMessage>;
  delete(): Promise<void>;
  addReaction(emoji: string): Promise<void>;
  removeReaction(emoji: string): Promise<void>;
}

1.3 Message Formatting

Simple markdown-based formatting. Covers 80%+ of use cases with minimal complexity.

// Primary: just pass a markdown string
await thread.post("Hello **world**! Check out [this link](https://example.com)");

// With attachments
await thread.post({
  text: "Here's the report:",
  attachments: [
    { type: "file", url: "https://...", name: "report.pdf" }
  ]
});

// Typing indicator
await thread.startTyping();

Supported markdown subset (common across all platforms):

  • **bold**
  • _italic_
  • ~~strikethrough~~
  • `inline code`
  • ```code blocks```
  • [links](url)
  • > quotes
  • Ordered/unordered lists

Types:

type PostableMessage = string | {
  text: string;
  attachments?: Attachment[];
};

interface Attachment {
  type: "image" | "file";
  url?: string;
  data?: Buffer | Blob;  // For uploads
  name?: string;
  mimeType?: string;
}

// Incoming messages preserve original + provide plain text
interface Message {
  text: string;           // Plain text (markdown stripped)
  markdown: string;       // Normalized markdown representation
  raw: unknown;           // Platform-specific original (escape hatch)
  // ... other fields
}

Mentions:

  • Use <@userId> syntax in markdown for user mentions
  • Adapters translate to/from platform-specific format
  • thread.mentionUser(userId) helper returns the correct mention string

Each adapter implements:

  • parseIncoming(raw: unknown): { text: string; markdown: string } - normalize to markdown
  • renderOutgoing(markdown: string): unknown - convert markdown to platform format

Phase 2: Chat Class (Main Entry Point)

// packages/chat-sdk/src/chat.ts

class Chat {
  private adapters: Map<string, Adapter>;
  private state: StateAdapter;
  private handlers: {
    onNewMention: Array<(thread: Thread, message: Message) => Promise<void>>;
    onNewMessage: Array<{
      pattern: RegExp;
      handler: (thread: Thread, message: Message) => Promise<void>;
    }>;
    onSubscribedMessage: Array<(thread: Thread, message: Message) => Promise<void>>;
  };

  constructor(config: ChatConfig);

  // Event registration
  onNewMention(
    handler: (thread: Thread, message: Message) => Promise<void>
  ): void;
  onNewMessage(
    pattern: RegExp,
    handler: (thread: Thread, message: Message) => Promise<void>
  ): void;
  onSubscribedMessage(
    handler: (thread: Thread, message: Message) => Promise<void>
  ): void;

  // Webhook handlers (exposed for routing)
  readonly webhooks: Record<string, (request: Request) => Promise<Response>>;

  // Internal: called by adapters when webhook received
  async handleIncomingMessage(
    adapter: Adapter,
    threadInfo: ThreadInfo,
    message: Message
  ): Promise<void>;
}

Webhook Flow

  1. Webhook hits bot.webhooks.slack (or other adapter)
  2. Adapter parses request, verifies signature, extracts message
  3. Adapter calls chat.handleIncomingMessage(adapter, threadInfo, message)
  4. Chat class:
    • Constructs Thread object
    • Acquires lock on thread (blocks if another instance is processing)
    • Checks if subscribed → routes to onSubscribedMessage handlers
    • Checks for @-mention of bot → routes to onNewMention handlers
    • Checks message against onNewMessage patterns → routes to matching handlers
    • Releases lock when all handlers complete

Phase 3: State Adapters

3.1 StateAdapter Interface

interface StateAdapter {
  // Connection lifecycle
  connect(): Promise<void>;
  disconnect(): Promise<void>;

  // Subscriptions (persistent across restarts)
  subscribe(threadId: string): Promise<void>;
  unsubscribe(threadId: string): Promise<void>;
  isSubscribed(threadId: string): Promise<boolean>;
  listSubscriptions(adapterName?: string): AsyncIterable<string>;

  // Distributed locking
  acquireLock(threadId: string, ttlMs: number): Promise<Lock | null>;
  releaseLock(lock: Lock): Promise<void>;
  extendLock(lock: Lock, ttlMs: number): Promise<boolean>;
}

3.2 Redis Implementation

// packages/state-redis/src/index.ts

import { createClient, RedisClientType } from "redis";

class RedisStateAdapter implements StateAdapter {
  private client: RedisClientType;
  private keyPrefix: string;

  constructor(options: { url: string; keyPrefix?: string });

  // Subscriptions stored as SET: "chat-sdk:subscriptions"
  // Locks use SETNX with TTL: "chat-sdk:lock:{threadId}"
}

3.3 Memory Implementation (for development)

// packages/state-memory/src/index.ts

class MemoryStateAdapter implements StateAdapter {
  private subscriptions = new Set<string>();
  private locks = new Map<string, Lock>();

  // Simple in-memory implementation
  // Warns in console that state won't persist
}

Phase 4: Platform Adapters

4.1 Slack Adapter

Dependencies: @slack/web-api, compatible with @vercel/slack-bolt

// packages/adapter-slack/src/index.ts

interface SlackAdapterConfig {
  botToken: string; // xoxb-...
  signingSecret: string; // For webhook verification
  userName?: string; // Override bot username
}

class SlackAdapter implements Adapter {
  private client: WebClient;

  // Thread ID format: "slack:{channelId}:{threadTs}"
  // If message is not in thread, threadTs = messageTs

  handleWebhook(request: Request): Promise<Response> {
    // 1. Verify signature using signingSecret
    // 2. Handle URL verification challenge
    // 3. Parse event (message, app_mention, etc.)
    // 4. Route to chat.handleIncomingMessage
    // 5. Return 200 quickly (Slack expects <3s response)
  }

  postMessage(threadId: string, message: PostableMessage): Promise<Message> {
    // Use chat.postMessage API
    // Convert markdown to Slack mrkdwn format
  }

  fetchMessages(threadId: string, options: FetchOptions): Promise<Message[]> {
    // Use conversations.replies API
  }
}

Slack-specific considerations:

  • Must respond to webhooks within 3 seconds (use background processing if needed)
  • Supports Block Kit for rich formatting
  • Thread = replies to a message (identified by thread_ts)
  • app_mention event for @-mentions

4.2 Microsoft Teams Adapter

Dependencies: botbuilder or direct REST API calls

// packages/adapter-teams/src/index.ts

interface TeamsAdapterConfig {
  appId: string;
  appPassword: string;
  userName?: string;
}

class TeamsAdapter implements Adapter {
  // Thread ID format: "teams:{conversationId}:{replyToId}"

  handleWebhook(request: Request): Promise<Response> {
    // 1. Validate JWT token from Microsoft
    // 2. Parse Activity object
    // 3. Route message activities to chat.handleIncomingMessage
  }

  postMessage(threadId: string, message: PostableMessage): Promise<Message> {
    // Use Bot Framework REST API
    // Convert markdown to Teams HTML subset
  }
}

Teams-specific considerations:

  • Uses Bot Framework protocol
  • Replies use replyToId for threading
  • Supports Adaptive Cards for rich content
  • @-mentions include <at> tags in message

4.3 Google Chat Adapter

Dependencies: googleapis or direct REST

// packages/adapter-gchat/src/index.ts

interface GoogleChatAdapterConfig {
  credentials: GoogleAuthCredentials;
  userName?: string;
}

class GoogleChatAdapter implements Adapter {
  // Thread ID format: "gchat:{spaceName}:{threadName}"

  handleWebhook(request: Request): Promise<Response> {
    // 1. Verify request (Bearer token or service account)
    // 2. Parse event payload
    // 3. Route MESSAGE events to chat.handleIncomingMessage
  }

  postMessage(threadId: string, message: PostableMessage): Promise<Message> {
    // Use Chat API spaces.messages.create
    // Convert markdown to Google Chat text format
  }
}

Google Chat-specific considerations:

  • Uses Google Cloud service account for auth
  • Threads are identified by thread.name
  • Cards for rich formatting
  • @-mentions detected via annotation type USER_MENTION

4.4 Discord Adapter (Lower Priority)

Dependencies: discord.js or direct REST API

// packages/adapter-discord/src/index.ts

interface DiscordAdapterConfig {
  botToken: string;
  publicKey: string; // For webhook verification
  userName?: string;
}

class DiscordAdapter implements Adapter {
  // Thread ID format: "discord:{guildId}:{channelId}:{threadId?}"
  // For DMs: "discord:dm:{channelId}"

  handleWebhook(request: Request): Promise<Response> {
    // 1. Verify Ed25519 signature
    // 2. Handle PING interaction
    // 3. Parse MESSAGE_CREATE events
    // 4. Route to chat.handleIncomingMessage
  }
}

Discord-specific considerations:

  • Threads can auto-archive (adapter should unarchive before posting)
  • DMs don't support threads (treat DM channel as the "thread")
  • Uses Ed25519 for webhook verification
  • Gateway vs HTTP interactions

Phase 5: Testing Strategy

5.1 Unit Tests

  • Markdown → platform format conversion for each adapter
  • Platform format → normalized markdown parsing
  • State adapter implementations (use in-memory for tests)
  • Thread ID encoding/decoding
  • Lock acquisition/release logic

5.2 Integration Tests

  • Mock webhook payloads for each platform
  • End-to-end flow: webhook → handler → post response
  • Subscription persistence across "restarts"
  • Concurrent webhook handling (lock contention)

5.3 Platform Sandboxes

Document how to set up test workspaces/servers:

  • Slack: Create test workspace, install app
  • Teams: Use Bot Framework Emulator
  • Google Chat: Use developer preview space
  • Discord: Create test server

Phase 6: Documentation & Examples

6.1 README

  • Quick start with Slack (most common)
  • Concepts: adapters, threads, messages, state
  • API reference

6.2 Examples

examples/
  nextjs-slack/           # Next.js App Router + Slack
  nextjs-multi/           # Next.js with multiple adapters
  hono-cloudflare/        # Hono on Cloudflare Workers
  express-simple/         # Express.js basic setup

6.3 Migration Guide

For users coming from:

  • @slack/bolt
  • botbuilder
  • Raw Discord.js

Implementation Order

  1. Core types & interfaces (2-3 files, get the shapes right)
  2. Memory state adapter (enables testing without Redis)
  3. Slack adapter (most familiar, best documented API)
  4. Chat class (wire up the event routing)
  5. Integration tests with Slack
  6. Teams adapter
  7. Google Chat adapter
  8. Redis state adapter
  9. Discord adapter (last, lower priority)
  10. Examples & docs

Decisions Made

  1. Serverless: Use waitUntil / Next.js after() to respond quickly while processing in background
  2. Message editing: thread.post() returns SentMessage with edit() and delete() methods
  3. Reactions: Supported via addReaction() / removeReaction() on SentMessage
  4. File uploads: Support both URL references and Buffer/Blob uploads
  5. Rate limiting: Surface to users (don't hide behind internal retries)
  6. Typing indicators: Supported via thread.startTyping()
  7. Formatting: Simple markdown strings (no complex AST)