diff --git a/.changeset/twelve-candies-mix.md b/.changeset/twelve-candies-mix.md new file mode 100644 index 00000000..e2809936 --- /dev/null +++ b/.changeset/twelve-candies-mix.md @@ -0,0 +1,6 @@ +--- +"@chat-adapter/slack": minor +"chat": minor +--- + +Implement external_select block kit for Slack diff --git a/apps/docs/content/docs/modals.mdx b/apps/docs/content/docs/modals.mdx index bc8da463..a3dbfc89 100644 --- a/apps/docs/content/docs/modals.mdx +++ b/apps/docs/content/docs/modals.mdx @@ -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 ``. + + +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). + + + +**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. + + ### RadioSelect A radio button group for mutually exclusive options. diff --git a/packages/adapter-discord/src/gateway.test.ts b/packages/adapter-discord/src/gateway.test.ts index 65ee7dc2..6f872227 100644 --- a/packages/adapter-discord/src/gateway.test.ts +++ b/packages/adapter-discord/src/gateway.test.ts @@ -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(), diff --git a/packages/adapter-discord/src/index.test.ts b/packages/adapter-discord/src/index.test.ts index 1ccc5f5c..df85d694 100644 --- a/packages/adapter-discord/src/index.test.ts +++ b/packages/adapter-discord/src/index.test.ts @@ -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); @@ -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); @@ -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); diff --git a/packages/adapter-gchat/src/index.test.ts b/packages/adapter-gchat/src/index.test.ts index 81c2389f..29d7f234 100644 --- a/packages/adapter-gchat/src/index.test.ts +++ b/packages/adapter-gchat/src/index.test.ts @@ -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; } diff --git a/packages/adapter-linear/src/index.test.ts b/packages/adapter-linear/src/index.test.ts index 4e6cda8e..67e1b6d4 100644 --- a/packages/adapter-linear/src/index.test.ts +++ b/packages/adapter-linear/src/index.test.ts @@ -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(), diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index f43e1504..98ea4d77 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -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 + ).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 + ).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", @@ -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(), @@ -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 + ).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, diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index ba02ba91..10f6b596 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -37,6 +37,7 @@ import type { ReactionEvent, Root, ScheduledMessage, + SelectOptionElement, StreamChunk, StreamOptions, ThreadInfo, @@ -69,11 +70,16 @@ import { encodeModalMetadata, modalToSlackView, type SlackModalResponse, + selectOptionToSlackOption, } from "./modals"; const SLACK_USER_ID_PATTERN = /^[A-Z0-9_]+$/; const SLACK_USER_ID_EXACT_PATTERN = /^U[A-Z0-9]+$/; +// Slack expects block_suggestion responses within 3s. Leave headroom for +// network latency so the HTTP response lands before Slack gives up. +const OPTIONS_LOAD_TIMEOUT_MS = 2500; + /** Find the next `<@` or `<#` mention in text. */ function findNextMention(text: string): number { const atIdx = text.indexOf("<@"); @@ -373,8 +379,24 @@ interface SlackViewClosedPayload { }; } +interface SlackBlockSuggestionPayload { + action_id: string; + block_id: string; + team?: { + id: string; + }; + type: "block_suggestion"; + user: { + id: string; + username?: string; + name?: string; + }; + value?: string; +} + type SlackInteractivePayload = | SlackBlockActionsPayload + | SlackBlockSuggestionPayload | SlackViewSubmissionPayload | SlackViewClosedPayload; @@ -1080,6 +1102,9 @@ export class SlackAdapter implements Adapter { this.handleBlockActions(payload, options); return new Response("", { status: 200 }); + case "block_suggestion": + return this.handleBlockSuggestion(payload, options); + case "view_submission": return this.handleViewSubmission(payload, options); @@ -1212,6 +1237,79 @@ export class SlackAdapter implements Adapter { } } + private async handleBlockSuggestion( + payload: SlackBlockSuggestionPayload, + options?: WebhookOptions + ): Promise { + if (!this.chat) { + this.logger.warn( + "Chat instance not initialized, ignoring block suggestion" + ); + return this.optionsLoadResponse([]); + } + + const loadPromise = this.chat.processOptionsLoad( + { + actionId: payload.action_id, + query: payload.value ?? "", + user: { + userId: payload.user.id, + userName: + payload.user.username || payload.user.name || payload.user.id, + fullName: + payload.user.name || payload.user.username || payload.user.id, + isBot: false, + isMe: false, + }, + adapter: this as Adapter, + raw: payload, + }, + options + ); + + // Slack requires a response within 3s for block_suggestion and does not + // support an async ack pattern — options must be in the response body. + // Race the handler against a budget and fall back to an empty 200 so the + // menu shows "No results" instead of hanging or erroring for the user. + const timeoutSentinel = Symbol("options_load_timeout"); + let timer: ReturnType | undefined; + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout( + () => resolve(timeoutSentinel), + OPTIONS_LOAD_TIMEOUT_MS + ); + }); + + const result = await Promise.race([loadPromise, timeoutPromise]); + if (timer) { + clearTimeout(timer); + } + + if (result === timeoutSentinel) { + this.logger.warn("Options load handler timed out", { + actionId: payload.action_id, + timeoutMs: OPTIONS_LOAD_TIMEOUT_MS, + }); + loadPromise.catch((err) => + this.logger.error("Options load handler error after timeout", { + error: err, + actionId: payload.action_id, + }) + ); + return this.optionsLoadResponse([]); + } + + return this.optionsLoadResponse(result ?? []); + } + + private optionsLoadResponse(options: SelectOptionElement[]): Response { + const slackOptions = options.slice(0, 100).map(selectOptionToSlackOption); + return new Response(JSON.stringify({ options: slackOptions }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + private async handleViewSubmission( payload: SlackViewSubmissionPayload, options?: WebhookOptions diff --git a/packages/adapter-slack/src/modals.test.ts b/packages/adapter-slack/src/modals.test.ts index ed1a074c..0d130858 100644 --- a/packages/adapter-slack/src/modals.test.ts +++ b/packages/adapter-slack/src/modals.test.ts @@ -1,4 +1,11 @@ -import { Modal, RadioSelect, Select, SelectOption, TextInput } from "chat"; +import { + ExternalSelect, + Modal, + RadioSelect, + Select, + SelectOption, + TextInput, +} from "chat"; import { describe, expect, it } from "vitest"; import { decodeModalMetadata, @@ -216,6 +223,35 @@ describe("modalToSlackView", () => { }); }); + it("converts external select with placeholder and min query length", () => { + const modal = Modal({ + callbackId: "test", + title: "Test", + children: [ + ExternalSelect({ + id: "person", + label: "Person", + placeholder: "Search people", + minQueryLength: 1, + }), + ], + }); + + const view = modalToSlackView(modal); + + expect(view.blocks[0]).toMatchObject({ + type: "input", + block_id: "person", + label: { type: "plain_text", text: "Person" }, + element: { + type: "external_select", + action_id: "person", + placeholder: { type: "plain_text", text: "Search people" }, + min_query_length: 1, + }, + }); + }); + it("includes contextId as private_metadata when provided", () => { const modal = Modal({ callbackId: "test", diff --git a/packages/adapter-slack/src/modals.ts b/packages/adapter-slack/src/modals.ts index b563072e..7645f6e3 100644 --- a/packages/adapter-slack/src/modals.ts +++ b/packages/adapter-slack/src/modals.ts @@ -4,10 +4,12 @@ */ import type { + ExternalSelectElement, ModalChild, ModalElement, RadioSelectElement, SelectElement, + SelectOptionElement, TextInputElement, } from "chat"; import { @@ -33,6 +35,12 @@ export interface SlackModalResponse { view?: SlackView; } +export interface SlackOptionObject { + description?: { type: "plain_text"; text: string }; + text: { type: "plain_text"; text: string }; + value: string; +} + // ============================================================================ // Private metadata encoding // ============================================================================ @@ -109,6 +117,8 @@ function modalChildToBlock(child: ModalChild): SlackBlock { return textInputToBlock(child); case "select": return selectToBlock(child); + case "external_select": + return externalSelectToBlock(child); case "radio_select": return radioSelectToBlock(child); case "text": @@ -122,6 +132,18 @@ function modalChildToBlock(child: ModalChild): SlackBlock { } } +export function selectOptionToSlackOption( + option: SelectOptionElement +): SlackOptionObject { + return { + text: { type: "plain_text", text: option.label }, + value: option.value, + ...(option.description + ? { description: { type: "plain_text", text: option.description } } + : {}), + }; +} + function textInputToBlock(input: TextInputElement): SlackBlock { const element: Record = { type: "plain_text_input", @@ -149,16 +171,7 @@ function textInputToBlock(input: TextInputElement): SlackBlock { } function selectToBlock(select: SelectElement): SlackBlock { - const options = select.options.map((opt) => { - const option: Record = { - text: { type: "plain_text" as const, text: opt.label }, - value: opt.value, - }; - if (opt.description) { - option.description = { type: "plain_text", text: opt.description }; - } - return option; - }); + const options = select.options.map(selectOptionToSlackOption); const element: Record = { type: "static_select", @@ -188,6 +201,29 @@ function selectToBlock(select: SelectElement): SlackBlock { }; } +function externalSelectToBlock(select: ExternalSelectElement): SlackBlock { + const element: Record = { + type: "external_select", + action_id: select.id, + }; + + if (select.placeholder) { + element.placeholder = { type: "plain_text", text: select.placeholder }; + } + + if (select.minQueryLength !== undefined) { + element.min_query_length = select.minQueryLength; + } + + return { + type: "input", + block_id: select.id, + optional: select.optional ?? false, + label: { type: "plain_text", text: select.label }, + element, + }; +} + function radioSelectToBlock(radioSelect: RadioSelectElement): SlackBlock { const limitedOptions = radioSelect.options.slice(0, 10); const options = limitedOptions.map((opt) => { diff --git a/packages/adapter-teams/src/index.test.ts b/packages/adapter-teams/src/index.test.ts index 177ddfea..120f3fae 100644 --- a/packages/adapter-teams/src/index.test.ts +++ b/packages/adapter-teams/src/index.test.ts @@ -32,7 +32,9 @@ const logger = new ConsoleLogger("error"); describe("ESM compatibility", () => { it( "all subpath imports resolve in Node.js ESM (no bare directory imports)", - { timeout: 30_000 }, + { + timeout: 30_000, + }, () => { const source = readFileSync( resolve(import.meta.dirname, "index.ts"), @@ -790,6 +792,7 @@ describe("TeamsAdapter", () => { getState: vi.fn(), processMessage: vi.fn(), processAction: vi.fn(), + processOptionsLoad: vi.fn().mockResolvedValue(undefined), processReaction: vi.fn(), }; @@ -1008,6 +1011,7 @@ describe("TeamsAdapter", () => { getState: () => mockState, processMessage: vi.fn(), processAction: vi.fn(), + processOptionsLoad: vi.fn().mockResolvedValue(undefined), processReaction: vi.fn(), }; diff --git a/packages/adapter-teams/src/modals.ts b/packages/adapter-teams/src/modals.ts index 8eed0800..aee9fe65 100644 --- a/packages/adapter-teams/src/modals.ts +++ b/packages/adapter-teams/src/modals.ts @@ -96,6 +96,8 @@ function modalChildToAdaptiveElements(child: ModalChild): CardElementArray { return [textInputToAdaptive(child)]; case "select": return [selectToAdaptive(child)]; + case "external_select": + throw new Error("ExternalSelect is only supported by the Slack adapter"); case "radio_select": return [radioSelectToAdaptive(child)]; case "text": diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index f7770323..48503429 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -97,6 +97,7 @@ function createMockChat(options?: { userName?: unknown }): ChatInstance { processMessage: vi.fn(), processReaction: vi.fn(), processAction: vi.fn(), + processOptionsLoad: vi.fn().mockResolvedValue(undefined), processModalClose: vi.fn(), processModalSubmit: vi.fn().mockResolvedValue(undefined), processSlashCommand: vi.fn(), diff --git a/packages/adapter-whatsapp/src/index.test.ts b/packages/adapter-whatsapp/src/index.test.ts index 7ba7cf86..5f5c3b83 100644 --- a/packages/adapter-whatsapp/src/index.test.ts +++ b/packages/adapter-whatsapp/src/index.test.ts @@ -654,6 +654,7 @@ const mockChat = { processMessage: vi.fn(), processReaction: vi.fn(), processAction: vi.fn(), + processOptionsLoad: vi.fn().mockResolvedValue(undefined), processModalSubmit: vi.fn(), processModalClose: vi.fn(), processSlashCommand: vi.fn(), diff --git a/packages/chat/src/chat.test.ts b/packages/chat/src/chat.test.ts index e02dcfab..84ddd0a5 100644 --- a/packages/chat/src/chat.test.ts +++ b/packages/chat/src/chat.test.ts @@ -1423,6 +1423,121 @@ describe("Chat", () => { }); }); + describe("Options Load", () => { + it("should call onOptionsLoad handler for a matching action ID", async () => { + const handler = vi + .fn() + .mockResolvedValue([{ label: "Maria Garcia", value: "person_123" }]); + chat.onOptionsLoad("person_select", handler); + + const options = await chat.processOptionsLoad({ + actionId: "person_select", + query: "mar", + user: { + userId: "U123", + userName: "user", + fullName: "Test User", + isBot: false, + isMe: false, + }, + adapter: mockAdapter, + raw: {}, + }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + actionId: "person_select", + query: "mar", + }) + ); + expect(options).toEqual([{ label: "Maria Garcia", value: "person_123" }]); + }); + + it("should prefer specific handlers before catch-all handlers", async () => { + const catchAll = vi + .fn() + .mockResolvedValue([{ label: "Fallback", value: "fallback" }]); + const specific = vi + .fn() + .mockResolvedValue([{ label: "Specific", value: "specific" }]); + chat.onOptionsLoad(catchAll); + chat.onOptionsLoad("person_select", specific); + + const options = await chat.processOptionsLoad({ + actionId: "person_select", + query: "mar", + user: { + userId: "U123", + userName: "user", + fullName: "Test User", + isBot: false, + isMe: false, + }, + adapter: mockAdapter, + raw: {}, + }); + + expect(specific).toHaveBeenCalledTimes(1); + expect(catchAll).not.toHaveBeenCalled(); + expect(options).toEqual([{ label: "Specific", value: "specific" }]); + }); + + it("should fall back to catch-all handlers when no specific handler matches", async () => { + const catchAll = vi + .fn() + .mockResolvedValue([{ label: "Fallback", value: "fallback" }]); + chat.onOptionsLoad(catchAll); + + const options = await chat.processOptionsLoad({ + actionId: "unknown_select", + query: "test", + user: { + userId: "U123", + userName: "user", + fullName: "Test User", + isBot: false, + isMe: false, + }, + adapter: mockAdapter, + raw: {}, + }); + + expect(catchAll).toHaveBeenCalledTimes(1); + expect(options).toEqual([{ label: "Fallback", value: "fallback" }]); + }); + + it("should continue after handler errors", async () => { + const failingHandler = vi.fn().mockRejectedValue(new Error("boom")); + const fallbackHandler = vi + .fn() + .mockResolvedValue([{ label: "Recovered", value: "recovered" }]); + chat.onOptionsLoad("person_select", failingHandler); + chat.onOptionsLoad(fallbackHandler); + + const options = await chat.processOptionsLoad({ + actionId: "person_select", + query: "mar", + user: { + userId: "U123", + userName: "user", + fullName: "Test User", + isBot: false, + isMe: false, + }, + adapter: mockAdapter, + raw: {}, + }); + + expect(failingHandler).toHaveBeenCalledTimes(1); + expect(fallbackHandler).toHaveBeenCalledTimes(1); + expect(options).toEqual([{ label: "Recovered", value: "recovered" }]); + expect(mockLogger.error).toHaveBeenCalledWith( + "Options load handler error", + expect.objectContaining({ actionId: "person_select" }) + ); + }); + }); + describe("thread", () => { it("should return a Thread handle for a valid thread ID", () => { const thread = chat.thread("slack:C123:1234.5678"); diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index 5cac0b93..070f0f14 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -7,7 +7,7 @@ import { import { isJSX, toModalElement } from "./jsx-runtime"; import { Message, type SerializedMessage } from "./message"; import { MessageHistoryCache } from "./message-history"; -import type { ModalElement } from "./modals"; +import type { ModalElement, SelectOptionElement } from "./modals"; import { reviver as standaloneReviver } from "./reviver"; import { type SerializedThread, ThreadImpl } from "./thread"; import type { @@ -45,6 +45,8 @@ import type { ModalResponse, ModalSubmitEvent, ModalSubmitHandler, + OptionsLoadEvent, + OptionsLoadHandler, ReactionEvent, ReactionHandler, SentMessage, @@ -96,6 +98,12 @@ interface ActionPattern { handler: ActionHandler; } +interface OptionsLoadPattern { + /** If specified, only these action IDs trigger the handler. Empty means all selects. */ + actionIds: string[]; + handler: OptionsLoadHandler; +} + interface ModalSubmitPattern { callbackIds: string[]; handler: ModalSubmitHandler; @@ -215,6 +223,7 @@ export class Chat< []; private readonly reactionHandlers: ReactionPattern[] = []; private readonly actionHandlers: ActionPattern[] = []; + private readonly optionsLoadHandlers: OptionsLoadPattern[] = []; private readonly modalSubmitHandlers: ModalSubmitPattern[] = []; private readonly modalCloseHandlers: ModalClosePattern[] = []; private readonly slashCommandHandlers: SlashCommandPattern[] = []; @@ -594,6 +603,34 @@ export class Chat< } } + /** + * Register a handler for loading dynamic options for external selects. + * Specific action IDs run before catch-all handlers. + */ + onOptionsLoad(handler: OptionsLoadHandler): void; + onOptionsLoad( + actionIds: string[] | string, + handler: OptionsLoadHandler + ): void; + onOptionsLoad( + actionIdOrHandler: string | string[] | OptionsLoadHandler, + handler?: OptionsLoadHandler + ): void { + if (typeof actionIdOrHandler === "function") { + this.optionsLoadHandlers.push({ + actionIds: [], + handler: actionIdOrHandler, + }); + this.logger.debug("Registered options load handler for all action IDs"); + } else if (handler) { + const actionIds = Array.isArray(actionIdOrHandler) + ? actionIdOrHandler + : [actionIdOrHandler]; + this.optionsLoadHandlers.push({ actionIds, handler }); + this.logger.debug("Registered options load handler", { actionIds }); + } + } + /** * Register a handler for modal form submissions. * @@ -871,6 +908,35 @@ export class Chat< return task; } + async processOptionsLoad( + event: OptionsLoadEvent, + _options?: WebhookOptions + ): Promise { + const matchingHandlers = [ + ...this.optionsLoadHandlers.filter( + ({ actionIds }) => + actionIds.length > 0 && actionIds.includes(event.actionId) + ), + ...this.optionsLoadHandlers.filter( + ({ actionIds }) => actionIds.length === 0 + ), + ]; + + for (const { handler } of matchingHandlers) { + try { + const options = await handler(event); + if (options) { + return options; + } + } catch (err) { + this.logger.error("Options load handler error", { + error: err, + actionId: event.actionId, + }); + } + } + } + async processModalSubmit( event: Omit< ModalSubmitEvent, diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 1a51dbcd..1544a418 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -76,6 +76,7 @@ import type { CardComponent, CardLinkComponent, DividerComponent, + ExternalSelectComponent, FieldComponent, FieldsComponent, ImageComponent, @@ -117,6 +118,7 @@ export const toModalElement = _toModalElement; // Modal builders import { + ExternalSelect as _ExternalSelect, fromReactModalElement as _fromReactModalElement, isModalElement as _isModalElement, Modal as _Modal, @@ -127,6 +129,8 @@ import { } from "./modals"; export const fromReactModalElement = _fromReactModalElement; export const isModalElement = _isModalElement; +export const ExternalSelect = + _ExternalSelect as unknown as ExternalSelectComponent; export const Modal = _Modal as unknown as ModalComponent; export const RadioSelect = _RadioSelect as unknown as RadioSelectComponent; export const Select = _Select as unknown as SelectComponent; @@ -182,6 +186,8 @@ export type { ContainerProps, DividerComponent, DividerProps, + ExternalSelectComponent, + ExternalSelectProps, FieldComponent, FieldProps, FieldsComponent, @@ -268,6 +274,8 @@ export { } from "./markdown"; // Modal types export type { + ExternalSelectElement, + ExternalSelectOptions, ModalChild, ModalElement, ModalOptions, @@ -336,6 +344,8 @@ export type { ModalSubmitEvent, ModalSubmitHandler, ModalUpdateResponse, + OptionsLoadEvent, + OptionsLoadHandler, PlanUpdateChunk, Postable, PostableAst, diff --git a/packages/chat/src/jsx-runtime.test.ts b/packages/chat/src/jsx-runtime.test.ts index 19aaf71f..14a4dc80 100644 --- a/packages/chat/src/jsx-runtime.test.ts +++ b/packages/chat/src/jsx-runtime.test.ts @@ -38,6 +38,8 @@ import { toModalElement, } from "./jsx-runtime"; import { + ExternalSelect, + type ExternalSelectElement, Modal, type ModalElement, RadioSelect, @@ -753,6 +755,28 @@ describe("toModalElement", () => { "RadioSelect requires at least one option" ); }); + + it("converts ExternalSelect in a modal", () => { + const externalSelect = jsx(ExternalSelect, { + id: "person", + label: "Person", + placeholder: "Search people", + minQueryLength: 1, + }); + const modal = jsxs(Modal, { + callbackId: "test", + title: "Test", + children: [externalSelect], + }); + const modalElement = toModalElement(modal); + + expect(modalElement).not.toBeNull(); + const child = modalElement?.children[0]; + expect(child?.type).toBe("external_select"); + if (child?.type === "external_select") { + expect(child.minQueryLength).toBe(1); + } + }); }); // ============================================================================ @@ -967,4 +991,8 @@ describe("ChatElement type compatibility", () => { it("RadioSelectElement is assignable to ChatElement", () => { expectTypeOf().toMatchTypeOf(); }); + + it("ExternalSelectElement is assignable to ChatElement", () => { + expectTypeOf().toMatchTypeOf(); + }); }); diff --git a/packages/chat/src/jsx-runtime.ts b/packages/chat/src/jsx-runtime.ts index aa6a88da..3705304c 100644 --- a/packages/chat/src/jsx-runtime.ts +++ b/packages/chat/src/jsx-runtime.ts @@ -63,6 +63,9 @@ import { } from "./cards"; import { + ExternalSelect, + type ExternalSelectElement, + type ExternalSelectOptions, filterModalChildren, isModalElement, Modal, @@ -181,6 +184,15 @@ export interface SelectProps { placeholder?: string; } +/** Props for ExternalSelect component in JSX */ +export interface ExternalSelectProps { + id: string; + label: string; + minQueryLength?: number; + optional?: boolean; + placeholder?: string; +} + /** Props for SelectOption component in JSX */ export interface SelectOptionProps { description?: string; @@ -208,6 +220,7 @@ export type CardJSXProps = | ModalProps | TextInputProps | SelectProps + | ExternalSelectProps | SelectOptionProps | TableProps; @@ -227,6 +240,7 @@ type CardComponentFunction = | typeof Modal | typeof TextInput | typeof Select + | typeof ExternalSelect | typeof RadioSelect | typeof SelectOption | typeof Table; @@ -259,6 +273,7 @@ export type ChatElement = | ModalElement | TextInputElement | SelectElement + | ExternalSelectElement | SelectOptionElement | RadioSelectElement | TableElement; @@ -344,6 +359,11 @@ export interface SelectComponent { (props: SelectProps): ChatElement; } +export interface ExternalSelectComponent { + (options: ExternalSelectOptions): ExternalSelectElement; + (props: ExternalSelectProps): ChatElement; +} + export interface SelectOptionComponent { (options: { label: string; @@ -385,6 +405,7 @@ type CardChildOrNested = | LinkElement | FieldElement | SelectElement + | ExternalSelectElement | SelectOptionElement | RadioSelectElement; @@ -432,6 +453,7 @@ type AnyCardElement = | FieldElement | ModalElement | ModalChild + | ExternalSelectElement | SelectOptionElement | null; @@ -524,6 +546,20 @@ function isSelectProps(props: CardJSXProps): props is SelectProps { return "id" in props && "label" in props && !("value" in props); } +/** + * Type guard to check if props match ExternalSelectProps + */ +function isExternalSelectProps( + props: CardJSXProps +): props is ExternalSelectProps { + return ( + "id" in props && + "label" in props && + !("value" in props) && + !("children" in props) + ); +} + /** * Type guard to check if props match SelectOptionProps */ @@ -698,6 +734,19 @@ function resolveJSXElement(element: JSXElement): AnyCardElement { }); } + if (type === ExternalSelect) { + if (!isExternalSelectProps(props)) { + throw new Error("ExternalSelect requires 'id' and 'label' props"); + } + return ExternalSelect({ + id: props.id, + label: props.label, + placeholder: props.placeholder, + minQueryLength: props.minQueryLength, + optional: props.optional, + }); + } + if (type === RadioSelect) { if (!isSelectProps(props)) { throw new Error("RadioSelect requires 'id' and 'label' props"); diff --git a/packages/chat/src/modals.test.ts b/packages/chat/src/modals.test.ts index 872da9ba..dd337466 100644 --- a/packages/chat/src/modals.test.ts +++ b/packages/chat/src/modals.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + ExternalSelect, filterModalChildren, fromReactModalElement, isModalElement, @@ -105,6 +106,28 @@ describe("Builder Functions", () => { }); }); + describe("ExternalSelect", () => { + it("should create with required fields", () => { + const externalSelect = ExternalSelect({ id: "person", label: "Person" }); + expect(externalSelect.type).toBe("external_select"); + expect(externalSelect.id).toBe("person"); + expect(externalSelect.label).toBe("Person"); + }); + + it("should include optional fields", () => { + const externalSelect = ExternalSelect({ + id: "person", + label: "Person", + placeholder: "Search people", + minQueryLength: 1, + optional: true, + }); + expect(externalSelect.placeholder).toBe("Search people"); + expect(externalSelect.minQueryLength).toBe(1); + expect(externalSelect.optional).toBe(true); + }); + }); + describe("SelectOption", () => { it("should create with label and value", () => { const opt = SelectOption({ label: "Option A", value: "a" }); @@ -160,6 +183,7 @@ describe("Type Guards", () => { it("should keep valid child types", () => { const children = [ TextInput({ id: "t1", label: "Name" }), + ExternalSelect({ id: "person", label: "Person" }), Select({ id: "s1", label: "Pick", @@ -167,7 +191,7 @@ describe("Type Guards", () => { }), ]; const result = filterModalChildren(children); - expect(result).toHaveLength(2); + expect(result).toHaveLength(3); }); it("should filter invalid children and warn", () => { @@ -241,6 +265,20 @@ describe("JSX Support", () => { } }); + it("should convert an ExternalSelect react element", () => { + const el = makeReactElement(ExternalSelect, { + id: "person", + label: "Person", + placeholder: "Search people", + minQueryLength: 1, + }); + const result = fromReactModalElement(el); + expect(result).not.toBeNull(); + if (result && "type" in result) { + expect(result.type).toBe("external_select"); + } + }); + it("should convert a RadioSelect react element", () => { const optEl = makeReactElement(SelectOption, { label: "X", diff --git a/packages/chat/src/modals.ts b/packages/chat/src/modals.ts index 8ffdf73b..4668e9ad 100644 --- a/packages/chat/src/modals.ts +++ b/packages/chat/src/modals.ts @@ -11,6 +11,7 @@ import type { FieldsElement, TextElement } from "./cards"; export const VALID_MODAL_CHILD_TYPES = [ "text_input", "select", + "external_select", "radio_select", "text", "fields", @@ -19,6 +20,7 @@ export const VALID_MODAL_CHILD_TYPES = [ export type ModalChild = | TextInputElement | SelectElement + | ExternalSelectElement | RadioSelectElement | TextElement | FieldsElement; @@ -56,6 +58,15 @@ export interface SelectElement { type: "select"; } +export interface ExternalSelectElement { + id: string; + label: string; + minQueryLength?: number; + optional?: boolean; + placeholder?: string; + type: "external_select"; +} + export interface SelectOptionElement { description?: string; label: string; @@ -173,6 +184,27 @@ export function Select(options: SelectOptions): SelectElement { }; } +export interface ExternalSelectOptions { + id: string; + label: string; + minQueryLength?: number; + optional?: boolean; + placeholder?: string; +} + +export function ExternalSelect( + options: ExternalSelectOptions +): ExternalSelectElement { + return { + type: "external_select", + id: options.id, + label: options.label, + placeholder: options.placeholder, + minQueryLength: options.minQueryLength, + optional: options.optional, + }; +} + export function SelectOption(options: { label: string; value: string; @@ -238,6 +270,7 @@ const modalComponentMap = new Map([ [Modal, "Modal"], [TextInput, "TextInput"], [Select, "Select"], + [ExternalSelect, "ExternalSelect"], [RadioSelect, "RadioSelect"], [SelectOption, "SelectOption"], ]); @@ -305,6 +338,15 @@ export function fromReactModalElement( optional: props.optional as boolean | undefined, }); + case "ExternalSelect": + return ExternalSelect({ + id: props.id as string, + label: props.label as string, + placeholder: props.placeholder as string | undefined, + minQueryLength: props.minQueryLength as number | undefined, + optional: props.optional as boolean | undefined, + }); + case "RadioSelect": return RadioSelect({ id: props.id as string, diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index dd4bcf0c..0b29273d 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -8,7 +8,7 @@ import type { SerializedChannel } from "./channel"; import type { ChatElement } from "./jsx-runtime"; import type { Logger, LogLevel } from "./logger"; import type { Message } from "./message"; -import type { ModalElement } from "./modals"; +import type { ModalElement, SelectOptionElement } from "./modals"; import type { PostableObject } from "./postable-object"; import type { SerializedThread } from "./thread"; @@ -648,6 +648,15 @@ export interface ChatInstance { options?: WebhookOptions ): Promise; + /** + * Process an interactive options load event from an adapter. + * Returns normalized select options for the adapter to render. + */ + processOptionsLoad( + event: OptionsLoadEvent, + options?: WebhookOptions + ): Promise; + /** * Process an incoming reaction event from an adapter. * Handles waitUntil registration and error catching internally. @@ -1977,6 +1986,33 @@ export interface ActionEvent { */ export type ActionHandler = (event: ActionEvent) => void | Promise; +// ============================================================================= +// Options Load Events +// ============================================================================= + +/** + * Event emitted when an adapter needs dynamic options for an external select. + */ +export interface OptionsLoadEvent { + /** The action ID of the select requesting options */ + actionId: string; + /** The adapter that received this event */ + adapter: Adapter; + /** The current user-entered query text */ + query: string; + /** Raw platform-specific payload */ + raw: unknown; + /** The user requesting options */ + user: Author; +} + +export type OptionsLoadHandler = ( + event: OptionsLoadEvent +) => + | SelectOptionElement[] + | Promise + | undefined; + // ============================================================================= // Modal Events (Form Submissions) // =============================================================================