From 7fe084097da9e3d07a4c8177d8ad10e90cfed4db Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Wed, 25 Feb 2026 11:16:08 +0100 Subject: [PATCH 1/2] Add Chat skills --- .agents/skills/chat-sdk/SKILL.md | 148 +++++++++++++++++++++++++++++++ .kilocode/skills/chat-sdk | 1 + skills-lock.json | 10 +++ 3 files changed, 159 insertions(+) create mode 100644 .agents/skills/chat-sdk/SKILL.md create mode 120000 .kilocode/skills/chat-sdk create mode 100644 skills-lock.json diff --git a/.agents/skills/chat-sdk/SKILL.md b/.agents/skills/chat-sdk/SKILL.md new file mode 100644 index 00000000000..c3865d369a2 --- /dev/null +++ b/.agents/skills/chat-sdk/SKILL.md @@ -0,0 +1,148 @@ +--- +name: chat-sdk +description: > + Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to + (1) Build a Slack, Teams, Google Chat, Discord, GitHub, or Linear bot, + (2) Use the Chat SDK to handle mentions, messages, reactions, slash commands, cards, modals, or streaming, + (3) Set up webhook handlers for chat platforms, + (4) Send interactive cards or stream AI responses to chat platforms. + Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "discord bot", "@chat-adapter", + building bots that work across multiple chat platforms. +--- + +# Chat SDK + +Unified TypeScript SDK for building chat bots across Slack, Teams, Google Chat, Discord, GitHub, and Linear. Write bot logic once, deploy everywhere. + +## Critical: Read the bundled docs + +The `chat` package ships with full documentation in `node_modules/chat/docs/` and TypeScript source types. **Always read these before writing code:** + +``` +node_modules/chat/docs/ # Full documentation (MDX files) +node_modules/chat/dist/ # Built types (.d.ts files) +``` + +Key docs to read based on task: +- `docs/getting-started.mdx` — setup guides +- `docs/usage.mdx` — event handlers, threads, messages, channels +- `docs/streaming.mdx` — AI streaming with AI SDK +- `docs/cards.mdx` — JSX interactive cards +- `docs/actions.mdx` — button/dropdown handlers +- `docs/modals.mdx` — form dialogs (Slack only) +- `docs/adapters/*.mdx` — platform-specific adapter setup +- `docs/state/*.mdx` — state adapter config (Redis, ioredis, memory) + +Also read the TypeScript types from `node_modules/chat/dist/` to understand the full API surface. + +## Quick start + +```typescript +import { Chat } from "chat"; +import { createSlackAdapter } from "@chat-adapter/slack"; +import { createRedisState } from "@chat-adapter/state-redis"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + slack: createSlackAdapter({ + botToken: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + }), + }, + state: createRedisState({ url: process.env.REDIS_URL! }), +}); + +bot.onNewMention(async (thread) => { + await thread.subscribe(); + await thread.post("Hello! I'm listening to this thread."); +}); + +bot.onSubscribedMessage(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); +``` + +## Core concepts + +- **Chat** — main entry point, coordinates adapters and routes events +- **Adapters** — platform-specific (Slack, Teams, GChat, Discord, GitHub, Linear) +- **State** — pluggable persistence (Redis for prod, memory for dev) +- **Thread** — conversation thread with `post()`, `subscribe()`, `startTyping()` +- **Message** — normalized format with `text`, `formatted` (mdast AST), `raw` +- **Channel** — container for threads, supports listing and posting + +## Event handlers + +| Handler | Trigger | +|---------|---------| +| `onNewMention` | Bot @-mentioned in unsubscribed thread | +| `onSubscribedMessage` | Any message in subscribed thread | +| `onNewMessage(regex)` | Messages matching pattern in unsubscribed threads | +| `onSlashCommand("/cmd")` | Slash command invocations | +| `onReaction(emojis)` | Emoji reactions added/removed | +| `onAction(actionId)` | Button clicks and dropdown selections | +| `onAssistantThreadStarted` | Slack Assistants API thread opened | +| `onAppHomeOpened` | Slack App Home tab opened | + +## Streaming + +Pass any `AsyncIterable` to `thread.post()`. Works with AI SDK's `textStream`: + +```typescript +import { ToolLoopAgent } from "ai"; +const agent = new ToolLoopAgent({ model: "anthropic/claude-4.5-sonnet" }); + +bot.onNewMention(async (thread, message) => { + const result = await agent.stream({ prompt: message.text }); + await thread.post(result.textStream); +}); +``` + +## Cards (JSX) + +Set `jsxImportSource: "chat"` in tsconfig. Components: `Card`, `CardText`, `Button`, `Actions`, `Fields`, `Field`, `Select`, `SelectOption`, `Image`, `Divider`, `LinkButton`, `Section`, `RadioSelect`. + +```tsx +await thread.post( + + Your order has been received! + + + + + +); +``` + +## Packages + +| Package | Purpose | +|---------|---------| +| `chat` | Core SDK | +| `@chat-adapter/slack` | Slack | +| `@chat-adapter/teams` | Microsoft Teams | +| `@chat-adapter/gchat` | Google Chat | +| `@chat-adapter/discord` | Discord | +| `@chat-adapter/github` | GitHub Issues | +| `@chat-adapter/linear` | Linear Issues | +| `@chat-adapter/state-redis` | Redis state (production) | +| `@chat-adapter/state-ioredis` | ioredis state (alternative) | +| `@chat-adapter/state-memory` | In-memory state (development) | + +## Changesets (Release Flow) + +This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and changelogs. Every PR that changes a package's behavior must include a changeset. + +```bash +pnpm changeset +# → select affected package(s) (e.g. @chat-adapter/slack, chat) +# → choose bump type: patch (fixes), minor (features), major (breaking) +# → write a short summary for the CHANGELOG +``` + +This creates a file in `.changeset/` — commit it with the PR. When merged to `main`, the Changesets GitHub Action opens a "Version Packages" PR to bump versions and update CHANGELOGs. Merging that PR publishes to npm. + +## Webhook setup + +Each adapter exposes a webhook handler via `bot.webhooks.{platform}`. Wire these to your HTTP framework's routes (e.g. Next.js API routes, Hono, Express). diff --git a/.kilocode/skills/chat-sdk b/.kilocode/skills/chat-sdk new file mode 120000 index 00000000000..77d22f1b1f6 --- /dev/null +++ b/.kilocode/skills/chat-sdk @@ -0,0 +1 @@ +../../.agents/skills/chat-sdk \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000000..08c55950216 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "chat-sdk": { + "source": "vercel/chat", + "sourceType": "github", + "computedHash": "dd43b1cf55f7a481732655011e121140143db3c1280b7ed502fa6be8180fb057" + } + } +} From fc8c4c60fdef5f6863f9455d1061659b46051adf Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Wed, 25 Feb 2026 11:36:11 +0100 Subject: [PATCH 2/2] Refactor Slack bot to use Chat SDK for multi-platform support Replace the bespoke Slack webhook handling with the Vercel Chat SDK (chat package), which provides a unified abstraction across Slack, Discord, Teams, and other platforms. Key changes: - New src/lib/chat-bot/index.ts: Central Chat SDK instance with Slack adapter, event handlers (onNewMention, onSubscribedMessage), and installation bridge from our DB into the SDK's state layer - Webhook route reduced from 308 to 23 lines by delegating to chat.webhooks.slack() which handles signature verification, URL challenge, event routing, bot self-message filtering, and dedup - Interactivity route similarly simplified - Reactions, ephemeral messages, and markdown-to-mrkdwn conversion now use Chat SDK primitives instead of manual Slack API calls - AI brain (processKiloBotMessage, runBot, Cloud Agent spawning) remains unchanged as it is already platform-agnostic New dependencies: chat, @chat-adapter/slack, @chat-adapter/state-memory --- package.json | 4 + pnpm-lock.yaml | 150 +++++++++++++ src/app/slack/interactivity/route.ts | 24 +- src/app/slack/webhook/route.ts | 313 ++------------------------- src/lib/chat-bot/index.ts | 265 +++++++++++++++++++++++ 5 files changed, 445 insertions(+), 311 deletions(-) create mode 100644 src/lib/chat-bot/index.ts diff --git a/package.json b/package.json index 24ce38b0de1..02fb46cbf89 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "@ai-sdk/xai": "^3.0.54", "@aws-sdk/client-s3": "^3.980.0", "@aws-sdk/s3-request-presigner": "^3.980.0", + "@chat-adapter/slack": "^4.13.1", + "@chat-adapter/state-memory": "^4.13.1", + "@chat-adapter/state-redis": "^4.13.1", "@lottiefiles/dotlottie-react": "^0.17.6", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", @@ -90,6 +93,7 @@ "@vercel/otel": "^2.1.0", "@workos-inc/node": "^8.0.0", "ai": "^6.0.78", + "chat": "^4.13.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bd768a5c69..420e8a57993 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,15 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.980.0 version: 3.980.0 + '@chat-adapter/slack': + specifier: ^4.13.1 + version: 4.13.1 + '@chat-adapter/state-memory': + specifier: ^4.13.1 + version: 4.13.1 + '@chat-adapter/state-redis': + specifier: ^4.13.1 + version: 4.13.1 '@lottiefiles/dotlottie-react': specifier: ^0.17.6 version: 0.17.7(react@19.2.0) @@ -180,6 +189,9 @@ importers: ai: specifier: ^6.0.78 version: 6.0.78(zod@4.3.4) + chat: + specifier: ^4.13.1 + version: 4.13.1 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2053,6 +2065,18 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@chat-adapter/shared@4.13.1': + resolution: {integrity: sha512-UOixL/WfNbjIXqDhBq6V1Gg4deNPl6M8uZseIPs+E1IUc1HHH5Yu0mVGlImvcTGenknHJa/H7KFQB59cIja3CQ==} + + '@chat-adapter/slack@4.13.1': + resolution: {integrity: sha512-suGpq22WZ9h+Ow7uumoqTlxJHZjIGh1doOau1QFUUlEUsv6nKH8/oLhM5fXge40wy9s6gf9BNfBBMmuoSWX0TA==} + + '@chat-adapter/state-memory@4.13.1': + resolution: {integrity: sha512-xul6ZiwBvuH4TbnR3YW8y+o8FHgNo+RiUUv9ILkbTdcqsA/dKg0mBkAx4zKF0P3ymBo+khAkoU3njsHD6Gt3Ag==} + + '@chat-adapter/state-redis@4.13.1': + resolution: {integrity: sha512-aIr91eyny/PROR4oCxDTaB7MBoaiCKJMzB/oYSi063KsWG4I2WJPw3qV7wRftVD2pEz62jJZeg6jBOWqh75FsA==} + '@chromatic-com/playwright@0.12.8': resolution: {integrity: sha512-2oi8ghd+2N+hDI0Y65Ac/aYfsvDTyDHvEZMNS4Ln7VzyZ6SdGLoQLOvP80IGJlekTI68K8gQpzXewRV2chUaZQ==} hasBin: true @@ -4217,6 +4241,35 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@reduxjs/toolkit@2.9.1': resolution: {integrity: sha512-sETJ3qO72y7L7WiR5K54UFLT3jRzAtqeBPVO15xC3bGA6kDqCH8m/v7BKCPH4czydXzz/1lPEGLvew7GjOO3Qw==} peerDependencies: @@ -6120,6 +6173,9 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@workflow/serde@4.0.1-beta.1': + resolution: {integrity: sha512-JdCvZk0sD1qk2XzteSHVNGbpRM8YcvRBglSJy3FysYgi19pBkk3me2P9gzFpPrnQS6IhAPnEqnJOAODHg1iZrA==} + '@workos-inc/node@8.2.0': resolution: {integrity: sha512-aAY2k7X09lQ4TaVe7Kjm3gjb+EsGRoncbwJCOou9mCmunQoEYO1lsNrJTRIMM+jKCPBQDcmMF1O3Bv8IQwQKtg==} engines: {node: '>=20.15.0'} @@ -6678,6 +6734,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chat@4.13.1: + resolution: {integrity: sha512-RjbgLkI7OSiZOaqWeJIJjmvZCrdnDkLQIAsKHrGU/3ytGQk2nvvG9fyP3bD4/5MvfQYot6dFlW1vmJVURneUmQ==} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -6768,6 +6827,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: @@ -8037,6 +8100,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -10545,6 +10612,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -13544,6 +13614,34 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@chat-adapter/shared@4.13.1': + dependencies: + chat: 4.13.1 + transitivePeerDependencies: + - supports-color + + '@chat-adapter/slack@4.13.1': + dependencies: + '@chat-adapter/shared': 4.13.1 + '@slack/web-api': 7.13.0 + chat: 4.13.1 + transitivePeerDependencies: + - debug + - supports-color + + '@chat-adapter/state-memory@4.13.1': + dependencies: + chat: 4.13.1 + transitivePeerDependencies: + - supports-color + + '@chat-adapter/state-redis@4.13.1': + dependencies: + chat: 4.13.1 + redis: 4.7.1 + transitivePeerDependencies: + - supports-color + '@chromatic-com/playwright@0.12.8(@playwright/test@1.57.0)(@swc/core@1.12.5)(@testing-library/dom@10.4.1)(@types/react@19.2.2)(esbuild@0.25.12)(prettier@3.7.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@chromaui/rrweb-snapshot': 2.0.0-alpha.18-noAbsolute @@ -15786,6 +15884,32 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + '@reduxjs/toolkit@2.9.1(react-redux@9.2.0(@types/react@19.2.2)(react@19.2.0)(redux@5.0.1))(react@19.2.0)': dependencies: '@standard-schema/spec': 1.1.0 @@ -18327,6 +18451,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@workflow/serde@4.0.1-beta.1': {} + '@workos-inc/node@8.2.0': dependencies: iron-webcrypto: 2.0.0 @@ -18953,6 +19079,17 @@ snapshots: character-reference-invalid@2.0.1: {} + chat@4.13.1: + dependencies: + '@workflow/serde': 4.0.1-beta.1 + mdast-util-to-string: 4.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + check-error@2.1.1: {} chokidar@3.6.0: @@ -19032,6 +19169,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) @@ -20573,6 +20712,8 @@ snapshots: generator-function@2.0.1: {} + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-amd-module-type@6.0.1: @@ -24083,6 +24224,15 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 diff --git a/src/app/slack/interactivity/route.ts b/src/app/slack/interactivity/route.ts index 2b8c65ba230..53fa06add0f 100644 --- a/src/app/slack/interactivity/route.ts +++ b/src/app/slack/interactivity/route.ts @@ -1,21 +1,21 @@ import type { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; -import { verifySlackRequest } from '@/lib/slack/verify-request'; +import { after } from 'next/server'; +import { chat } from '@/lib/chat-bot'; /** - * Slack Interactivity endpoint handler + * Slack Interactivity endpoint handler. + * * Handles interactive components like buttons, modals, shortcuts, etc. + * Delegates to the Chat SDK which handles signature verification and routing. + * + * Register handlers via chat.onAction(), chat.onModalSubmit(), etc. in @/lib/chat-bot. + * * @see https://api.slack.com/interactivity/handling */ export async function POST(request: NextRequest) { - const rawBody = await request.text(); - const timestamp = request.headers.get('x-slack-request-timestamp'); - const signature = request.headers.get('x-slack-signature'); + console.log('[Slack:Interactivity] POST request received'); - if (!verifySlackRequest(rawBody, timestamp, signature)) { - console.error('[Slack:Interactivity] Invalid Slack signature'); - return new NextResponse('Invalid signature', { status: 401 }); - } - - return new NextResponse(null, { status: 200 }); + return chat.webhooks.slack(request, { + waitUntil: p => after(() => p), + }); } diff --git a/src/app/slack/webhook/route.ts b/src/app/slack/webhook/route.ts index a629f2e6b2d..8a2e3ec74d3 100644 --- a/src/app/slack/webhook/route.ts +++ b/src/app/slack/webhook/route.ts @@ -1,308 +1,23 @@ import type { NextRequest } from 'next/server'; -import { after, NextResponse } from 'next/server'; -import type { AppMentionEvent, GenericMessageEvent } from '@slack/types'; -import { WebClient } from '@slack/web-api'; -import { processKiloBotMessage } from '@/lib/slack-bot'; -import { markdownToSlackMrkdwn } from '@/lib/slack/markdownToSlackMrkdwn'; -import { logSlackBotRequest } from '@/lib/slack-bot-logging'; -import { - getInstallationByTeamId, - getAccessTokenFromInstallation, - getOwnerFromInstallation, - addSlackReactionByAccessToken, - removeSlackReactionByAccessToken, - postSlackMessageByAccessToken, -} from '@/lib/integrations/slack-service'; -import { - stripSlackBotMention, - getSlackBotUserIdFromInstallation, - replaceSlackUserMentionsWithNames, - isExternalWorkspaceEvent, -} from '@/lib/slack-bot/slack-utils'; -import { getDevUserSuffix } from '@/lib/slack-bot/dev-user-info'; -import { APP_URL } from '@/lib/constants'; -import { verifySlackRequest } from '@/lib/slack/verify-request'; -import { db } from '@/lib/drizzle'; -import { cliSessions } from '@/db/schema'; -import { eq } from 'drizzle-orm'; - -import type { Owner } from '@/lib/integrations/core/types'; -import type { PlatformIntegration } from '@/db/schema'; - -/** - * Reaction emoji names - */ -const PROCESSING_REACTION = 'hourglass_flowing_sand'; -const COMPLETE_REACTION = 'white_check_mark'; - -/** - * Build the session URL for a cloud agent session based on the owner type - * @param dbSessionId - The database UUID (session_id from cli_sessions table) - * @param owner - The owner of the installation (user or org) - */ -function buildSessionUrl(dbSessionId: string, owner: Owner): string { - const basePath = owner.type === 'org' ? `/organizations/${owner.id}/cloud` : '/cloud'; - return `${APP_URL}${basePath}/chat?sessionId=${dbSessionId}`; -} +import { after } from 'next/server'; +import { chat } from '@/lib/chat-bot'; /** - * Look up the database session UUID from the cloud agent session ID - * @param cloudAgentSessionId - The agent_xxx format ID from cloud agent - * @returns The database UUID (session_id) or null if not found - */ -async function getDbSessionIdFromCloudAgentId(cloudAgentSessionId: string): Promise { - const [session] = await db - .select({ session_id: cliSessions.session_id }) - .from(cliSessions) - .where(eq(cliSessions.cloud_agent_session_id, cloudAgentSessionId)) - .limit(1); - - return session?.session_id ?? null; -} - -/** - * Post an ephemeral message to the user with a button to view the cloud agent session - */ -async function postSessionLinkEphemeral({ - accessToken, - channel, - user, - threadTs, - cloudAgentSessionId, - installation, -}: { - accessToken: string; - channel: string; - user: string; - threadTs: string; - cloudAgentSessionId: string; - installation: PlatformIntegration; -}): Promise { - const owner = getOwnerFromInstallation(installation); - if (!owner) { - console.error('[SlackBot:Webhook] Could not determine owner for session link'); - return; - } - - // Look up the database session UUID from the cloud agent session ID - const dbSessionId = await getDbSessionIdFromCloudAgentId(cloudAgentSessionId); - if (!dbSessionId) { - console.error( - '[SlackBot:Webhook] Could not find database session for cloud agent session:', - cloudAgentSessionId - ); - return; - } - - const sessionUrl = buildSessionUrl(dbSessionId, owner); - - const client = new WebClient(accessToken); - await client.chat.postEphemeral({ - channel, - user, - text: `View the Cloud Agent session: ${sessionUrl}`, - thread_ts: threadTs, - blocks: [ - { - type: 'actions', - elements: [ - { - type: 'button', - text: { - type: 'plain_text', - text: 'View Session', - emoji: true, - }, - value: 'view_session', - url: sessionUrl, - }, - ], - }, - ], - }); -} - -/** - * Slack Events API webhook handler - * Handles @mentions of the bot and responds in threads + * Slack Events API webhook handler. + * + * Delegates all event handling to the Chat SDK which handles: + * - Signature verification + * - URL verification challenge + * - Event routing (app_mention, message, reactions, etc.) + * - Bot self-message filtering + * - Deduplication + * + * The actual bot logic is registered via event handlers in @/lib/chat-bot. */ export async function POST(request: NextRequest) { console.log('[SlackBot:Webhook] POST request received'); - const rawBody = await request.text(); - const timestamp = request.headers.get('x-slack-request-timestamp'); - const signature = request.headers.get('x-slack-signature'); - - if (!verifySlackRequest(rawBody, timestamp, signature)) { - console.error('[SlackBot:Webhook] Invalid Slack signature'); - return new NextResponse('Invalid signature', { status: 401 }); - } - - const body = JSON.parse(rawBody); - - // Handle Slack URL verification challenge - // This is required when first setting up the Events API - if (body.type === 'url_verification') { - console.log('[SlackBot:Webhook] URL verification challenge received'); - return NextResponse.json({ challenge: body.challenge }); - } - - // Handle event callbacks - if (body.type === 'event_callback') { - const event = body.event; - const teamId = body.team_id as string; - console.log('[SlackBot:Webhook] Event received:', event?.type, 'from team:', teamId); - - if (isExternalWorkspaceEvent(event)) { - console.log('[SlackBot:Webhook] Event is from an external user, ignoring'); - return new NextResponse(null, { status: 200 }); - } - - // Handle app_mention events (ignore messages from bots) - if (event?.type === 'app_mention' && !event.bot_id) { - after(processSlackMessage(event, teamId)); - return new NextResponse(null, { status: 200 }); - } - - // Handle message events in channels/DMs (optional, for future expansion) - if (event?.type === 'message' && !event.bot_id && event.channel_type === 'im') { - after(processSlackMessage(event, teamId)); - return new NextResponse(null, { status: 200 }); - } - } - - // Return 200 for any other events to acknowledge receipt - return new NextResponse(null, { status: 200 }); -} - -/** - * Process a Slack message event (either @mention or direct message) - */ -async function processSlackMessage(event: AppMentionEvent | GenericMessageEvent, teamId: string) { - console.log(`[SlackBot:Webhook] processSlackMessage (${event.type} started for team:`, teamId); - console.log('[SlackBot:Webhook] Event data:', JSON.stringify(event, null, 2)); - - // Fetch the installation and access token once at the start - const installation = await getInstallationByTeamId(teamId); - if (!installation) { - console.error('[SlackBot:Webhook] No Slack installation found for team:', teamId); - return; - } - - const accessToken = getAccessTokenFromInstallation(installation); - if (!accessToken) { - console.error('[SlackBot:Webhook] No access token found for team:', teamId); - return; - } - - const startTime = Date.now(); - const { text, channel, ts, thread_ts, user } = event; - - const botUserId = getSlackBotUserIdFromInstallation(installation); - - // Remove only the bot's mention; keep other user mentions for context - // Slack mentions look like <@U123ABC456> or <@U123ABC456|username> - const cleanedText = stripSlackBotMention(text, botUserId); - - if (!cleanedText) { - console.log('[SlackBot:Webhook] No text after removing mention, ignoring'); - return; - } - - // Resolve user mentions to human-readable names - const client = new WebClient(accessToken); - const kiloInputText = await replaceSlackUserMentionsWithNames(client, cleanedText); - - console.log('[SlackBot:Webhook] Cleaned text:', kiloInputText); - - // Determine the thread to reply to - // If the mention was in a thread, reply there; otherwise reply to the original message - const replyThreadTs = thread_ts ?? ts; - - // Add a "processing" reaction to the original message - await addSlackReactionByAccessToken(accessToken, { - channel, - timestamp: ts, - name: PROCESSING_REACTION, - }); - - // Process the message through Kilo Bot - const result = await processKiloBotMessage(kiloInputText, teamId, { - channelId: channel, - threadTs: replyThreadTs, - userId: user as string, - messageTs: ts, - }); - const responseTimeMs = Date.now() - startTime; - - // Append dev user suffix if in dev environment - const responseWithDevInfo = result.response + getDevUserSuffix(); - const slackFormattedMessage = markdownToSlackMrkdwn(responseWithDevInfo); - - // Post the response in the thread - const slackResponse = await postSlackMessageByAccessToken(accessToken, { - channel, - text: slackFormattedMessage, - thread_ts: replyThreadTs, - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: slackFormattedMessage, - }, - }, - ], + return chat.webhooks.slack(request, { + waitUntil: p => after(() => p), }); - - console.log( - '[SlackBot:Webhook] Slack response:', - slackResponse.ok ? 'success' : slackResponse.error - ); - - // Replace the processing reaction with a complete reaction - await Promise.all([ - removeSlackReactionByAccessToken(accessToken, { - channel, - timestamp: ts, - name: PROCESSING_REACTION, - }), - addSlackReactionByAccessToken(accessToken, { - channel, - timestamp: ts, - name: COMPLETE_REACTION, - }), - ]); - - // If a cloud agent session was created, post an ephemeral button for the user to view it - if (result.cloudAgentSessionId && user) { - await postSessionLinkEphemeral({ - accessToken, - channel, - user, - threadTs: replyThreadTs, - cloudAgentSessionId: result.cloudAgentSessionId, - installation, - }); - } - - // Log the request for admin debugging - await logSlackBotRequest({ - slackTeamId: teamId, - slackTeamName: installation?.platform_account_login ?? undefined, - slackChannelId: channel, - slackUserId: user ?? 'unknown', - slackThreadTs: replyThreadTs, - eventType: event.type, - userMessage: kiloInputText, - status: result.error ? 'error' : 'success', - errorMessage: result.error, - responseTimeMs, - modelUsed: result.modelUsed, - toolCallsMade: result.toolCallsMade.length > 0 ? result.toolCallsMade : undefined, - cloudAgentSessionId: result.cloudAgentSessionId, - integration: installation, - }); - - console.log(`[SlackBot:Webhook] processSlackMessage (${event.type}) completed`); } diff --git a/src/lib/chat-bot/index.ts b/src/lib/chat-bot/index.ts new file mode 100644 index 00000000000..bae540f65ff --- /dev/null +++ b/src/lib/chat-bot/index.ts @@ -0,0 +1,265 @@ +import 'server-only'; +import { Chat, ConsoleLogger, emoji, type Thread, type Message } from 'chat'; +import { createSlackAdapter, type SlackAdapter } from '@chat-adapter/slack'; +import { createMemoryState } from '@chat-adapter/state-memory'; +import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_SIGNING_SECRET } from '@/lib/config.server'; +import { + getInstallationByTeamId, + getOwnerFromInstallation, + getAccessTokenFromInstallation, +} from '@/lib/integrations/slack-service'; +import { processKiloBotMessage } from '@/lib/slack-bot'; +import { logSlackBotRequest } from '@/lib/slack-bot-logging'; +import { getSlackBotUserIdFromInstallation } from '@/lib/slack-bot/slack-utils'; +import { getDevUserSuffix } from '@/lib/slack-bot/dev-user-info'; +import { APP_URL } from '@/lib/constants'; +import { db } from '@/lib/drizzle'; +import { cliSessions } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +import type { Owner } from '@/lib/integrations/core/types'; +import type { PlatformIntegration } from '@/db/schema'; +import type { SlackEventContext } from '@/lib/slack-bot/slack-channel-context'; +import type { SlackBotEventType } from '@/db/schema'; + +/** + * Create the Slack adapter for the Chat SDK. + * + * We operate in multi-workspace mode (no default botToken). + * The adapter resolves tokens per-team using setInstallation/getInstallation + * backed by the state adapter. We bridge from our DB on each webhook request + * via the `syncInstallationToState` helper. + */ +const logger = new ConsoleLogger('info', 'ChatBot'); + +const slackAdapter = createSlackAdapter({ + signingSecret: SLACK_SIGNING_SECRET ?? '', + logger, + clientId: SLACK_CLIENT_ID ?? undefined, + clientSecret: SLACK_CLIENT_SECRET ?? undefined, +}); + +/** + * The Chat SDK instance -- singleton for the application. + * + * Uses in-memory state for now. For production with multiple server instances, + * swap to createRedisState({ url: process.env.REDIS_URL!, logger: 'info' }). + */ +const chat = new Chat({ + userName: 'kilo', + adapters: { + slack: slackAdapter, + }, + state: createMemoryState(), + logger: 'info', +}); + +// Register as singleton for thread/message deserialization support +chat.registerSingleton(); + +// --------------------------------------------------------------------------- +// Installation bridge: sync our DB installations into the Chat SDK state +// --------------------------------------------------------------------------- + +/** + * Ensure the Chat SDK's Slack adapter has the installation for a given team. + * Called before webhook handling to bridge our DB-stored installations + * into the adapter's state-backed installation cache. + */ +export async function syncInstallationToState(teamId: string): Promise { + const installation = await getInstallationByTeamId(teamId); + if (!installation) return null; + + const accessToken = getAccessTokenFromInstallation(installation); + if (!accessToken) return null; + + const botUserId = getSlackBotUserIdFromInstallation(installation); + + await slackAdapter.setInstallation(teamId, { + botToken: accessToken, + botUserId: botUserId ?? undefined, + teamName: installation.platform_account_login ?? undefined, + }); + + return installation; +} + +// --------------------------------------------------------------------------- +// Session URL helpers (moved from webhook route) +// --------------------------------------------------------------------------- + +function buildSessionUrl(dbSessionId: string, owner: Owner): string { + const basePath = owner.type === 'org' ? `/organizations/${owner.id}/cloud` : '/cloud'; + return `${APP_URL}${basePath}/chat?sessionId=${dbSessionId}`; +} + +async function getDbSessionIdFromCloudAgentId(cloudAgentSessionId: string): Promise { + const [session] = await db + .select({ session_id: cliSessions.session_id }) + .from(cliSessions) + .where(eq(cliSessions.cloud_agent_session_id, cloudAgentSessionId)) + .limit(1); + + return session?.session_id ?? null; +} + +// --------------------------------------------------------------------------- +// Extract Slack-specific context from a Chat SDK message +// --------------------------------------------------------------------------- + +/** + * Extract Slack event context from a Chat SDK thread + message. + * Maps from the Chat SDK's normalized format back to the Slack-specific + * context our processKiloBotMessage expects. + */ +function extractSlackEventContext( + thread: Thread, + message: Message +): { teamId: string; slackEventContext: SlackEventContext } | null { + // Thread ID format: "slack:CHANNEL:THREAD_TS" + const slackAdapter = chat.getAdapter('slack'); + const decoded = slackAdapter.decodeThreadId(thread.id); + + // The team_id comes from the raw Slack event + const raw = message.raw as { team?: string; team_id?: string }; + const teamId = raw.team_id ?? raw.team; + if (!teamId) return null; + + return { + teamId, + slackEventContext: { + channelId: decoded.channel, + threadTs: decoded.threadTs, + userId: message.author.userId, + messageTs: message.id, // Slack message ts is the message ID + }, + }; +} + +// --------------------------------------------------------------------------- +// Core message handler -- shared between onNewMention and onSubscribedMessage +// --------------------------------------------------------------------------- + +async function handleBotMessage(thread: Thread, message: Message): Promise { + const context = extractSlackEventContext(thread, message); + if (!context) { + console.error('[ChatBot] Could not extract Slack context from message'); + return; + } + + const { teamId, slackEventContext } = context; + const startTime = Date.now(); + + // Sync installation to state (ensures adapter has the token) + const installation = await syncInstallationToState(teamId); + if (!installation) { + console.error('[ChatBot] No installation found for team:', teamId); + await thread.post( + 'Error: No Slack integration found for this workspace. Please install the Kilo Code Slack integration.' + ); + return; + } + + // Add processing reaction to the original message + // We need the SentMessage wrapper to use addReaction + const originalMessage = thread.createSentMessageFromMessage(message); + await originalMessage.addReaction(emoji.hourglass); + + // The Chat SDK already strips the bot @mention and normalizes the text + const userText = message.text; + if (!userText.trim()) { + console.log('[ChatBot] Empty message text, ignoring'); + await originalMessage.removeReaction(emoji.hourglass); + return; + } + + console.log('[ChatBot] Processing message:', userText.slice(0, 100)); + + // Process through the existing AI brain + const result = await processKiloBotMessage(userText, teamId, slackEventContext); + const responseTimeMs = Date.now() - startTime; + + // Append dev user suffix if in dev environment + const responseWithDevInfo = result.response + getDevUserSuffix(); + + // Post the response -- the Chat SDK handles markdown -> mrkdwn conversion + await thread.post({ markdown: responseWithDevInfo }); + + // Swap reactions: remove hourglass, add checkmark + await Promise.all([ + originalMessage.removeReaction(emoji.hourglass), + originalMessage.addReaction(emoji.check), + ]); + + // Post ephemeral session link if a Cloud Agent session was created + if (result.cloudAgentSessionId && message.author.userId) { + const owner = getOwnerFromInstallation(installation); + if (owner) { + const dbSessionId = await getDbSessionIdFromCloudAgentId(result.cloudAgentSessionId); + if (dbSessionId) { + const sessionUrl = buildSessionUrl(dbSessionId, owner); + // Use the Chat SDK's ephemeral support with a card containing a link button + await thread.postEphemeral( + message.author, + { markdown: `[View Session](${sessionUrl})` }, + { fallbackToDM: true } + ); + } + } + } + + // Determine event type for logging + const eventType: SlackBotEventType = message.isMention ? 'app_mention' : 'message'; + + // Log the request + await logSlackBotRequest({ + slackTeamId: teamId, + slackTeamName: installation?.platform_account_login ?? undefined, + slackChannelId: slackEventContext.channelId, + slackUserId: message.author.userId ?? 'unknown', + slackThreadTs: slackEventContext.threadTs, + eventType, + userMessage: userText, + status: result.error ? 'error' : 'success', + errorMessage: result.error, + responseTimeMs, + modelUsed: result.modelUsed, + toolCallsMade: result.toolCallsMade.length > 0 ? result.toolCallsMade : undefined, + cloudAgentSessionId: result.cloudAgentSessionId, + integration: installation, + }); + + console.log('[ChatBot] Message processing completed'); +} + +// --------------------------------------------------------------------------- +// Register event handlers +// --------------------------------------------------------------------------- + +/** + * Handle new @mentions in unsubscribed threads. + * Subscribe to the thread so follow-up messages are also handled. + */ +chat.onNewMention(async (thread, message) => { + console.log('[ChatBot] New mention received'); + await thread.subscribe(); + await handleBotMessage(thread, message); +}); + +/** + * Handle messages in subscribed threads. + * This fires for follow-up messages after the initial @mention. + */ +chat.onSubscribedMessage(async (thread, message) => { + console.log('[ChatBot] Subscribed message received, isMention:', message.isMention); + // Process all messages in subscribed threads (not just mentions) + // This enables conversational follow-ups + await handleBotMessage(thread, message); +}); + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export { chat, slackAdapter }; +export type { SlackAdapter };