Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/twelve-candies-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@chat-adapter/slack": minor
"chat": minor
---

Implement external_select block kit for Slack
51 changes: 51 additions & 0 deletions apps/docs/content/docs/modals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,57 @@ A dropdown for selecting a single option.
| `initialOption` | `string` (optional) | Pre-selected value |
| `optional` | `boolean` (optional) | Allow empty submission |

### ExternalSelect

A dropdown that loads its options dynamically from a handler as the user types. Useful for large or remote-backed option sets (people, tickets, records) where a static `<Select>` would be impractical. Slack-only.

| Prop | Type | Description |
|------|------|-------------|
| `id` | `string` | Field identifier (key in `event.values`) |
| `label` | `string` | Field label |
| `placeholder` | `string` (optional) | Placeholder text |
| `minQueryLength` | `number` (optional) | Minimum characters before the loader fires (Slack default: 3) |
| `optional` | `boolean` (optional) | Allow empty submission |

Register the loader with `onOptionsLoad`:

```tsx title="lib/bot.tsx" lineNumbers
import { ExternalSelect, Modal } from "chat";

bot.onAction("assign", async (event) => {
await event.openModal(
<Modal callbackId="assign_form" title="Assign to…">
<ExternalSelect
id="assignee"
label="Assignee"
placeholder="Search people"
minQueryLength={1}
/>
</Modal>
);
});

bot.onOptionsLoad("assignee", async (event) => {
const people = await peopleService.search(event.query);
return people.map((p) => ({ label: p.fullName, value: p.id }));
});

bot.onModalSubmit("assign_form", async (event) => {
const assigneeId = event.values.assignee;
// …
});
```

The selected value arrives in `event.values` on submit just like a static `<Select>`.

<Callout type="warn">
Slack requires a response within 3 seconds for options requests. The adapter caps the loader at ~2.5s and returns an empty result on timeout — keep your loader fast (cache, prefetch, or narrow the query server-side).
</Callout>

<Callout type="info">
**Slack setup:** `ExternalSelect` uses Slack's `block_suggestion` payload, which is dispatched to the **Options Load URL**. In your [Slack app settings](https://api.slack.com/apps) go to **Interactivity & Shortcuts** → **Select Menus** and set the **Options Load URL** to the same endpoint as your Interactivity Request URL (e.g. `https://your-domain.com/api/webhooks/slack`). Without this, typing into an external select will silently return no results.
</Callout>

### RadioSelect

A radio button group for mutually exclusive options.
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-discord/src/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const mockChat = {
getUserName: vi.fn().mockReturnValue("bot"),
handleIncomingMessage: vi.fn(),
processAction: vi.fn(),
processOptionsLoad: vi.fn().mockResolvedValue(undefined),
processAppHomeOpened: vi.fn(),
processAssistantContextChanged: vi.fn(),
processAssistantThreadStarted: vi.fn(),
Expand Down
3 changes: 3 additions & 0 deletions packages/adapter-discord/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3301,6 +3301,7 @@ describe("handleWebhook - forwarded gateway events", () => {
handleIncomingMessage: processMessage,
processSlashCommand: vi.fn(),
processAction: vi.fn(),
processOptionsLoad: vi.fn().mockResolvedValue(undefined),
processReaction: vi.fn(),
} as unknown as ChatInstance);

Expand Down Expand Up @@ -3343,6 +3344,7 @@ describe("handleWebhook - forwarded gateway events", () => {
handleIncomingMessage: vi.fn(),
processSlashCommand: vi.fn(),
processAction: vi.fn(),
processOptionsLoad: vi.fn().mockResolvedValue(undefined),
processReaction,
} as unknown as ChatInstance);

Expand Down Expand Up @@ -3389,6 +3391,7 @@ describe("handleWebhook - forwarded gateway events", () => {
handleIncomingMessage: vi.fn(),
processSlashCommand: vi.fn(),
processAction: vi.fn(),
processOptionsLoad: vi.fn().mockResolvedValue(undefined),
processReaction,
} as unknown as ChatInstance);

Expand Down
1 change: 1 addition & 0 deletions packages/adapter-gchat/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function createMockChatInstance(state: StateAdapter): ChatInstance {
processMessage: vi.fn(),
processReaction: vi.fn(),
processAction: vi.fn(),
processOptionsLoad: vi.fn().mockResolvedValue(undefined),
} as unknown as ChatInstance;
}

Expand Down
1 change: 1 addition & 0 deletions packages/adapter-linear/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ function createMockChatInstance(
handleIncomingMessage: vi.fn().mockResolvedValue(undefined),
processReaction: vi.fn(),
processAction: vi.fn(),
processOptionsLoad: vi.fn().mockResolvedValue(undefined),
processModalSubmit: vi.fn().mockResolvedValue(undefined),
processModalClose: vi.fn(),
processSlashCommand: vi.fn(),
Expand Down
144 changes: 144 additions & 0 deletions packages/adapter-slack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,95 @@ describe("handleWebhook - interactive payloads", () => {
expect(response.status).toBe(200);
});

it("handles block_suggestion payloads and returns options JSON", async () => {
const state = createMockState();
const chatInstance = createMockChatInstance(state);
(
chatInstance.processOptionsLoad as ReturnType<typeof vi.fn>
).mockResolvedValue([{ label: "Maria Garcia", value: "person_123" }]);
await adapter.initialize(chatInstance);

const payload = JSON.stringify({
type: "block_suggestion",
team: { id: "T123" },
user: {
id: "U123",
username: "testuser",
name: "Test User",
},
action_id: "person_select",
block_id: "person_block",
value: "mar",
});
const body = `payload=${encodeURIComponent(payload)}`;
const request = createWebhookRequest(body, secret, {
contentType: "application/x-www-form-urlencoded",
});

const response = await adapter.handleWebhook(request);

expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(chatInstance.processOptionsLoad).toHaveBeenCalledWith(
expect.objectContaining({
actionId: "person_select",
query: "mar",
user: expect.objectContaining({ userId: "U123" }),
}),
undefined
);
await expect(response.json()).resolves.toEqual({
options: [
{
text: { type: "plain_text", text: "Maria Garcia" },
value: "person_123",
},
],
});
});

it("returns empty options when block_suggestion handler exceeds 2.5s budget", async () => {
vi.useFakeTimers();
try {
const state = createMockState();
const chatInstance = createMockChatInstance(state);
(
chatInstance.processOptionsLoad as ReturnType<typeof vi.fn>
).mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(
() => resolve([{ label: "Too late", value: "late" }]),
5000
);
})
);
await adapter.initialize(chatInstance);

const payload = JSON.stringify({
type: "block_suggestion",
team: { id: "T123" },
user: { id: "U123", username: "testuser", name: "Test User" },
action_id: "person_select",
block_id: "person_block",
value: "mar",
});
const body = `payload=${encodeURIComponent(payload)}`;
const request = createWebhookRequest(body, secret, {
contentType: "application/x-www-form-urlencoded",
});

const responsePromise = adapter.handleWebhook(request);
await vi.advanceTimersByTimeAsync(2500);
const response = await responsePromise;

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ options: [] });
} finally {
vi.useRealTimers();
}
});

it("includes trigger_id in block_actions event", async () => {
const payload = JSON.stringify({
type: "block_actions",
Expand Down Expand Up @@ -1274,6 +1363,7 @@ function createMockChatInstance(state: StateAdapter): ChatInstance {
handleIncomingMessage: vi.fn().mockResolvedValue(undefined),
processReaction: vi.fn(),
processAction: vi.fn(),
processOptionsLoad: vi.fn().mockResolvedValue(undefined),
processModalSubmit: vi.fn().mockResolvedValue(undefined),
processModalClose: vi.fn(),
processSlashCommand: vi.fn(),
Expand Down Expand Up @@ -1431,6 +1521,60 @@ describe("multi-workspace mode", () => {
expect(response.status).toBe(200);
});

it("handleWebhook resolves token for block_suggestion payloads", async () => {
const state = createMockState();
const chatInstance = createMockChatInstance(state);
const adapter = createSlackAdapter({
signingSecret: secret,
logger: mockLogger,
});
await adapter.initialize(chatInstance);

await adapter.setInstallation("T_INTER_2", {
botToken: "xoxb-inter-token-2",
});

(
chatInstance.processOptionsLoad as ReturnType<typeof vi.fn>
).mockResolvedValue([{ label: "Maria Garcia", value: "person_123" }]);

const payload = JSON.stringify({
type: "block_suggestion",
team: { id: "T_INTER_2" },
user: {
id: "U123",
username: "testuser",
name: "Test User",
},
action_id: "person_select",
block_id: "person_block",
value: "mar",
});
const body = `payload=${encodeURIComponent(payload)}`;
const request = createWebhookRequest(body, secret, {
contentType: "application/x-www-form-urlencoded",
});

const response = await adapter.handleWebhook(request);

expect(response.status).toBe(200);
expect(chatInstance.processOptionsLoad).toHaveBeenCalledWith(
expect.objectContaining({
actionId: "person_select",
query: "mar",
}),
undefined
);
await expect(response.json()).resolves.toEqual({
options: [
{
text: { type: "plain_text", text: "Maria Garcia" },
value: "person_123",
},
],
});
});

it("URL verification works without token", async () => {
const adapter = createSlackAdapter({
signingSecret: secret,
Expand Down
Loading