diff --git a/packages/types/src/block-kit/block-elements.ts b/packages/types/src/block-kit/block-elements.ts index 806561d7d..9525ab752 100644 --- a/packages/types/src/block-kit/block-elements.ts +++ b/packages/types/src/block-kit/block-elements.ts @@ -702,6 +702,33 @@ export interface URLInput extends Actionable, Dispatchable, Focusable, Placehold initial_value?: string; } +/** + * @description A URL source element to reference in a task card block. + * @see {@link https://docs.slack.dev/reference/block-kit/block-elements/url-source-element} + */ + +export interface URLSourceElement { + /** + * @description The type of element. In this case `type` is always `url`. + */ + type: 'url'; + + /** + * @description The URL type source. + */ + url: string; + + /** + * @description Display text for the URL. + */ + text: string; + + /** + * @description Icon URL to display with the source. + */ + icon_url?: string[]; +} + /** * @description Allows users to run a {@link https://docs.slack.dev/tools/deno-slack-sdk/guides/creating-link-triggers/#workflow_buttons link trigger} with customizable inputs. * @see {@link https://docs.slack.dev/reference/block-kit/block-elements/workflow-button-element Workflow button element reference}. diff --git a/packages/types/src/block-kit/blocks.ts b/packages/types/src/block-kit/blocks.ts index 17d710acb..99b464683 100644 --- a/packages/types/src/block-kit/blocks.ts +++ b/packages/types/src/block-kit/blocks.ts @@ -22,6 +22,7 @@ import type { Select, Timepicker, URLInput, + URLSourceElement, WorkflowButton, } from './block-elements'; import type { @@ -64,6 +65,8 @@ export type KnownBlock = | RichTextBlock | SectionBlock | TableBlock + | TaskCardBlock + | PlanUpdateBlock | VideoBlock; /** @@ -402,6 +405,73 @@ interface TableBlockColumnSettings { is_wrapped?: boolean; } +/** + * @description A discrete action or tool call. + * @see https://docs.slack.dev/reference/block-kit/blocks/task-card-block/ +*/ +export interface TaskCardBlock extends Block { + /** + * @description The type of element. In this case `type` is always `task_card`. + */ + type: 'task_card'; + + /** + * @description ID for the task. + */ + task_id: string; + + /** + * @description Title of the task in plain text. + */ + title: string; + + /** + * @description Details of the task in the form of a single `rich_text` block. + */ + details?: RichTextBlock | Record; + + /** + * @description Output of the task in the form of a single "rich_text" entity. + */ + output?: RichTextBlock | Record; + + /** + * @description List of sources used to generate a response. + */ + sources?: (URLSourceElement | Record)[] + + /** + * @description The state of a task. Either `pending`, `in_progress`, `complete`, or `error`. + */ + status: 'pending' | 'in_progress' | 'complete' | 'error'; +} + +/** + * @description A collection of related tasks. + * @see https://docs.slack.dev/reference/block-kit/blocks/plan-block/ + */ +export interface PlanUpdateBlock extends Block { + /** + * @description The type of block. In this case `type` is always `plan`. + */ + type: 'plan'; + + /** + * @description ID for the plan. + */ + plan_id: string; + + /** + * @description Title of the plan in plain text. + */ + title: string; + + /** + * @description An array of tasks associated with this plan. + */ + tasks?: (TaskCardBlock | Record)[]; +} + /** * @description Displays an embedded video player. A video block is designed to embed videos in all app surfaces (e.g. * link unfurls, messages, modals, App Home) — anywhere you can put blocks! To use the video block within your app, you diff --git a/packages/types/src/chunk.ts b/packages/types/src/chunk.ts new file mode 100644 index 000000000..a60007a33 --- /dev/null +++ b/packages/types/src/chunk.ts @@ -0,0 +1,97 @@ +import { URLSourceElement } from "./block-kit/block-elements"; +/** + * Base interface for streaming message chunks. + * https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + */ +export interface Chunk { + type: string; +} + +/** + * Used for streaming text content with markdown formatting support. + * https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + */ +export interface MarkdownTextChunk extends Chunk { + type: 'markdown_text'; + text: string; +} + +/** + * Used for updating the title of a plan. + * https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + */ +export interface PlanUpdateChunk extends Chunk { + type: 'plan_update'; + title: string; +} + +/** + * Used for displaying tool execution progress in a timeline-style UI. + * https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + */ +export interface TaskUpdateChunk extends Chunk { + type: 'task_update'; + id: string; + title: string; + status: 'pending' | 'in_progress' | 'complete' | 'error'; + details?: string; + output?: string; + sources?: URLSourceElement[]; +} + +/** + * Union type of all possible chunk types + */ +export type AnyChunk = MarkdownTextChunk | PlanUpdateChunk | TaskUpdateChunk; + +/** + * Parse a chunk object and return the appropriate typed chunk. + * Returns null if the chunk is invalid or unknown. + */ +export function parseChunk(chunk: unknown): AnyChunk | null { + if (!chunk || typeof chunk !== 'object') { + return null; + } + + const chunkObj = chunk as Record; + + if (!('type' in chunkObj) || typeof chunkObj.type !== 'string') { + console.warn('Unknown chunk detected and skipped (missing type)', chunk); + return null; + } + + const { type } = chunkObj; + + if (type === 'markdown_text') { + if (typeof chunkObj.text === 'string') { + return chunkObj as unknown as MarkdownTextChunk; + } + console.warn('Invalid MarkdownTextChunk (missing text property)', chunk); + return null; + } + + if(type === 'plan_update') { + if (typeof chunkObj.title === 'string') { + return chunkObj as unknown as PlanUpdateChunk; + } + console.warn('Invalid PlanUpdateChunk (missing title property)', chunk); + return null; + } + + if (type === 'task_update') { + const taskChunk = chunkObj as Partial; + if ( + typeof taskChunk.id === 'string' && + typeof taskChunk.title === 'string' && + typeof taskChunk.status === 'string' && + ['pending', 'in_progress', 'complete', 'error'].includes(taskChunk.status) + ) { + return chunkObj as unknown as TaskUpdateChunk; + } + console.warn('Invalid TaskUpdateChunk (missing required properties)', chunk); + return null; + } + + console.warn(`Unknown chunk type detected and skipped: ${type}`, chunk); + return null; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9992536de..2740a2290 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -3,6 +3,7 @@ export * from './block-kit/blocks'; export * from './block-kit/composition-objects'; export * from './block-kit/extensions'; export * from './calls'; +export * from './chunk'; export * from './dialog'; export * from './events'; export * from './message-attachments'; diff --git a/packages/web-api/package.json b/packages/web-api/package.json index 36ffc23bf..c41da4903 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@slack/logger": "^4.0.0", - "@slack/types": "^2.18.0", + "@slack/types": "^2.19.0", "@types/node": ">=18.0.0", "@types/retry": "0.12.0", "axios": "^1.11.0", diff --git a/packages/web-api/src/chat-stream.ts b/packages/web-api/src/chat-stream.ts index 695100b14..4db44b6a6 100644 --- a/packages/web-api/src/chat-stream.ts +++ b/packages/web-api/src/chat-stream.ts @@ -89,7 +89,14 @@ export class ChatStreamer { if (args.token) { this.token = args.token; } - this.buffer += args.markdown_text; + + if (args.chunks) { + return await this.flushBuffer(args); + } + + if (args.markdown_text) { + this.buffer += args.markdown_text; + } if (this.buffer.length >= this.options.buffer_size) { return await this.flushBuffer(args); } @@ -144,12 +151,13 @@ export class ChatStreamer { this.streamTs = response.ts; this.state = 'in_progress'; } + const finalArgs = args?.chunks ? args : { ...args, markdown_text: this.buffer }; + const response = await this.client.chat.stopStream({ token: this.token, channel: this.streamArgs.channel, ts: this.streamTs, - ...args, - markdown_text: this.buffer, + ...finalArgs, }); this.state = 'completed'; return response; @@ -158,12 +166,14 @@ export class ChatStreamer { private async flushBuffer( args: Omit, ): Promise { + + const finalArgs = args.chunks ? args : { ...args, markdown_text: this.buffer }; + if (!this.streamTs) { const response = await this.client.chat.startStream({ ...this.streamArgs, token: this.token, - ...args, - markdown_text: this.buffer, + ...finalArgs, }); this.buffer = ''; this.streamTs = response.ts; @@ -174,8 +184,7 @@ export class ChatStreamer { token: this.token, channel: this.streamArgs.channel, ts: this.streamTs, - ...args, - markdown_text: this.buffer, + ...finalArgs, }); this.buffer = ''; return response; diff --git a/packages/web-api/src/types/request/chat.ts b/packages/web-api/src/types/request/chat.ts index 7734d7a7f..9c5c2292c 100644 --- a/packages/web-api/src/types/request/chat.ts +++ b/packages/web-api/src/types/request/chat.ts @@ -1,4 +1,5 @@ import type { + AnyChunk, Block, // TODO: these will be combined into one in a new types release EntityMetadata, KnownBlock, @@ -168,7 +169,13 @@ export interface Unfurls { unfurl_media?: boolean; } -export interface ChatAppendStreamArguments extends TokenOverridable, ChannelAndTS, MarkdownText {} +export interface ChatAppendStreamArguments extends TokenOverridable, ChannelAndTS, Partial { + /** + * @description An array of {@link https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming chunk objects} to append to the stream. + * Either `markdown_text` or `chunks` is required. + */ + chunks?: AnyChunk[]; +} // https://docs.slack.dev/reference/methods/chat.delete export interface ChatDeleteArguments extends ChannelAndTS, AsUser, TokenOverridable {} @@ -233,6 +240,11 @@ export type ChatScheduledMessagesListArguments = OptionalArgument< >; export interface ChatStartStreamArguments extends TokenOverridable, Channel, Partial, ThreadTS { + /** + * @description An array of {@link https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming chunk objects} to start the stream with. + * Either `markdown_text` or `chunks` is required. + */ + chunks?: AnyChunk[]; /** * @description The ID of the team that is associated with `recipient_user_id`. * This is required when starting a streaming conversation outside of a DM. @@ -249,6 +261,10 @@ export type ChatStopStreamArguments = TokenOverridable & ChannelAndTS & Partial & Partial & { + /** + * @description An array of {@link https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming chunk objects} to finalize the stream with. + */ + chunks?: AnyChunk[]; /** * Block formatted elements will be appended to the end of the message. */ diff --git a/packages/web-api/test/types/methods/chat.test-d.ts b/packages/web-api/test/types/methods/chat.test-d.ts index f1ec5f025..5755f951c 100644 --- a/packages/web-api/test/types/methods/chat.test-d.ts +++ b/packages/web-api/test/types/methods/chat.test-d.ts @@ -32,6 +32,30 @@ expectAssignable>([ markdown_text: 'hello', }, ]); +expectAssignable>([ + { + channel: 'C1234', + ts: '1234.56', + markdown_text: 'hello', + chunks: [ + { + type: 'markdown_text', + text: 'Hello world', + }, + { + type: 'plan_update', + title: 'Hello world', + }, + { + type: 'task_update', + id: 'task-1', + title: 'Processing request', + status: 'in_progress', + details: 'Working on it...', + }, + ], + }, +]); // chat.delete // -- sad path @@ -475,6 +499,131 @@ function wideBooleanTest(b: boolean) { wideBooleanTest(true); wideBooleanTest(false); +// TaskCardBlock with all properties +expectAssignable>([ + { + channel: 'C1234', + text: 'fallback', + blocks: [ + { + type: 'task_card', + task_id: '000', + title: 'Living life well...', + status: 'error', + details: { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'text', + text: 'Dreamt of touching grass', + }, + ], + }, + ], + }, + sources: [ + { + type: 'url', + text: 'An online encyclopedia', + url: 'https://wikipedia.org', + }, + ], + output: { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'text', + text: 'Good things once happened', + }, + ], + }, + ], + }, + }, + ], + }, +]); + +// TaskCardBlock with minimal required properties +expectAssignable>([ + { + channel: 'C1234', + text: 'fallback', + blocks: [ + { + type: 'task_card', + task_id: 'task-123', + title: 'Simple task', + status: 'pending', + }, + ], + }, +]); + +// PlanUpdateBlock with nested TaskCardBlocks +expectAssignable>([ + { + channel: 'C1234', + text: 'fallback', + blocks: [ + { + type: 'plan', + plan_id: 'plan-001', + title: 'My execution plan', + tasks: [ + { + type: 'task_card', + task_id: 'task-1', + title: 'First task', + status: 'complete', + }, + { + type: 'task_card', + task_id: 'task-2', + title: 'Second task', + status: 'in_progress', + details: { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'text', + text: 'Working on this...', + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, +]); + +// PlanUpdateBlock with minimal required properties +expectAssignable>([ + { + channel: 'C1234', + text: 'fallback', + blocks: [ + { + type: 'plan', + plan_id: 'plan-minimal', + title: 'Empty plan', + }, + ], + }, +]); + // chat.scheduleMessage // -- sad path expectError(web.chat.scheduleMessage()); // lacking argument @@ -635,7 +784,46 @@ expectAssignable>([ { channel: 'C1234', thread_ts: '1234.56', - markdown_text: 'hello', + chunks: [ + { + type: 'markdown_text', + text: 'Hello world', + }, + { + type: 'plan_update', + title: 'Hello world', + }, + { + type: 'task_update', + id: 'task-1', + title: 'Processing request', + status: 'in_progress', + details: 'Working on it...', + }, + ], + }, +]); +expectAssignable>([ + { + channel: 'C1234', + thread_ts: '1234.56', + chunks: [ + { + type: 'markdown_text', + text: 'Hello world', + }, + { + type: 'plan_update', + title: 'Hello world', + }, + { + type: 'task_update', + id: 'task-1', + title: 'Processing request', + status: 'in_progress', + details: 'Working on it...', + }, + ], recipient_team_id: 'T1234', recipient_user_id: 'U1234', }, @@ -670,6 +858,30 @@ expectAssignable>([ blocks: [], }, ]); +expectAssignable>([ + { + channel: 'C1234', + ts: '1234.56', + chunks: [ + { + type: 'markdown_text', + text: 'Hello world', + }, + { + type: 'plan_update', + title: 'Hello world', + }, + { + type: 'task_update', + id: 'task-1', + title: 'Processing request', + status: 'in_progress', + details: 'Working on it...', + }, + ], + blocks: [], + }, +]); // chat.unfurl // -- sad path