Files
Moltbot/src/slack/streaming.ts
nathandenherder 6945fbf100 feat(slack): add native text streaming support
Adds support for Slack's Agents & AI Apps text streaming APIs
(chat.startStream, chat.appendStream, chat.stopStream) to deliver
LLM responses as a single updating message instead of separate
messages per block.

Changes:
- New src/slack/streaming.ts with stream lifecycle helpers using
  the SDK's ChatStreamer (client.chatStream())
- New 'streaming' config option on SlackAccountConfig
- Updated dispatch.ts to route block replies through the stream
  when enabled, with graceful fallback to normal delivery
- Docs in docs/channels/slack.md covering setup and requirements

The streaming integration works by intercepting the deliver callback
in the reply dispatcher. When streaming is enabled and a thread
context exists, the first text delivery starts a stream, subsequent
deliveries append to it, and the stream is finalized after dispatch
completes. Media payloads and error cases fall back to normal
message delivery.

Refs:
- https://docs.slack.dev/ai/developing-ai-apps#streaming
- https://docs.slack.dev/reference/methods/chat.startStream
- https://docs.slack.dev/reference/methods/chat.appendStream
- https://docs.slack.dev/reference/methods/chat.stopStream
2026-02-07 15:03:12 -05:00

137 lines
3.8 KiB
TypeScript

/**
* Slack native text streaming helpers.
*
* Uses the Slack SDK's `ChatStreamer` (via `client.chatStream()`) to stream
* text responses word-by-word in a single updating message, matching Slack's
* "Agents & AI Apps" streaming UX.
*
* @see https://docs.slack.dev/ai/developing-ai-apps#streaming
* @see https://docs.slack.dev/reference/methods/chat.startStream
* @see https://docs.slack.dev/reference/methods/chat.appendStream
* @see https://docs.slack.dev/reference/methods/chat.stopStream
*/
import type { ChatStreamer, WebClient } from "@slack/web-api";
import { logVerbose } from "../globals.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SlackStreamSession = {
/** The SDK ChatStreamer instance managing this stream. */
streamer: ChatStreamer;
/** Channel this stream lives in. */
channel: string;
/** Thread timestamp (required for streaming). */
threadTs: string;
/** True once stop() has been called. */
stopped: boolean;
};
export type StartSlackStreamParams = {
client: WebClient;
channel: string;
threadTs: string;
/** Optional initial markdown text to include in the stream start. */
text?: string;
};
export type AppendSlackStreamParams = {
session: SlackStreamSession;
text: string;
};
export type StopSlackStreamParams = {
session: SlackStreamSession;
/** Optional final markdown text to append before stopping. */
text?: string;
};
// ---------------------------------------------------------------------------
// Stream lifecycle
// ---------------------------------------------------------------------------
/**
* Start a new Slack text stream.
*
* Returns a {@link SlackStreamSession} that should be passed to
* {@link appendSlackStream} and {@link stopSlackStream}.
*
* The first chunk of text can optionally be included via `text`.
*/
export async function startSlackStream(
params: StartSlackStreamParams,
): Promise<SlackStreamSession> {
const { client, channel, threadTs, text } = params;
logVerbose(`slack-stream: starting stream in ${channel} thread=${threadTs}`);
const streamer = client.chatStream({
channel,
thread_ts: threadTs,
});
const session: SlackStreamSession = {
streamer,
channel,
threadTs,
stopped: false,
};
// If initial text is provided, send it as the first append which will
// trigger the ChatStreamer to call chat.startStream under the hood.
if (text) {
await streamer.append({ markdown_text: text });
logVerbose(`slack-stream: appended initial text (${text.length} chars)`);
}
return session;
}
/**
* Append markdown text to an active Slack stream.
*/
export async function appendSlackStream(params: AppendSlackStreamParams): Promise<void> {
const { session, text } = params;
if (session.stopped) {
logVerbose("slack-stream: attempted to append to a stopped stream, ignoring");
return;
}
if (!text) {
return;
}
await session.streamer.append({ markdown_text: text });
logVerbose(`slack-stream: appended ${text.length} chars`);
}
/**
* Stop (finalize) a Slack stream.
*
* After calling this the stream message becomes a normal Slack message.
* Optionally include final text to append before stopping.
*/
export async function stopSlackStream(params: StopSlackStreamParams): Promise<void> {
const { session, text } = params;
if (session.stopped) {
logVerbose("slack-stream: stream already stopped, ignoring duplicate stop");
return;
}
session.stopped = true;
logVerbose(
`slack-stream: stopping stream in ${session.channel} thread=${session.threadTs}${
text ? ` (final text: ${text.length} chars)` : ""
}`,
);
await session.streamer.stop(text ? { markdown_text: text } : undefined);
logVerbose("slack-stream: stream stopped");
}