2026-01-23 23:04:09 +00:00
|
|
|
import { resolveHumanDelayConfig } from "../../../agents/identity.js";
|
2026-01-23 22:51:37 +00:00
|
|
|
import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js";
|
2026-01-23 22:36:43 +00:00
|
|
|
import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
2026-01-23 22:29:47 +00:00
|
|
|
import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js";
|
2026-01-23 23:20:07 +00:00
|
|
|
import { logAckFailure, logTypingFailure } from "../../../channels/logging.js";
|
feat: per-channel responsePrefix override (#9001)
* feat: per-channel responsePrefix override
Add responsePrefix field to all channel config types and Zod schemas,
enabling per-channel and per-account outbound response prefix overrides.
Resolution cascade (most specific wins):
L1: channels.<ch>.accounts.<id>.responsePrefix
L2: channels.<ch>.responsePrefix
L3: (reserved for channels.defaults)
L4: messages.responsePrefix (existing global)
Semantics:
- undefined -> inherit from parent level
- empty string -> explicitly no prefix (stops cascade)
- "auto" -> derive [identity.name] from routed agent
Changes:
- Core logic: resolveResponsePrefix() in identity.ts accepts
optional channel/accountId and walks the cascade
- resolveEffectiveMessagesConfig() passes channel context through
- Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack,
Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs
- Zod schemas: responsePrefix added for config validation
- All channel handlers wired: telegram, discord, slack, signal,
imessage, line, heartbeat runner, route-reply, native commands
- 23 new tests covering backward compat, channel/account levels,
full cascade, auto keyword, empty string stops, unknown fallthrough
Fully backward compatible - no existing config is affected.
Fixes #8857
* fix: address CI lint + review feedback
- Replace Record<string, any> with proper typed helpers (no-explicit-any)
- Add curly braces to single-line if returns (eslint curly)
- Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types
- Extract getChannelConfig() helper for type-safe dynamic key access
* fix: finish responsePrefix overrides (#9001) (thanks @mudrii)
* fix: normalize prefix wiring and types (#9001) (thanks @mudrii)
---------
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
|
|
|
import { createReplyPrefixOptions } from "../../../channels/reply-prefix.js";
|
2026-01-23 22:55:41 +00:00
|
|
|
import { createTypingCallbacks } from "../../../channels/typing.js";
|
2026-01-23 23:04:09 +00:00
|
|
|
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
|
|
|
|
|
import { removeSlackReaction } from "../../actions.js";
|
2026-02-16 15:43:29 -05:00
|
|
|
import { createSlackDraftStream } from "../../draft-stream.js";
|
2026-02-16 16:56:41 -05:00
|
|
|
import {
|
|
|
|
|
applyAppendOnlyStreamUpdate,
|
|
|
|
|
buildStatusFinalPreviewText,
|
2026-02-21 19:53:23 +01:00
|
|
|
resolveSlackStreamingConfig,
|
2026-02-16 16:56:41 -05:00
|
|
|
} from "../../stream-mode.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
import type { SlackStreamSession } from "../../streaming.js";
|
2026-02-05 18:14:13 -05:00
|
|
|
import { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { resolveSlackThreadTargets } from "../../threading.js";
|
2026-02-18 00:49:30 +01:00
|
|
|
import { createSlackReplyDeliveryPlan, deliverReplies, resolveSlackThreadTs } from "../replies.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
import type { PreparedSlackMessage } from "./types.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-02-05 18:14:13 -05:00
|
|
|
function hasMedia(payload: ReplyPayload): boolean {
|
|
|
|
|
return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:53:23 +01:00
|
|
|
export function isSlackStreamingEnabled(params: {
|
|
|
|
|
mode: "off" | "partial" | "block" | "progress";
|
|
|
|
|
nativeStreaming: boolean;
|
|
|
|
|
}): boolean {
|
|
|
|
|
if (params.mode !== "partial") {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return params.nativeStreaming;
|
2026-02-18 00:49:30 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveSlackStreamingThreadHint(params: {
|
|
|
|
|
replyToMode: "off" | "first" | "all";
|
|
|
|
|
incomingThreadTs: string | undefined;
|
|
|
|
|
messageTs: string | undefined;
|
2026-02-22 22:05:39 +00:00
|
|
|
isThreadReply?: boolean;
|
2026-02-18 00:49:30 +01:00
|
|
|
}): string | undefined {
|
|
|
|
|
return resolveSlackThreadTs({
|
|
|
|
|
replyToMode: params.replyToMode,
|
|
|
|
|
incomingThreadTs: params.incomingThreadTs,
|
|
|
|
|
messageTs: params.messageTs,
|
|
|
|
|
hasReplied: false,
|
2026-02-22 22:05:39 +00:00
|
|
|
isThreadReply: params.isThreadReply,
|
2026-02-18 00:49:30 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 18:14:13 -05:00
|
|
|
function shouldUseStreaming(params: {
|
|
|
|
|
streamingEnabled: boolean;
|
|
|
|
|
threadTs: string | undefined;
|
|
|
|
|
}): boolean {
|
|
|
|
|
if (!params.streamingEnabled) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (!params.threadTs) {
|
2026-02-18 00:49:30 +01:00
|
|
|
logVerbose("slack-stream: streaming disabled — no reply thread target available");
|
2026-02-05 18:14:13 -05:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessage) {
|
2026-01-14 01:08:15 +00:00
|
|
|
const { ctx, account, message, route } = prepared;
|
|
|
|
|
const cfg = ctx.cfg;
|
|
|
|
|
const runtime = ctx.runtime;
|
|
|
|
|
|
2026-01-23 23:04:09 +00:00
|
|
|
if (prepared.isDirectMessage) {
|
|
|
|
|
const sessionCfg = cfg.session;
|
|
|
|
|
const storePath = resolveStorePath(sessionCfg?.store, {
|
|
|
|
|
agentId: route.agentId,
|
|
|
|
|
});
|
|
|
|
|
await updateLastRoute({
|
|
|
|
|
storePath,
|
|
|
|
|
sessionKey: route.mainSessionKey,
|
|
|
|
|
deliveryContext: {
|
|
|
|
|
channel: "slack",
|
|
|
|
|
to: `user:${message.user}`,
|
|
|
|
|
accountId: route.accountId,
|
2026-02-22 13:26:31 -05:00
|
|
|
threadId: prepared.ctxPayload.MessageThreadId,
|
2026-01-23 23:04:09 +00:00
|
|
|
},
|
|
|
|
|
ctx: prepared.ctxPayload,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
fix(slack): finalize replyToMode off threading behavior (#23799)
* fix: make replyToMode 'off' actually prevent threading in Slack
Three independent bugs caused Slack replies to always create threads
even when replyToMode was set to 'off':
1. Typing indicator created threads via statusThreadTs fallback (#16868)
- resolveSlackThreadTargets fell back to messageTs for statusThreadTs
- 'is typing...' was posted as thread reply, creating a thread
- Fix: remove messageTs fallback, let statusThreadTs be undefined
2. [[reply_to_current]] tags bypassed replyToMode entirely (#16080)
- Slack dock had allowExplicitReplyTagsWhenOff: true
- Reply tags from system prompt always threaded regardless of config
- Fix: set allowExplicitReplyTagsWhenOff to false for Slack
3. Contradictory replyToMode defaults in codebase (#20827)
- monitor/provider.ts defaulted to 'all'
- accounts.ts defaulted to 'off' (matching docs)
- Fix: align provider.ts default to 'off' per documentation
Fixes: openclaw/openclaw#16868, openclaw/openclaw#16080, openclaw/openclaw#20827
* fix(slack): respect replyToMode in DMs even with typing indicator thread
When replyToMode is 'off' in DMs, replies should stay in the main
conversation even when the typing indicator creates a thread context.
Previously, when incomingThreadTs was set (from the typing indicator's
thread), replyToMode was forced to 'all', causing all replies to go
into the thread.
Now, for direct messages, the user's configured replyToMode is always
respected. For channels/groups, the existing behavior is preserved
(stay in thread if already in one).
This fix:
- Keeps the typing indicator working (statusThreadTs fallback preserved)
- Prevents DM replies from being forced into threads
- Maintains channel thread continuity
Fixes #16868
* refactor(slack): eliminate redundant resolveSlackThreadContext call
- Add isThreadReply to resolveSlackThreadTargets return value
- Remove duplicate call in dispatch.ts
- Addresses greptile review feedback with cleaner DRY approach
* docs(slack): add JSDoc to resolveSlackThreadTargets
Document return values including isThreadReply distinction between
genuine user thread replies vs bot status message thread context.
* docs(changelog): record Slack replyToMode off threading fixes
---------
Co-authored-by: James <jamesrp13@gmail.com>
Co-authored-by: theoseo <suhong.seo@gmail.com>
2026-02-22 13:27:50 -05:00
|
|
|
const { statusThreadTs, isThreadReply } = resolveSlackThreadTargets({
|
2026-01-14 01:08:15 +00:00
|
|
|
message,
|
|
|
|
|
replyToMode: ctx.replyToMode,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const messageTs = message.ts ?? message.event_ts;
|
|
|
|
|
const incomingThreadTs = message.thread_ts;
|
|
|
|
|
let didSetStatus = false;
|
|
|
|
|
|
|
|
|
|
// Shared mutable ref for "replyToMode=first". Both tool + auto-reply flows
|
|
|
|
|
// mark this to ensure only the first reply is threaded.
|
|
|
|
|
const hasRepliedRef = { value: false };
|
|
|
|
|
const replyPlan = createSlackReplyDeliveryPlan({
|
|
|
|
|
replyToMode: ctx.replyToMode,
|
|
|
|
|
incomingThreadTs,
|
|
|
|
|
messageTs,
|
|
|
|
|
hasRepliedRef,
|
fix(slack): finalize replyToMode off threading behavior (#23799)
* fix: make replyToMode 'off' actually prevent threading in Slack
Three independent bugs caused Slack replies to always create threads
even when replyToMode was set to 'off':
1. Typing indicator created threads via statusThreadTs fallback (#16868)
- resolveSlackThreadTargets fell back to messageTs for statusThreadTs
- 'is typing...' was posted as thread reply, creating a thread
- Fix: remove messageTs fallback, let statusThreadTs be undefined
2. [[reply_to_current]] tags bypassed replyToMode entirely (#16080)
- Slack dock had allowExplicitReplyTagsWhenOff: true
- Reply tags from system prompt always threaded regardless of config
- Fix: set allowExplicitReplyTagsWhenOff to false for Slack
3. Contradictory replyToMode defaults in codebase (#20827)
- monitor/provider.ts defaulted to 'all'
- accounts.ts defaulted to 'off' (matching docs)
- Fix: align provider.ts default to 'off' per documentation
Fixes: openclaw/openclaw#16868, openclaw/openclaw#16080, openclaw/openclaw#20827
* fix(slack): respect replyToMode in DMs even with typing indicator thread
When replyToMode is 'off' in DMs, replies should stay in the main
conversation even when the typing indicator creates a thread context.
Previously, when incomingThreadTs was set (from the typing indicator's
thread), replyToMode was forced to 'all', causing all replies to go
into the thread.
Now, for direct messages, the user's configured replyToMode is always
respected. For channels/groups, the existing behavior is preserved
(stay in thread if already in one).
This fix:
- Keeps the typing indicator working (statusThreadTs fallback preserved)
- Prevents DM replies from being forced into threads
- Maintains channel thread continuity
Fixes #16868
* refactor(slack): eliminate redundant resolveSlackThreadContext call
- Add isThreadReply to resolveSlackThreadTargets return value
- Remove duplicate call in dispatch.ts
- Addresses greptile review feedback with cleaner DRY approach
* docs(slack): add JSDoc to resolveSlackThreadTargets
Document return values including isThreadReply distinction between
genuine user thread replies vs bot status message thread context.
* docs(changelog): record Slack replyToMode off threading fixes
---------
Co-authored-by: James <jamesrp13@gmail.com>
Co-authored-by: theoseo <suhong.seo@gmail.com>
2026-02-22 13:27:50 -05:00
|
|
|
isThreadReply,
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-23 23:20:07 +00:00
|
|
|
const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel;
|
2026-01-23 22:55:41 +00:00
|
|
|
const typingCallbacks = createTypingCallbacks({
|
|
|
|
|
start: async () => {
|
|
|
|
|
didSetStatus = true;
|
|
|
|
|
await ctx.setSlackThreadStatus({
|
|
|
|
|
channelId: message.channel,
|
|
|
|
|
threadTs: statusThreadTs,
|
|
|
|
|
status: "is typing...",
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
stop: async () => {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!didSetStatus) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-23 23:30:52 +00:00
|
|
|
didSetStatus = false;
|
2026-01-23 22:55:41 +00:00
|
|
|
await ctx.setSlackThreadStatus({
|
|
|
|
|
channelId: message.channel,
|
|
|
|
|
threadTs: statusThreadTs,
|
|
|
|
|
status: "",
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
onStartError: (err) => {
|
2026-01-23 23:20:07 +00:00
|
|
|
logTypingFailure({
|
|
|
|
|
log: (message) => runtime.error?.(danger(message)),
|
|
|
|
|
channel: "slack",
|
|
|
|
|
action: "start",
|
|
|
|
|
target: typingTarget,
|
|
|
|
|
error: err,
|
|
|
|
|
});
|
2026-01-23 22:55:41 +00:00
|
|
|
},
|
|
|
|
|
onStopError: (err) => {
|
2026-01-23 23:20:07 +00:00
|
|
|
logTypingFailure({
|
|
|
|
|
log: (message) => runtime.error?.(danger(message)),
|
|
|
|
|
channel: "slack",
|
|
|
|
|
action: "stop",
|
|
|
|
|
target: typingTarget,
|
|
|
|
|
error: err,
|
|
|
|
|
});
|
2026-01-23 22:55:41 +00:00
|
|
|
},
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
feat: per-channel responsePrefix override (#9001)
* feat: per-channel responsePrefix override
Add responsePrefix field to all channel config types and Zod schemas,
enabling per-channel and per-account outbound response prefix overrides.
Resolution cascade (most specific wins):
L1: channels.<ch>.accounts.<id>.responsePrefix
L2: channels.<ch>.responsePrefix
L3: (reserved for channels.defaults)
L4: messages.responsePrefix (existing global)
Semantics:
- undefined -> inherit from parent level
- empty string -> explicitly no prefix (stops cascade)
- "auto" -> derive [identity.name] from routed agent
Changes:
- Core logic: resolveResponsePrefix() in identity.ts accepts
optional channel/accountId and walks the cascade
- resolveEffectiveMessagesConfig() passes channel context through
- Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack,
Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs
- Zod schemas: responsePrefix added for config validation
- All channel handlers wired: telegram, discord, slack, signal,
imessage, line, heartbeat runner, route-reply, native commands
- 23 new tests covering backward compat, channel/account levels,
full cascade, auto keyword, empty string stops, unknown fallthrough
Fully backward compatible - no existing config is affected.
Fixes #8857
* fix: address CI lint + review feedback
- Replace Record<string, any> with proper typed helpers (no-explicit-any)
- Add curly braces to single-line if returns (eslint curly)
- Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types
- Extract getChannelConfig() helper for type-safe dynamic key access
* fix: finish responsePrefix overrides (#9001) (thanks @mudrii)
* fix: normalize prefix wiring and types (#9001) (thanks @mudrii)
---------
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
|
|
|
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
|
|
|
cfg,
|
|
|
|
|
agentId: route.agentId,
|
|
|
|
|
channel: "slack",
|
|
|
|
|
accountId: route.accountId,
|
|
|
|
|
});
|
feat: add dynamic template variables to messages.responsePrefix (#923)
Adds support for template variables in `messages.responsePrefix` that
resolve dynamically at runtime with the actual model used (including
after fallback).
Supported variables (case-insensitive):
- {model} - short model name (e.g., "claude-opus-4-5", "gpt-4o")
- {modelFull} - full model identifier (e.g., "anthropic/claude-opus-4-5")
- {provider} - provider name (e.g., "anthropic", "openai")
- {thinkingLevel} or {think} - thinking level ("high", "low", "off")
- {identity.name} or {identityName} - agent identity name
Example: "[{model} | think:{thinkingLevel}]" → "[claude-opus-4-5 | think:high]"
Variables show the actual model used after fallback, not the intended
model. Unresolved variables remain as literal text.
Implementation:
- New module: src/auto-reply/reply/response-prefix-template.ts
- Template interpolation in normalize-reply.ts via context provider
- onModelSelected callback in agent-runner-execution.ts
- Updated all 6 provider message handlers (web, signal, discord,
telegram, slack, imessage)
- 27 unit tests covering all variables and edge cases
- Documentation in docs/gateway/configuration.md and JSDoc
Fixes #923
2026-01-14 23:05:08 -05:00
|
|
|
|
2026-02-21 19:53:23 +01:00
|
|
|
const slackStreaming = resolveSlackStreamingConfig({
|
|
|
|
|
streaming: account.config.streaming,
|
|
|
|
|
streamMode: account.config.streamMode,
|
|
|
|
|
nativeStreaming: account.config.nativeStreaming,
|
|
|
|
|
});
|
|
|
|
|
const previewStreamingEnabled = slackStreaming.mode !== "off";
|
|
|
|
|
const streamingEnabled = isSlackStreamingEnabled({
|
|
|
|
|
mode: slackStreaming.mode,
|
|
|
|
|
nativeStreaming: slackStreaming.nativeStreaming,
|
|
|
|
|
});
|
2026-02-18 00:49:30 +01:00
|
|
|
const streamThreadHint = resolveSlackStreamingThreadHint({
|
|
|
|
|
replyToMode: ctx.replyToMode,
|
|
|
|
|
incomingThreadTs,
|
|
|
|
|
messageTs,
|
2026-02-22 22:05:39 +00:00
|
|
|
isThreadReply,
|
2026-02-18 00:49:30 +01:00
|
|
|
});
|
2026-02-05 18:14:13 -05:00
|
|
|
const useStreaming = shouldUseStreaming({
|
|
|
|
|
streamingEnabled,
|
2026-02-05 19:03:26 -05:00
|
|
|
threadTs: streamThreadHint,
|
2026-02-05 18:14:13 -05:00
|
|
|
});
|
|
|
|
|
let streamSession: SlackStreamSession | null = null;
|
|
|
|
|
let streamFailed = false;
|
|
|
|
|
|
2026-02-18 00:49:30 +01:00
|
|
|
const deliverNormally = async (payload: ReplyPayload, forcedThreadTs?: string): Promise<void> => {
|
|
|
|
|
const replyThreadTs = forcedThreadTs ?? replyPlan.nextThreadTs();
|
|
|
|
|
await deliverReplies({
|
|
|
|
|
replies: [payload],
|
|
|
|
|
target: prepared.replyTarget,
|
|
|
|
|
token: ctx.botToken,
|
|
|
|
|
accountId: account.accountId,
|
|
|
|
|
runtime,
|
|
|
|
|
textLimit: ctx.textLimit,
|
|
|
|
|
replyThreadTs,
|
2026-02-22 14:36:46 -05:00
|
|
|
replyToMode: ctx.replyToMode,
|
2026-02-18 00:49:30 +01:00
|
|
|
});
|
|
|
|
|
replyPlan.markSent();
|
|
|
|
|
};
|
2026-02-05 18:14:13 -05:00
|
|
|
|
2026-02-18 00:49:30 +01:00
|
|
|
const deliverWithStreaming = async (payload: ReplyPayload): Promise<void> => {
|
2026-02-05 18:14:13 -05:00
|
|
|
if (streamFailed || hasMedia(payload) || !payload.text?.trim()) {
|
2026-02-18 00:49:30 +01:00
|
|
|
await deliverNormally(payload, streamSession?.threadTs);
|
2026-02-05 18:14:13 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const text = payload.text.trim();
|
2026-02-18 02:09:40 +01:00
|
|
|
let plannedThreadTs: string | undefined;
|
2026-02-05 18:14:13 -05:00
|
|
|
try {
|
|
|
|
|
if (!streamSession) {
|
2026-02-18 00:49:30 +01:00
|
|
|
const streamThreadTs = replyPlan.nextThreadTs();
|
2026-02-18 02:09:40 +01:00
|
|
|
plannedThreadTs = streamThreadTs;
|
2026-02-05 18:14:13 -05:00
|
|
|
if (!streamThreadTs) {
|
|
|
|
|
logVerbose(
|
2026-02-18 00:49:30 +01:00
|
|
|
"slack-stream: no reply thread target for stream start, falling back to normal delivery",
|
2026-02-05 18:14:13 -05:00
|
|
|
);
|
|
|
|
|
streamFailed = true;
|
2026-02-18 00:49:30 +01:00
|
|
|
await deliverNormally(payload);
|
2026-02-05 18:14:13 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
streamSession = await startSlackStream({
|
|
|
|
|
client: ctx.app.client,
|
|
|
|
|
channel: message.channel,
|
|
|
|
|
threadTs: streamThreadTs,
|
|
|
|
|
text,
|
fix(slack): pass recipient_team_id to streaming API calls (#20988)
* fix(slack): pass recipient_team_id and recipient_user_id to streaming API calls
The Slack Agents & AI Apps streaming API (chat.startStream / chat.stopStream)
requires recipient_team_id and recipient_user_id parameters. Without them,
stopStream fails with 'missing_recipient_team_id' (all contexts) or
'missing_recipient_user_id' (DM contexts), causing streamed messages to
disappear after generation completes.
This passes:
- team_id (from auth.test at provider startup, stored in monitor context)
- user_id (from the incoming message sender, for DM recipient identification)
through to the ChatStreamer via recipient_team_id and recipient_user_id options.
Fixes #19839, #20847, #20299, #19791, #20337
AI-assisted: Written with Claude (Opus 4.6) via OpenClaw. Lightly tested
(unit tests pass, live workspace verification in progress).
* fix(slack): disable block streaming when native streaming is active
When Slack native streaming (`chat.startStream`/`stopStream`) is enabled,
`disableBlockStreaming` was set to `false`, which activated the app-level
block streaming pipeline. This pipeline intercepted agent output, sent it
via block replies, then dropped the final payloads that would have flowed
through `deliverWithStreaming` to the Slack streaming API — resulting in
zero replies delivered.
Set `disableBlockStreaming: true` when native streaming is active so the
final reply flows through the Slack streaming API path as intended.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-19 16:44:34 -06:00
|
|
|
teamId: ctx.teamId,
|
|
|
|
|
userId: message.user,
|
2026-02-05 18:14:13 -05:00
|
|
|
});
|
|
|
|
|
replyPlan.markSent();
|
2026-02-18 00:49:30 +01:00
|
|
|
return;
|
2026-02-05 18:14:13 -05:00
|
|
|
}
|
2026-02-18 00:49:30 +01:00
|
|
|
|
|
|
|
|
await appendSlackStream({
|
|
|
|
|
session: streamSession,
|
|
|
|
|
text: "\n" + text,
|
|
|
|
|
});
|
2026-02-05 18:14:13 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
runtime.error?.(
|
|
|
|
|
danger(`slack-stream: streaming API call failed: ${String(err)}, falling back`),
|
|
|
|
|
);
|
|
|
|
|
streamFailed = true;
|
2026-02-18 02:09:40 +01:00
|
|
|
await deliverNormally(payload, streamSession?.threadTs ?? plannedThreadTs);
|
2026-02-05 18:14:13 -05:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
feat: per-channel responsePrefix override (#9001)
* feat: per-channel responsePrefix override
Add responsePrefix field to all channel config types and Zod schemas,
enabling per-channel and per-account outbound response prefix overrides.
Resolution cascade (most specific wins):
L1: channels.<ch>.accounts.<id>.responsePrefix
L2: channels.<ch>.responsePrefix
L3: (reserved for channels.defaults)
L4: messages.responsePrefix (existing global)
Semantics:
- undefined -> inherit from parent level
- empty string -> explicitly no prefix (stops cascade)
- "auto" -> derive [identity.name] from routed agent
Changes:
- Core logic: resolveResponsePrefix() in identity.ts accepts
optional channel/accountId and walks the cascade
- resolveEffectiveMessagesConfig() passes channel context through
- Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack,
Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs
- Zod schemas: responsePrefix added for config validation
- All channel handlers wired: telegram, discord, slack, signal,
imessage, line, heartbeat runner, route-reply, native commands
- 23 new tests covering backward compat, channel/account levels,
full cascade, auto keyword, empty string stops, unknown fallthrough
Fully backward compatible - no existing config is affected.
Fixes #8857
* fix: address CI lint + review feedback
- Replace Record<string, any> with proper typed helpers (no-explicit-any)
- Add curly braces to single-line if returns (eslint curly)
- Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types
- Extract getChannelConfig() helper for type-safe dynamic key access
* fix: finish responsePrefix overrides (#9001) (thanks @mudrii)
* fix: normalize prefix wiring and types (#9001) (thanks @mudrii)
---------
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
|
|
|
...prefixOptions,
|
2026-01-14 14:31:43 +00:00
|
|
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
2026-02-25 02:15:54 +00:00
|
|
|
typingCallbacks,
|
2026-01-14 14:31:43 +00:00
|
|
|
deliver: async (payload) => {
|
2026-02-05 18:14:13 -05:00
|
|
|
if (useStreaming) {
|
|
|
|
|
await deliverWithStreaming(payload);
|
2026-02-18 00:49:30 +01:00
|
|
|
return;
|
2026-02-05 18:14:13 -05:00
|
|
|
}
|
2026-02-18 00:49:30 +01:00
|
|
|
|
2026-02-16 15:43:29 -05:00
|
|
|
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
|
|
|
|
|
const draftMessageId = draftStream?.messageId();
|
|
|
|
|
const draftChannelId = draftStream?.channelId();
|
|
|
|
|
const finalText = payload.text;
|
|
|
|
|
const canFinalizeViaPreviewEdit =
|
2026-02-21 19:53:23 +01:00
|
|
|
previewStreamingEnabled &&
|
2026-02-16 16:56:41 -05:00
|
|
|
streamMode !== "status_final" &&
|
2026-02-16 15:43:29 -05:00
|
|
|
mediaCount === 0 &&
|
|
|
|
|
!payload.isError &&
|
|
|
|
|
typeof finalText === "string" &&
|
|
|
|
|
finalText.trim().length > 0 &&
|
|
|
|
|
typeof draftMessageId === "string" &&
|
|
|
|
|
typeof draftChannelId === "string";
|
|
|
|
|
|
|
|
|
|
if (canFinalizeViaPreviewEdit) {
|
|
|
|
|
draftStream?.stop();
|
|
|
|
|
try {
|
|
|
|
|
await ctx.app.client.chat.update({
|
2026-02-16 15:50:44 -05:00
|
|
|
token: ctx.botToken,
|
2026-02-16 15:43:29 -05:00
|
|
|
channel: draftChannelId,
|
|
|
|
|
ts: draftMessageId,
|
|
|
|
|
text: finalText.trim(),
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logVerbose(
|
|
|
|
|
`slack: preview final edit failed; falling back to standard send (${String(err)})`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-21 19:53:23 +01:00
|
|
|
} else if (previewStreamingEnabled && streamMode === "status_final" && hasStreamedMessage) {
|
2026-02-16 16:56:41 -05:00
|
|
|
try {
|
|
|
|
|
const statusChannelId = draftStream?.channelId();
|
|
|
|
|
const statusMessageId = draftStream?.messageId();
|
|
|
|
|
if (statusChannelId && statusMessageId) {
|
|
|
|
|
await ctx.app.client.chat.update({
|
|
|
|
|
token: ctx.botToken,
|
|
|
|
|
channel: statusChannelId,
|
|
|
|
|
ts: statusMessageId,
|
|
|
|
|
text: "Status: complete. Final answer posted below.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logVerbose(`slack: status_final completion update failed (${String(err)})`);
|
|
|
|
|
}
|
2026-02-16 15:43:29 -05:00
|
|
|
} else if (mediaCount > 0) {
|
2026-02-16 16:07:00 -05:00
|
|
|
await draftStream?.clear();
|
|
|
|
|
hasStreamedMessage = false;
|
2026-02-16 15:43:29 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:06:03 +00:00
|
|
|
await deliverNormally(payload);
|
2026-01-14 14:31:43 +00:00
|
|
|
},
|
|
|
|
|
onError: (err, info) => {
|
|
|
|
|
runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`));
|
2026-01-23 22:55:41 +00:00
|
|
|
typingCallbacks.onIdle?.();
|
2026-01-14 14:31:43 +00:00
|
|
|
},
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-02-16 15:43:29 -05:00
|
|
|
const draftStream = createSlackDraftStream({
|
|
|
|
|
target: prepared.replyTarget,
|
|
|
|
|
token: ctx.botToken,
|
|
|
|
|
accountId: account.accountId,
|
|
|
|
|
maxChars: Math.min(ctx.textLimit, 4000),
|
|
|
|
|
resolveThreadTs: () => replyPlan.nextThreadTs(),
|
|
|
|
|
onMessageSent: () => replyPlan.markSent(),
|
|
|
|
|
log: logVerbose,
|
|
|
|
|
warn: logVerbose,
|
|
|
|
|
});
|
|
|
|
|
let hasStreamedMessage = false;
|
2026-02-21 19:53:23 +01:00
|
|
|
const streamMode = slackStreaming.draftMode;
|
2026-02-16 16:56:41 -05:00
|
|
|
let appendRenderedText = "";
|
|
|
|
|
let appendSourceText = "";
|
|
|
|
|
let statusUpdateCount = 0;
|
2026-02-16 15:43:29 -05:00
|
|
|
const updateDraftFromPartial = (text?: string) => {
|
|
|
|
|
const trimmed = text?.trimEnd();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-16 16:56:41 -05:00
|
|
|
|
|
|
|
|
if (streamMode === "append") {
|
|
|
|
|
const next = applyAppendOnlyStreamUpdate({
|
|
|
|
|
incoming: trimmed,
|
|
|
|
|
rendered: appendRenderedText,
|
|
|
|
|
source: appendSourceText,
|
|
|
|
|
});
|
|
|
|
|
appendRenderedText = next.rendered;
|
|
|
|
|
appendSourceText = next.source;
|
|
|
|
|
if (!next.changed) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
draftStream.update(next.rendered);
|
|
|
|
|
hasStreamedMessage = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (streamMode === "status_final") {
|
|
|
|
|
statusUpdateCount += 1;
|
|
|
|
|
if (statusUpdateCount > 1 && statusUpdateCount % 4 !== 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
draftStream.update(buildStatusFinalPreviewText(statusUpdateCount));
|
|
|
|
|
hasStreamedMessage = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 15:43:29 -05:00
|
|
|
draftStream.update(trimmed);
|
|
|
|
|
hasStreamedMessage = true;
|
|
|
|
|
};
|
2026-02-22 14:06:03 +00:00
|
|
|
const onDraftBoundary =
|
|
|
|
|
useStreaming || !previewStreamingEnabled
|
|
|
|
|
? undefined
|
|
|
|
|
: async () => {
|
|
|
|
|
if (hasStreamedMessage) {
|
|
|
|
|
draftStream.forceNewMessage();
|
|
|
|
|
hasStreamedMessage = false;
|
|
|
|
|
appendRenderedText = "";
|
|
|
|
|
appendSourceText = "";
|
|
|
|
|
statusUpdateCount = 0;
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-02-16 15:43:29 -05:00
|
|
|
|
2026-01-23 22:51:37 +00:00
|
|
|
const { queuedFinal, counts } = await dispatchInboundMessage({
|
2026-01-14 01:08:15 +00:00
|
|
|
ctx: prepared.ctxPayload,
|
|
|
|
|
cfg,
|
|
|
|
|
dispatcher,
|
|
|
|
|
replyOptions: {
|
|
|
|
|
...replyOptions,
|
|
|
|
|
skillFilter: prepared.channelConfig?.skills,
|
|
|
|
|
hasRepliedRef,
|
2026-02-18 00:49:30 +01:00
|
|
|
disableBlockStreaming: useStreaming
|
fix(slack): pass recipient_team_id to streaming API calls (#20988)
* fix(slack): pass recipient_team_id and recipient_user_id to streaming API calls
The Slack Agents & AI Apps streaming API (chat.startStream / chat.stopStream)
requires recipient_team_id and recipient_user_id parameters. Without them,
stopStream fails with 'missing_recipient_team_id' (all contexts) or
'missing_recipient_user_id' (DM contexts), causing streamed messages to
disappear after generation completes.
This passes:
- team_id (from auth.test at provider startup, stored in monitor context)
- user_id (from the incoming message sender, for DM recipient identification)
through to the ChatStreamer via recipient_team_id and recipient_user_id options.
Fixes #19839, #20847, #20299, #19791, #20337
AI-assisted: Written with Claude (Opus 4.6) via OpenClaw. Lightly tested
(unit tests pass, live workspace verification in progress).
* fix(slack): disable block streaming when native streaming is active
When Slack native streaming (`chat.startStream`/`stopStream`) is enabled,
`disableBlockStreaming` was set to `false`, which activated the app-level
block streaming pipeline. This pipeline intercepted agent output, sent it
via block replies, then dropped the final payloads that would have flowed
through `deliverWithStreaming` to the Slack streaming API — resulting in
zero replies delivered.
Set `disableBlockStreaming: true` when native streaming is active so the
final reply flows through the Slack streaming API path as intended.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-19 16:44:34 -06:00
|
|
|
? true
|
2026-02-18 00:49:30 +01:00
|
|
|
: typeof account.config.blockStreaming === "boolean"
|
2026-01-14 01:08:15 +00:00
|
|
|
? !account.config.blockStreaming
|
|
|
|
|
: undefined,
|
feat: per-channel responsePrefix override (#9001)
* feat: per-channel responsePrefix override
Add responsePrefix field to all channel config types and Zod schemas,
enabling per-channel and per-account outbound response prefix overrides.
Resolution cascade (most specific wins):
L1: channels.<ch>.accounts.<id>.responsePrefix
L2: channels.<ch>.responsePrefix
L3: (reserved for channels.defaults)
L4: messages.responsePrefix (existing global)
Semantics:
- undefined -> inherit from parent level
- empty string -> explicitly no prefix (stops cascade)
- "auto" -> derive [identity.name] from routed agent
Changes:
- Core logic: resolveResponsePrefix() in identity.ts accepts
optional channel/accountId and walks the cascade
- resolveEffectiveMessagesConfig() passes channel context through
- Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack,
Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs
- Zod schemas: responsePrefix added for config validation
- All channel handlers wired: telegram, discord, slack, signal,
imessage, line, heartbeat runner, route-reply, native commands
- 23 new tests covering backward compat, channel/account levels,
full cascade, auto keyword, empty string stops, unknown fallthrough
Fully backward compatible - no existing config is affected.
Fixes #8857
* fix: address CI lint + review feedback
- Replace Record<string, any> with proper typed helpers (no-explicit-any)
- Add curly braces to single-line if returns (eslint curly)
- Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types
- Extract getChannelConfig() helper for type-safe dynamic key access
* fix: finish responsePrefix overrides (#9001) (thanks @mudrii)
* fix: normalize prefix wiring and types (#9001) (thanks @mudrii)
---------
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
|
|
|
onModelSelected,
|
2026-02-18 00:49:30 +01:00
|
|
|
onPartialReply: useStreaming
|
|
|
|
|
? undefined
|
2026-02-21 19:53:23 +01:00
|
|
|
: !previewStreamingEnabled
|
|
|
|
|
? undefined
|
|
|
|
|
: async (payload) => {
|
|
|
|
|
updateDraftFromPartial(payload.text);
|
|
|
|
|
},
|
2026-02-22 14:06:03 +00:00
|
|
|
onAssistantMessageStart: onDraftBoundary,
|
|
|
|
|
onReasoningEnd: onDraftBoundary,
|
2026-01-14 01:08:15 +00:00
|
|
|
},
|
|
|
|
|
});
|
2026-02-16 15:43:29 -05:00
|
|
|
await draftStream.flush();
|
|
|
|
|
draftStream.stop();
|
2026-01-14 01:08:15 +00:00
|
|
|
markDispatchIdle();
|
|
|
|
|
|
2026-02-05 18:14:13 -05:00
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// Finalize the stream if one was started
|
|
|
|
|
// -----------------------------------------------------------------------
|
2026-02-05 18:25:21 -05:00
|
|
|
const finalStream = streamSession as SlackStreamSession | null;
|
|
|
|
|
if (finalStream && !finalStream.stopped) {
|
2026-02-05 18:14:13 -05:00
|
|
|
try {
|
2026-02-05 18:25:21 -05:00
|
|
|
await stopSlackStream({ session: finalStream });
|
2026-02-05 18:14:13 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
runtime.error?.(danger(`slack-stream: failed to stop stream: ${String(err)}`));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 21:28:46 -06:00
|
|
|
const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
|
|
|
|
|
|
|
|
|
|
if (!anyReplyDelivered) {
|
2026-02-16 16:07:00 -05:00
|
|
|
await draftStream.clear();
|
2026-01-23 22:36:43 +00:00
|
|
|
if (prepared.isRoomish) {
|
|
|
|
|
clearHistoryEntriesIfEnabled({
|
2026-01-14 01:08:15 +00:00
|
|
|
historyMap: ctx.channelHistories,
|
|
|
|
|
historyKey: prepared.historyKey,
|
2026-01-23 22:36:43 +00:00
|
|
|
limit: ctx.historyLimit,
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (shouldLogVerbose()) {
|
|
|
|
|
const finalCount = counts.final;
|
|
|
|
|
logVerbose(
|
|
|
|
|
`slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${prepared.replyTarget}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 22:29:47 +00:00
|
|
|
removeAckReactionAfterReply({
|
|
|
|
|
removeAfterReply: ctx.removeAckAfterReply,
|
|
|
|
|
ackReactionPromise: prepared.ackReactionPromise,
|
|
|
|
|
ackReactionValue: prepared.ackReactionValue,
|
|
|
|
|
remove: () =>
|
|
|
|
|
removeSlackReaction(
|
|
|
|
|
message.channel,
|
|
|
|
|
prepared.ackReactionMessageTs ?? "",
|
|
|
|
|
prepared.ackReactionValue,
|
|
|
|
|
{
|
|
|
|
|
token: ctx.botToken,
|
|
|
|
|
client: ctx.app.client,
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
onError: (err) => {
|
2026-01-23 23:20:07 +00:00
|
|
|
logAckFailure({
|
|
|
|
|
log: logVerbose,
|
|
|
|
|
channel: "slack",
|
|
|
|
|
target: `${message.channel}/${message.ts}`,
|
|
|
|
|
error: err,
|
|
|
|
|
});
|
2026-01-23 22:29:47 +00:00
|
|
|
},
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-01-23 22:36:43 +00:00
|
|
|
if (prepared.isRoomish) {
|
|
|
|
|
clearHistoryEntriesIfEnabled({
|
2026-01-14 01:08:15 +00:00
|
|
|
historyMap: ctx.channelHistories,
|
|
|
|
|
historyKey: prepared.historyKey,
|
2026-01-23 22:36:43 +00:00
|
|
|
limit: ctx.historyLimit,
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|