mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-29 01:31:18 +00:00
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
This commit is contained in:
@@ -563,6 +563,40 @@ Common failures:
|
|||||||
|
|
||||||
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
|
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
|
||||||
|
|
||||||
|
## Text streaming
|
||||||
|
|
||||||
|
Slack's [Agents & AI Apps](https://docs.slack.dev/ai/developing-ai-apps) feature includes native text streaming APIs that let your app stream responses word-by-word (similar to ChatGPT) instead of waiting for the full response.
|
||||||
|
|
||||||
|
Enable it per-account:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
channels:
|
||||||
|
slack:
|
||||||
|
streaming: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
1. **Agents & AI Apps** must be toggled on in your [Slack app settings](https://api.slack.com/apps). This automatically adds the `assistant:write` scope.
|
||||||
|
2. Streaming only works **within threads** (DM threads, channel threads). Messages without a thread context fall back to normal delivery automatically.
|
||||||
|
3. Block streaming (`blockStreaming`) is automatically enabled when `streaming` is active so the LLM's incremental output feeds into the stream.
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- On the first text block the bot calls `chat.startStream` to create a single updating message.
|
||||||
|
- Subsequent text blocks are appended via `chat.appendStream`.
|
||||||
|
- When the reply is complete the stream is finalized with `chat.stopStream`.
|
||||||
|
- Media attachments (images, files) are delivered as separate messages alongside the stream.
|
||||||
|
- If a streaming API call fails, the bot gracefully falls back to normal message delivery for the remainder of the response.
|
||||||
|
|
||||||
|
### Relevant Slack API methods
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
| --------------------------------------------------------------------------------- | ------------------------- |
|
||||||
|
| [`chat.startStream`](https://docs.slack.dev/reference/methods/chat.startStream) | Start a new text stream |
|
||||||
|
| [`chat.appendStream`](https://docs.slack.dev/reference/methods/chat.appendStream) | Append text to the stream |
|
||||||
|
| [`chat.stopStream`](https://docs.slack.dev/reference/methods/chat.stopStream) | Finalize the stream |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
|
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ export type SlackAccountConfig = {
|
|||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
|
/**
|
||||||
|
* Enable Slack native text streaming (Agents & AI Apps).
|
||||||
|
*
|
||||||
|
* When true, replies are streamed word-by-word into a single updating
|
||||||
|
* message using `chat.startStream` / `chat.appendStream` / `chat.stopStream`.
|
||||||
|
* Requires the Agents & AI Apps feature enabled in Slack app settings and
|
||||||
|
* the `assistant:write` scope.
|
||||||
|
*
|
||||||
|
* Falls back to normal delivery on error or when the message is not in a thread.
|
||||||
|
*/
|
||||||
|
streaming?: boolean;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||||
reactionNotifications?: SlackReactionNotificationMode;
|
reactionNotifications?: SlackReactionNotificationMode;
|
||||||
|
|||||||
@@ -480,6 +480,7 @@ export const SlackAccountSchema = z
|
|||||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
|
streaming: z.boolean().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
||||||
|
import type { SlackStreamSession } from "../../streaming.js";
|
||||||
import type { PreparedSlackMessage } from "./types.js";
|
import type { PreparedSlackMessage } from "./types.js";
|
||||||
import { resolveHumanDelayConfig } from "../../../agents/identity.js";
|
import { resolveHumanDelayConfig } from "../../../agents/identity.js";
|
||||||
import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js";
|
import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js";
|
||||||
@@ -10,9 +12,39 @@ import { createTypingCallbacks } from "../../../channels/typing.js";
|
|||||||
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
|
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
|
||||||
import { removeSlackReaction } from "../../actions.js";
|
import { removeSlackReaction } from "../../actions.js";
|
||||||
|
import { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js";
|
||||||
import { resolveSlackThreadTargets } from "../../threading.js";
|
import { resolveSlackThreadTargets } from "../../threading.js";
|
||||||
import { createSlackReplyDeliveryPlan, deliverReplies } from "../replies.js";
|
import { createSlackReplyDeliveryPlan, deliverReplies } from "../replies.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a reply payload contains media (images, files, etc.)
|
||||||
|
* that cannot be delivered through the streaming API.
|
||||||
|
*/
|
||||||
|
function hasMedia(payload: ReplyPayload): boolean {
|
||||||
|
return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if Slack native text streaming should be used for this message.
|
||||||
|
*
|
||||||
|
* Streaming requires:
|
||||||
|
* 1. The `streaming` config option enabled on the account
|
||||||
|
* 2. A thread timestamp (streaming only works within threads)
|
||||||
|
*/
|
||||||
|
function shouldUseStreaming(params: {
|
||||||
|
streamingEnabled: boolean;
|
||||||
|
threadTs: string | undefined;
|
||||||
|
}): boolean {
|
||||||
|
if (!params.streamingEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!params.threadTs) {
|
||||||
|
logVerbose("slack-stream: streaming disabled — no thread_ts available");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessage) {
|
export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessage) {
|
||||||
const { ctx, account, message, route } = prepared;
|
const { ctx, account, message, route } = prepared;
|
||||||
const cfg = ctx.cfg;
|
const cfg = ctx.cfg;
|
||||||
@@ -102,11 +134,30 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
// -----------------------------------------------------------------------
|
||||||
...prefixOptions,
|
// Slack native text streaming state
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
// -----------------------------------------------------------------------
|
||||||
deliver: async (payload) => {
|
const streamingEnabled = account.config.streaming === true;
|
||||||
const replyThreadTs = replyPlan.nextThreadTs();
|
const replyThreadTs = replyPlan.nextThreadTs();
|
||||||
|
|
||||||
|
const useStreaming = shouldUseStreaming({
|
||||||
|
streamingEnabled,
|
||||||
|
threadTs: replyThreadTs ?? incomingThreadTs ?? statusThreadTs,
|
||||||
|
});
|
||||||
|
|
||||||
|
let streamSession: SlackStreamSession | null = null;
|
||||||
|
let streamFailed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver a payload via Slack native text streaming when possible.
|
||||||
|
* Falls back to normal delivery for media payloads, errors, or if the
|
||||||
|
* streaming API call itself fails.
|
||||||
|
*/
|
||||||
|
const deliverWithStreaming = async (payload: ReplyPayload): Promise<void> => {
|
||||||
|
const effectiveThreadTs = replyPlan.nextThreadTs();
|
||||||
|
|
||||||
|
// Fall back to normal delivery for media, errors, or if streaming already failed
|
||||||
|
if (streamFailed || hasMedia(payload) || !payload.text?.trim()) {
|
||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
target: prepared.replyTarget,
|
target: prepared.replyTarget,
|
||||||
@@ -114,9 +165,92 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
runtime,
|
runtime,
|
||||||
textLimit: ctx.textLimit,
|
textLimit: ctx.textLimit,
|
||||||
replyThreadTs,
|
replyThreadTs: effectiveThreadTs,
|
||||||
});
|
});
|
||||||
replyPlan.markSent();
|
replyPlan.markSent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = payload.text.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!streamSession) {
|
||||||
|
// Determine the thread_ts for the stream (required by Slack API)
|
||||||
|
const streamThreadTs = effectiveThreadTs ?? incomingThreadTs ?? statusThreadTs;
|
||||||
|
|
||||||
|
if (!streamThreadTs) {
|
||||||
|
// No thread context — can't stream, fall back
|
||||||
|
logVerbose(
|
||||||
|
"slack-stream: no thread_ts for stream start, falling back to normal delivery",
|
||||||
|
);
|
||||||
|
streamFailed = true;
|
||||||
|
await deliverReplies({
|
||||||
|
replies: [payload],
|
||||||
|
target: prepared.replyTarget,
|
||||||
|
token: ctx.botToken,
|
||||||
|
accountId: account.accountId,
|
||||||
|
runtime,
|
||||||
|
textLimit: ctx.textLimit,
|
||||||
|
replyThreadTs: effectiveThreadTs,
|
||||||
|
});
|
||||||
|
replyPlan.markSent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a new stream
|
||||||
|
streamSession = await startSlackStream({
|
||||||
|
client: ctx.app.client,
|
||||||
|
channel: message.channel,
|
||||||
|
threadTs: streamThreadTs,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
replyPlan.markSent();
|
||||||
|
} else {
|
||||||
|
// Append to existing stream
|
||||||
|
await appendSlackStream({
|
||||||
|
session: streamSession,
|
||||||
|
text: "\n" + text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(
|
||||||
|
danger(`slack-stream: streaming API call failed: ${String(err)}, falling back`),
|
||||||
|
);
|
||||||
|
streamFailed = true;
|
||||||
|
|
||||||
|
// Fall back to normal delivery for this payload
|
||||||
|
await deliverReplies({
|
||||||
|
replies: [payload],
|
||||||
|
target: prepared.replyTarget,
|
||||||
|
token: ctx.botToken,
|
||||||
|
accountId: account.accountId,
|
||||||
|
runtime,
|
||||||
|
textLimit: ctx.textLimit,
|
||||||
|
replyThreadTs: effectiveThreadTs,
|
||||||
|
});
|
||||||
|
replyPlan.markSent();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||||
|
...prefixOptions,
|
||||||
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
|
deliver: async (payload) => {
|
||||||
|
if (useStreaming) {
|
||||||
|
await deliverWithStreaming(payload);
|
||||||
|
} else {
|
||||||
|
const effectiveThreadTs = replyPlan.nextThreadTs();
|
||||||
|
await deliverReplies({
|
||||||
|
replies: [payload],
|
||||||
|
target: prepared.replyTarget,
|
||||||
|
token: ctx.botToken,
|
||||||
|
accountId: account.accountId,
|
||||||
|
runtime,
|
||||||
|
textLimit: ctx.textLimit,
|
||||||
|
replyThreadTs: effectiveThreadTs,
|
||||||
|
});
|
||||||
|
replyPlan.markSent();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`));
|
runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`));
|
||||||
@@ -135,14 +269,29 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
skillFilter: prepared.channelConfig?.skills,
|
skillFilter: prepared.channelConfig?.skills,
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
disableBlockStreaming:
|
disableBlockStreaming:
|
||||||
typeof account.config.blockStreaming === "boolean"
|
// When native streaming is active, keep block streaming enabled so we
|
||||||
? !account.config.blockStreaming
|
// get incremental block callbacks that we route through the stream.
|
||||||
: undefined,
|
useStreaming
|
||||||
|
? false
|
||||||
|
: typeof account.config.blockStreaming === "boolean"
|
||||||
|
? !account.config.blockStreaming
|
||||||
|
: undefined,
|
||||||
onModelSelected,
|
onModelSelected,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Finalize the stream if one was started
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
if (streamSession && !streamSession.stopped) {
|
||||||
|
try {
|
||||||
|
await stopSlackStream({ session: streamSession });
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(danger(`slack-stream: failed to stop stream: ${String(err)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
|
const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
|
||||||
|
|
||||||
if (!anyReplyDelivered) {
|
if (!anyReplyDelivered) {
|
||||||
|
|||||||
136
src/slack/streaming.ts
Normal file
136
src/slack/streaming.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user