Skip to content
Back to skills

Slack Bot

ASecurity

Slack bot integration patterns using the Chat SDK. Use when working with Slack webhook handling, message processing, slash commands, OAuth installation, identity verification, or markdown-to-Slack conversion. Triggers on files in apps/ai/src/modules/slack-bot/.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 27, 2026
developmenttypescriptgobashreactexpresstestingapidatabasefrontendsecurity

Works with

  • terminal
  • cli
  • api

Security analysis

A100/100

Scanned September 27, 2026

npx -y skills add gethamster/skills --skill slack-bot --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Slack Bot?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Slack Bot
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/gethamster-slack-bot/badge)](https://www.skillsdirectory.com/skills/gethamster-slack-bot)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: slack-bot
description: "Slack bot integration patterns using the Chat SDK. Use when working with Slack webhook handling, message processing, slash commands, OAuth installation, identity verification, or markdown-to-Slack conversion. Triggers on files in apps/ai/src/modules/slack-bot/."
version: "1.1.0"
---

# Slack Bot Integration

Slack bot module built on the [Chat SDK](https://www.npmjs.com/package/chat) (`chat` + `@chat-adapter/slack`). Handles DMs, slash commands, OAuth installation, and identity verification.

## Quick Reference

| Task | File | Notes |
| --- | --- | --- |
| Add a sub-command | `slack-slash-commands.service.ts` | Add to `SubCommand` type + `getSubCommandMap()` |
| Change message processing | `slack-chat-handler.service.ts` | `handleMessage()` → `processIncomingText()` is the shared flow |
| Modify webhook behavior | `slack-webhook.controller.ts` | Single `POST /webhooks/slack/events` endpoint |
| Update OAuth flow | `slack-bot-install.controller.ts` | `GET /webhooks/slack/install/callback`; `stateParam` typed as `unknown` with runtime string guard |
| Start OAuth install flow | `slack-bot-install.controller.ts` | `GET /webhooks/slack/install` redirects to Slack authorize |
| Change account linking | `slack-bot-verify.controller.ts` | `POST /v1/slack-bot/verify` (authenticated) |
| Verify page (frontend) | `apps/web/app/auth/slack/verify/` | Scoped to installation's account via cookie hint |
| Modify verification logic | `slack-bot-verify.service.ts` | Token redemption + Slack confirmation message |
| Feature-flag the connector | `feature-flags.config.ts` | `enableConnectorSlackBot` / `NEXT_PUBLIC_ENABLE_CONNECTOR_SLACK_BOT` |
| Edit markdown conversion | `utils/markdown-to-slack.ts` | Preserve code blocks first, then transform |
| Manage identities/threads | `external-bot.repository.ts` | Shared with `external-bot` module |

## Architecture

```
apps/ai/src/modules/slack-bot/
├── chat-bot.provider.ts              # Chat SDK singleton (SlackAdapter + Redis state)
├── slack-webhook.controller.ts       # Webhook: POST /webhooks/slack/events
├── slack-webhook.service.ts          # Webhook validation, payload size limits, signature verification
├── slack-chat-handler.service.ts     # SDK event handlers, message + file attachment processing
├── slack-slash-commands.service.ts   # Slash command registry
├── linear-feedback.service.ts        # Submits feedback as Linear issues via @kit/shared/linear-feedback
├── slack-bot-install.controller.ts   # OAuth install callback
├── slack-bot-verify.controller.ts    # Account linking verification
├── slack-bot-verify.service.ts       # Token redemption + Slack confirmation
├── slack-bot.module.ts
├── dto/
│   ├── slack-verify.dto.ts
│   └── slack-verify-response.dto.ts
└── utils/
    ├── markdown-to-slack.ts          # Markdown -> Slack mrkdwn
    ├── progressive-status.ts         # Progressive status messages during AI processing (timer chain + cleanup)
    ├── slack-api.ts                  # Slack API helpers (postMessage, postEphemeral, update, delete, reactions, token resolution)
    ├── slack-thread-context.ts       # Thread context fetch + synthetic attachment builder
    └── to-web-request.ts             # Express -> Web API Request

apps/ai/src/modules/external-bot/    # Shared identity/installation layer
├── external-bot.repository.ts           # DB: identities, threads, installations + bot token fallback
├── external-bot.service.ts              # Business logic: verification, thread orchestration, polling
└── external-bot.module.ts
```

**Dependencies**: `ExternalBotModule` (identity/threads), `AiAgentModule` (AI processing), `DocumentsModule` (file extraction via `ChatFileProcessorService`), `ConfigModule`, `RedisModule`.

## Feature Flag

The Slack Bot connector tile in the web UI is gated behind:

```
NEXT_PUBLIC_ENABLE_CONNECTOR_SLACK_BOT=true|false
```

Schema key: `enableConnectorSlackBot` in `apps/web/config/feature-flags.config.ts`. When disabled, the tile is hidden from the context-settings service grid.

**Env vars**: `SLACK_BOT_SIGNING_SECRET`, `SLACK_BOT_CLIENT_ID`, `SLACK_BOT_CLIENT_SECRET`, `SLACK_BOT_ENCRYPTION_KEY` (encrypts OAuth state in Redis). All required per environment — each env (local, tux, prod) has its own Slack bot app.

## Token Storage: Redis + DB Fallback

Bot OAuth tokens live in **two places** — Redis (primary, fast) and the database (durable fallback). This means a Redis flush doesn't force workspaces to reinstall the Slack app.

- **On OAuth install**: The install flow is kicked off from the team context-settings "Add Connection" page via an install dialog. It hits `GET /webhooks/slack/install`, which redirects to Slack's authorize URL with a `state` param encoding the installing `accountId`. On callback, the SDK stores the encrypted token in Redis. The install callback stores the token in Supabase Vault via `store_external_bot_token()` and saves the vault reference in `external_bot_installations.bot_token_secret_id`. If `accountId` was present in the state, it is bound to the installation (`account_id` column) via `bindInstallationAccount()` (first-writer-wins: only sets when NULL). **Note**: `verifyAndDecodeState` accepts `unknown` and rejects non-string values (e.g. array from repeated query params) before calling string methods.
- **On token resolution**: `resolveSlackBotToken()` tries Redis first. On miss (flush, key corruption, encryption key rotation), it reads from the Vault via `get_external_bot_token()`, returns the token, and re-syncs it to Redis so subsequent requests are fast again. Vault persistence ensures that the user doesn't have to reinstall the app into the workspace on cache miss.
- **On webhook**: `SlackWebhookController.ensureInstallation()` calls `resolveSlackBotToken()` before the SDK processes the request, transparently restoring Redis from DB if needed
- **On Redis flush**: Token is restored from DB automatically — no user action required

## Threading Model

In Slack's API, `ts` (timestamp) is the unique identifier for a message — a string like `"1234567890.123456"` that doubles as both ID and timestamp. Replying to a thread means passing the parent message's `ts` as `thread_ts`.

**Variable naming in `slack-chat-handler.service.ts`**:

- `incomingMessageTs` — the ts of the message the user just sent (Slack's `ts`)
- `threadTs` — the thread root ts, only present for replies (Slack's `thread_ts`)
- `threadAnchorTs` — resolved thread identifier: `threadTs ?? incomingMessageTs`

Each Slack thread (`channel:thread_ts`) maps 1:1 to a Hamster thread. The `platformChannelId` stored in `external_bot_threads` encodes this as `{channelId}:{threadAnchorTs}`.

### DMs

- **Top-level DM**: Each new message starts a brand-new Hamster thread. The bot replies as a Slack thread reply (using `postSlackThreadReply` with the message's own `ts` as `thread_ts`), which anchors the conversation. The handler does NOT call `thread.subscribe()` — subscribing at the channel level would capture all future DMs instead of letting each start a new thread.
- **DM thread reply**: Continues the existing Hamster thread mapped to that Slack thread. Uses `thread.subscribe()` so follow-ups route to `onSubscribedMessage`. Replies via SDK `thread.post()`.

### Channels

- **Top-level @mention**: Starts a brand-new Hamster thread, same as a top-level DM. The bot replies as a Slack thread reply (using `postSlackThreadReply` with the message's own `ts` as `thread_ts`), anchoring a new thread. Does NOT call `thread.subscribe()` — subscribing at the channel level would capture all future @mentions instead of letting each start a new thread. The SDK's `onNewMention` handler only fires on actual @mentions, so regular channel messages are ignored.
- **Thread @mention**: Processes normally. Thread context (prior messages) is fetched via `fetchSlackThreadContext` and attached as a synthetic `slack-thread-context.md` file so the AI agent has full conversation context.

## Message Processing Flow

`onNewMention` and `onSubscribedMessage` both delegate to `handleMessage` → `processIncomingText`. The SDK treats DMs as mentions, so `onNewMention` is the correct handler for both DMs and channel @mentions:

```
1. Strip bot mention from raw event text (<@UBOTID> → empty) to get actual user content
2. Early exit: no text + no attachments → silent return
3. Parse Slack IDs (channel via decodeThreadId, teamId from raw event)
4. Reject file-only messages (no text after mention stripping) → ephemeral hint to user
5. Identity resolution (findIdentity)
   ├── Not found → check installation has account binding
   │   ├── No installation or no account_id → silent rejection (fail-closed), stop
   │   └── Has account binding → send verification link with account_id hint via ephemeral message
   └── Found → continue
6. Subscribe to thread (skipped for top-level messages)
7. Determine platformChannelId = {channelId}:{threadAnchorTs}
8. Get or create Hamster thread (aiClient for auth, human userId as created_by)
9. Download + extract file attachments and fetch thread context
10. Create user message (human userId as created_by, source: 'slack', chat_state: 'getting_started')
11. Add hourglass reaction to user's message (fire-and-forget processing indicator)
12. Start progressive status messages (fire-and-forget timer chain, posts + updates a threaded message)
13. Process AI inline via aiAgentService.processThreadMessage()
14. Deliver replies:
    ├── Top-level message (DM or channel @mention) → postSlackThreadReply (anchors Slack thread)
    └── Thread reply → thread.post() via SDK
15. Cancel status messages + delete status message (in finally block)
16. Remove hourglass reaction (fire-and-forget, in finally block — removed on both success and failure)
```

### File Attachments

File attachments are downloaded from Slack, extracted via `ChatFileProcessorService`, and passed to the AI as `syntheticAttachments` in message metadata.

**Flow**: `processSlackAttachments` → `getEligibleAttachments` → `processInBatches` (bounded concurrency) → `processOneAttachment` (download + extract).

**Download strategies** (tried in order):

1. SDK `attachment.fetchData()` — primary
2. Slack `files.info` API — fallback (inline `content` for text, `url_private_download` for binary)

**Size enforcement** (three layers):

1. `getEligibleAttachments` — pre-filters by `attachment.size` metadata before downloading
2. `downloadFromUrl` — checks `Content-Length` header before reading the response body
3. `downloadFromUrl` — checks `arrayBuffer.byteLength` after download (catches missing/inaccurate `Content-Length`)

**Limits**: `MAX_FILE_SIZE_BYTES` (50 MB per file), `MAX_ATTACHMENTS_PER_MESSAGE` (8 per message), `MAX_CONCURRENT_DOWNLOADS` (3 parallel).

**Webhook payload limit**: 1 MB (`MAX_PAYLOAD_BYTES`) for all events. File content is downloaded separately via `fetchData()` / `files.info` — the webhook payload only contains JSON metadata.

**Oversized file feedback**: When files exceed the size limit, an ephemeral message lists the skipped file names so the user knows why they were dropped.

### Processing Indicator

Two fire-and-forget feedback mechanisms run during AI processing:

1. **Hourglass reaction** (`PROCESSING_REACTION` in `slack-chat-handler.service.ts`) — added at start, removed in `finally` block. Requires `reactions:write` scope.
2. **Progressive status messages** (`utils/progressive-status.ts`) — threaded reply posted at 1s, updated in-place at 10s/20s with randomly selected messages (5 per stage), then **deleted** on completion.

```
0s → hourglass reaction added
1s → status message posted (randomly picked from bank)
10s/20s → status message updated in place
AI responds → status message deleted, reaction removed
```

### RLS Ownership Pattern

All DB writes use the **AI user client** (`aiClient` where `auth.uid() = aiUserId`) for authentication. However, `created_by` is set to the **human user's** ID (the actual message author). This means `created_by ≠ auth.uid()` — the repository intentionally avoids `INSERT...RETURNING` (no `.select()` after insert) because the RLS creator-shortcut SELECT policy would not match.

The human user is tracked in thread metadata (`human_user_id`). The AI client passes `is_thread_owner` checks for participant and thread_item INSERT operations.

## Key Patterns

### Express to Web Request

The Chat SDK expects Web API `Request` objects:

```typescript
const webRequest = toWebRequest(req, { includeBody: true });
const sdkResponse = await this.bot.webhooks.slack(webRequest, {
  waitUntil: (task) => backgroundTasks.push(task),
});
```

### Sending Messages Outside Webhook Context

Use the shared helpers in `utils/slack-api.ts`. They handle the HTTP call, response parsing, and error formatting.

| Helper | Slack API | Visibility | Use case |
| --- | --- | --- | --- |
| `postSlackMessage` | `chat.postMessage` | Everyone in the channel | Public notifications |
| `postSlackThreadReply` | `chat.postMessage` (with `thread_ts`) | Everyone in the thread | AI replies to top-level messages — DMs and channel @mentions (anchors a Slack thread) |
| `postSlackEphemeral` | `chat.postEphemeral` | Only the target user | Verification links, confirmations, errors |
| `updateSlackMessage` | `chat.update` | Everyone in the channel | Edit a message in place (best-effort, swallows `message_not_found` / `cant_update_message`) |
| `deleteSlackMessage` | `chat.delete` | N/A (message removed) | Delete a message (best-effort, swallows `message_not_found`) |
| `addSlackReaction` | `reactions.add` | Everyone (emoji on message) | Processing indicator (best-effort, swallows `already_reacted`) |
| `removeSlackReaction` | `reactions.remove` | Everyone (removes emoji) | Clear processing indicator (best-effort, swallows `no_reaction`) |

Resolve the bot token first with `resolveSlackBotToken` (tries Redis, falls back to DB, re-syncs):

```typescript
import { postSlackEphemeral, postSlackMessage, resolveSlackBotToken } from './utils/slack-api';

const slackAdapter = this.bot.getAdapter('slack');
const botToken = await resolveSlackBotToken(slackAdapter, teamId, this.externalBotRepo);

// Visible only to the target user
await postSlackEphemeral(botToken, channelId, userId, 'Only you can see this');

// Visible to everyone in the channel
await postSlackMessage(botToken, channelId, 'Everyone can see this');
```

**Security**: Prefer `postSlackEphemeral` for anything containing tokens, links, or user-specific state. Never post verification URLs via `postSlackMessage` or `thread.post()` — they leak one-time tokens to other channel members.

### ID Extraction

Always extract `team_id` from raw events, not SDK abstractions. The Chat SDK normalizes messages into a generic shape, but workspace/guild/tenant IDs are platform-specific (`team_id` in Slack, `guild_id` in Discord, `tenantId` in Teams). The `external-bot` layer supports all three, so we read from `message.raw` to get the untouched platform payload. Use `decodeThreadId` for channel:

```typescript
// Team ID from raw event
const parsed = RawEventSchema.safeParse(message.raw);
const teamId = parsed.success ? (parsed.data.team ?? parsed.data.team_id) : undefined;

// Channel ID from thread
const decoded = this.bot.getAdapter('slack').decodeThreadId(thread.id);
const channelId = decoded.channel;
```

### Slash Commands (Per-Environment with Sub-Commands)

Each environment has its own Slack app and slash command: `/hamster` (prod), `/hamster-tux` (staging), `/hamster-local` (local). All route through the same sub-command system — the first word after the command is the sub-command (e.g. `/hamster logout`). Running the command with no arguments shows help.

**Available sub-commands**: `help`, `logout`, `feedback`

**Adding a new sub-command**:

```typescript
// 1. Add to SubCommand type
type SubCommand = 'help' | 'logout' | 'feedback' | 'mycommand';

// 2. Add handler method
private async handleMyCommand(event: SlashCommandEvent): Promise<void> {
  await this.replyEphemeral(event, 'Response text here.');
}

// 3. Register in getSubCommandMap()
['mycommand', { handler: (event) => this.handleMyCommand(event), description: 'Description shown in help' }],
```

**Important**: Register sub-commands in `getSubCommandMap()`, not `getCommandMap()`. The top-level app commands (`/hamster`, etc.) are registered automatically from `APP_COMMANDS`.

## Session Expiry (30-Day)

Identity sessions expire after 30 days of inactivity. The `last_active_at` column on `external_bot_identities` tracks the last message timestamp. On each incoming message, `resolveIdentity()` checks if `Date.now() - lastActiveAt > 30 days`. If expired, the identity is deleted and the user must re-verify. The expiry constant is `SESSION_EXPIRY_MS` in `slack-chat-handler.service.ts`.

## Membership Cleanup

When a user is removed from a team account (`accounts_memberships` DELETE), a trigger (`cleanup_bot_identities_on_membership_delete`) automatically deletes their `external_bot_identities` rows for that account. This prevents orphaned identities from letting removed users continue posting via the bot.

## Documentation

- **This file** (`SKILL.md`) — Update when adding/changing sub-commands, services, architecture, or internal patterns.
- **`docs/features/slack-bot.md`** — Update when changes affect users (new commands, changed behavior, new capabilities).

## Critical Rules

1. **Set `chat_state: 'getting_started'`** — Slack messages must use the getting_started chat state so the orchestrator handles them correctly
2. **Disable inline ask questions** — Slack does not support interactive ask_questions; the agent must respond with plain text only
3. **Fail closed on missing account binding** — If the installation has no `account_id`, do NOT mint a verification token. Send an ephemeral "not fully connected" message and return early. The install flow must bind an account first.
4. **Never subscribe top-level messages** — Calling `thread.subscribe()` on a top-level message (DM or channel @mention) captures all future messages in the channel, breaking new-thread creation. Only subscribe thread replies (`raw.thread_ts` present).

## Verification Flow (Frontend)

The verify page at `apps/web/app/auth/slack/verify/` is scoped to the installation's account:

```
promptVerification() builds URL: /auth/slack/verify/auth?token=xxx&account_id=yyy
  → auth/route.ts sets two cookies: slack_verify_token + slack_verify_account_id
  → page.tsx reads account hint cookie, queries external_bot_installations filtered to that account_id
  → VerifyForm auto-selects the single account (no multi-select shown)
  → linkSlackAccountAction clears both cookies on success
```

- The `account_id` in the URL is the installation's bound account — the only one `redeemVerificationToken` will accept
- `loadTeamAccounts(expectedAccountId?)` queries `external_bot_installations` with RLS (`has_role_on_account`), optionally filtered by the hint
- Falls back to showing all installed accounts if the cookie hint is missing (e.g. old verification links)

## Testing

```bash
pnpm --filter hamster-ai test -- --run apps/ai/src/modules/slack-bot/
```

Mock the Chat SDK bot with handler capture:

```typescript
const mockBot = { onNewMention: vi.fn(), onSubscribedMessage: vi.fn(), onSlashCommand: vi.fn() };
service.onModuleInit();
// onNewMention takes a single callback arg (the SDK treats DMs as mentions)
const handler = mockBot.onNewMention.mock.calls[0]![0];
await handler(mockThread, mockMessage);
```

## Local Development

Slack delivers webhooks via server-to-server HTTPS requests and verifies endpoint certificates, so `localhost` (plain HTTP, no public DNS) can't receive them. A Cloudflare Tunnel exposes the local API on a public HTTPS URL that Slack can reach.

1. **Start the dev server with tunnel**:

```bash
pnpm dev:with-slack
```

1. **Copy the `TUNNEL_URL`** printed in the terminal output (e.g. `https://fork-margin-hitting-disks.trycloudflare.com`)
2. **Update the Slack app manifest** at https://app.slack.com/app-settings/T0962GGFR3R/A0AK7LG9RC5/app-manifest — replace all URL prefixes with the tunnel URL
3. **Verify the URL** by clicking `Click here to verify` in the banner at the top of the screen
4. **Add Slack bot connection** by going to context-settings and installing `Slack Bot` for your team.
5. **Open the App in Slack** and message the Hamster Bot. It will send a verification link. Once completed you'll be able to have a conversation with Hamster.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…