Files
openclaw/src/slack/monitor/message-handler/prepare.ts

803 lines
26 KiB
TypeScript
Raw Normal View History

2026-01-14 01:08:15 +00:00
import { resolveAckReaction } from "../../../agents/identity.js";
import { hasControlCommand } from "../../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../../auto-reply/commands-registry.js";
2026-01-17 05:21:02 +00:00
import {
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
2026-01-17 05:21:02 +00:00
} from "../../../auto-reply/envelope.js";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntryIfEnabled,
} from "../../../auto-reply/reply/history.js";
2026-01-17 05:04:29 +00:00
import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js";
import {
buildMentionRegexes,
matchesMentionWithExplicit,
} from "../../../auto-reply/reply/mentions.js";
import type { FinalizedMsgContext } from "../../../auto-reply/templating.js";
import {
shouldAckReaction as shouldAckReactionGate,
type AckReactionScope,
} from "../../../channels/ack-reactions.js";
import { resolveControlCommandGate } from "../../../channels/command-gating.js";
import { resolveConversationLabel } from "../../../channels/conversation-label.js";
2026-01-23 23:20:07 +00:00
import { logInboundDrop } from "../../../channels/logging.js";
import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js";
import { recordInboundSession } from "../../../channels/session.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../../config/sessions.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../security/dm-policy-shared.js";
import { resolveSlackReplyToMode, type ResolvedSlackAccount } from "../../accounts.js";
2026-01-14 01:08:15 +00:00
import { reactSlackMessage } from "../../actions.js";
import { sendMessageSlack } from "../../send.js";
import { hasSlackThreadParticipation } from "../../sent-thread-cache.js";
2026-01-21 20:01:12 +00:00
import { resolveSlackThreadContext } from "../../threading.js";
import type { SlackMessageEvent } from "../../types.js";
import {
normalizeSlackAllowOwnerEntry,
resolveSlackAllowListMatch,
resolveSlackUserAllowed,
} from "../allow-list.js";
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
2026-01-14 01:08:15 +00:00
import { resolveSlackChannelConfig } from "../channel-config.js";
import { stripSlackMentionsForCommandDetection } from "../commands.js";
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js";
import { authorizeSlackDirectMessage } from "../dm-auth.js";
import { resolveSlackThreadStarter } from "../media.js";
import { resolveSlackRoomContextHints } from "../room-context.js";
import { resolveSlackMessageContent } from "./prepare-content.js";
import { resolveSlackThreadContextData } from "./prepare-thread-context.js";
import type { PreparedSlackMessage } from "./types.js";
2026-01-14 01:08:15 +00:00
const mentionRegexCache = new WeakMap<SlackMonitorContext, Map<string, RegExp[]>>();
function resolveCachedMentionRegexes(
ctx: SlackMonitorContext,
agentId: string | undefined,
): RegExp[] {
const key = agentId?.trim() || "__default__";
let byAgent = mentionRegexCache.get(ctx);
if (!byAgent) {
byAgent = new Map<string, RegExp[]>();
mentionRegexCache.set(ctx, byAgent);
}
const cached = byAgent.get(key);
if (cached) {
return cached;
}
const built = buildMentionRegexes(ctx.cfg, agentId);
byAgent.set(key, built);
return built;
}
type SlackConversationContext = {
channelInfo: {
name?: string;
type?: SlackMessageEvent["channel_type"];
topic?: string;
purpose?: string;
};
channelName?: string;
resolvedChannelType: ReturnType<typeof normalizeSlackChannelType>;
isDirectMessage: boolean;
isGroupDm: boolean;
isRoom: boolean;
isRoomish: boolean;
channelConfig: ReturnType<typeof resolveSlackChannelConfig> | null;
allowBots: boolean;
isBotMessage: boolean;
};
type SlackAuthorizationContext = {
senderId: string;
allowFromLower: string[];
};
type SlackRoutingContext = {
route: ReturnType<typeof resolveAgentRoute>;
chatType: "direct" | "group" | "channel";
replyToMode: ReturnType<typeof resolveSlackReplyToMode>;
threadContext: ReturnType<typeof resolveSlackThreadContext>;
threadTs: string | undefined;
isThreadReply: boolean;
threadKeys: ReturnType<typeof resolveThreadSessionKeys>;
sessionKey: string;
historyKey: string;
};
async function resolveSlackConversationContext(params: {
2026-01-14 01:08:15 +00:00
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
}): Promise<SlackConversationContext> {
const { ctx, account, message } = params;
2026-01-14 01:08:15 +00:00
const cfg = ctx.cfg;
let channelInfo: {
name?: string;
type?: SlackMessageEvent["channel_type"];
topic?: string;
purpose?: string;
} = {};
let resolvedChannelType = normalizeSlackChannelType(message.channel_type, message.channel);
// D-prefixed channels are always direct messages. Skip channel lookups in
// that common path to avoid an unnecessary API round-trip.
if (resolvedChannelType !== "im" && (!message.channel_type || message.channel_type !== "im")) {
2026-01-14 01:08:15 +00:00
channelInfo = await ctx.resolveChannelName(message.channel);
resolvedChannelType = normalizeSlackChannelType(
message.channel_type ?? channelInfo.type,
message.channel,
);
2026-01-14 01:08:15 +00:00
}
const channelName = channelInfo?.name;
const isDirectMessage = resolvedChannelType === "im";
const isGroupDm = resolvedChannelType === "mpim";
const isRoom = resolvedChannelType === "channel" || resolvedChannelType === "group";
2026-01-14 01:08:15 +00:00
const isRoomish = isRoom || isGroupDm;
const channelConfig = isRoom
? resolveSlackChannelConfig({
channelId: message.channel,
channelName,
channels: ctx.channelsConfig,
channelKeys: ctx.channelsConfigKeys,
defaultRequireMention: ctx.defaultRequireMention,
2026-01-14 01:08:15 +00:00
})
: null;
const allowBots =
channelConfig?.allowBots ??
account.config?.allowBots ??
cfg.channels?.slack?.allowBots ??
false;
return {
channelInfo,
channelName,
resolvedChannelType,
isDirectMessage,
isGroupDm,
isRoom,
isRoomish,
channelConfig,
allowBots,
isBotMessage: Boolean(message.bot_id),
};
}
async function authorizeSlackInboundMessage(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
conversation: SlackConversationContext;
}): Promise<SlackAuthorizationContext | null> {
const { ctx, account, message, conversation } = params;
const { isDirectMessage, channelName, resolvedChannelType, isBotMessage, allowBots } =
conversation;
2026-01-14 01:08:15 +00:00
if (isBotMessage) {
if (message.user && ctx.botUserId && message.user === ctx.botUserId) {
return null;
}
2026-01-14 01:08:15 +00:00
if (!allowBots) {
logVerbose(`slack: drop bot message ${message.bot_id ?? "unknown"} (allowBots=false)`);
2026-01-14 01:08:15 +00:00
return null;
}
}
if (isDirectMessage && !message.user) {
logVerbose("slack: drop dm message (missing user id)");
return null;
}
const senderId = message.user ?? (isBotMessage ? message.bot_id : undefined);
if (!senderId) {
logVerbose("slack: drop message (missing sender id)");
return null;
}
if (
!ctx.isChannelAllowed({
channelId: message.channel,
channelName,
channelType: resolvedChannelType,
})
) {
logVerbose("slack: drop message (channel not allowed)");
return null;
}
const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx, {
includePairingStore: isDirectMessage,
});
2026-01-14 01:08:15 +00:00
if (isDirectMessage) {
const directUserId = message.user;
if (!directUserId) {
logVerbose("slack: drop dm message (missing user id)");
return null;
}
const allowed = await authorizeSlackDirectMessage({
ctx,
accountId: account.accountId,
senderId: directUserId,
allowFromLower,
resolveSenderName: ctx.resolveUserName,
sendPairingReply: async (text) => {
await sendMessageSlack(message.channel, text, {
token: ctx.botToken,
client: ctx.app.client,
accountId: account.accountId,
});
},
onDisabled: () => {
logVerbose("slack: drop dm (dms disabled)");
},
onUnauthorized: ({ allowMatchMeta }) => {
logVerbose(
`Blocked unauthorized slack sender ${message.user} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`,
);
},
log: logVerbose,
});
if (!allowed) {
2026-01-14 01:08:15 +00:00
return null;
}
}
return {
senderId,
allowFromLower,
};
}
function resolveSlackRoutingContext(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
isDirectMessage: boolean;
isGroupDm: boolean;
isRoom: boolean;
isRoomish: boolean;
}): SlackRoutingContext {
const { ctx, account, message, isDirectMessage, isGroupDm, isRoom, isRoomish } = params;
2026-01-14 01:08:15 +00:00
const route = resolveAgentRoute({
cfg: ctx.cfg,
2026-01-14 01:08:15 +00:00
channel: "slack",
accountId: account.accountId,
teamId: ctx.teamId || undefined,
peer: {
refactor: unify peer kind to ChatType, rename dm to direct (#11881) * fix: use .js extension for ESM imports of RoutePeerKind The imports incorrectly used .ts extension which doesn't resolve with moduleResolution: NodeNext. Changed to .js and added 'type' import modifier. * fix tsconfig * refactor: unify peer kind to ChatType, rename dm to direct - Replace RoutePeerKind with ChatType throughout codebase - Change 'dm' literal values to 'direct' in routing/session keys - Keep backward compat: normalizeChatType accepts 'dm' -> 'direct' - Add ChatType export to plugin-sdk, deprecate RoutePeerKind - Update session key parsing to accept both 'dm' and 'direct' markers - Update all channel monitors and extensions to use ChatType BREAKING CHANGE: Session keys now use 'direct' instead of 'dm'. Existing 'dm' keys still work via backward compat layer. * fix tests * test: update session key expectations for dmdirect migration - Fix test expectations to expect :direct: in generated output - Add explicit backward compat test for normalizeChatType('dm') - Keep input test data with :dm: keys to verify backward compat * fix: accept legacy 'dm' in session key parsing for backward compat getDmHistoryLimitFromSessionKey now accepts both :dm: and :direct: to ensure old session keys continue to work correctly. * test: add explicit backward compat tests for dmdirect migration - session-key.test.ts: verify both :dm: and :direct: keys are valid - getDmHistoryLimitFromSessionKey: verify both formats work * feat: backward compat for resetByType.dm config key * test: skip unix-path Nix tests on Windows
2026-02-08 16:20:52 -08:00
kind: isDirectMessage ? "direct" : isRoom ? "channel" : "group",
2026-01-14 01:08:15 +00:00
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
},
});
const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel";
const replyToMode = resolveSlackReplyToMode(account, chatType);
const threadContext = resolveSlackThreadContext({ message, replyToMode });
2026-01-21 20:01:12 +00:00
const threadTs = threadContext.incomingThreadTs;
const isThreadReply = threadContext.isThreadReply;
// Keep true thread replies thread-scoped, but preserve channel-level sessions
// for top-level room turns when replyToMode is off.
fix(slack): use thread-level sessions for channels to prevent context mixing (#10686) * fix(slack): use thread-level sessions for channels to prevent context mixing All messages in a Slack channel share a single session, causing context from different threads to mix together. When users have multiple conversations in different threads of the same channel, the agent sees combined context from all threads, leading to confused responses. Session key was: `slack:channel:${channelId}` (no thread identifier) 1. **Thread-level session keys**: Each message in channels/groups now gets its own session based on thread_ts: - Thread replies: use the parent thread's ts - New messages: use the message's own ts (becomes thread root) - DMs: unchanged (no thread-level sessions needed) New session key format: `slack:channel:${channelId}:thread:${threadTs}` 2. **Increased thread cache TTL**: Changed from 60 seconds to 6 hours. Users often pause conversations, and the short TTL caused unnecessary API calls and thread resolution failures. 3. **Increased cache size**: Changed from 500 to 10,000 entries to support busy workspaces with many active threads. 1. Create two threads in the same Slack channel 2. In Thread A: tell the bot your name is "Alice" and ask about "billing" 3. In Thread B: tell the bot your name is "Bob" and ask about "API" 4. Reply in Thread A and ask "what's my name?" - should say "Alice" 5. Check sessions: each thread should have a unique session key with :thread: suffix Fixes context bleed issues related to #758 * fix(slack): also update resolveSlackSystemEventSessionKey for thread-level sessions The context.ts file has a separate function for resolving session keys for system events (reactions, file uploads, etc.). This also needs to support thread-level sessions to ensure all Slack events route to the correct thread-specific session. Added threadTs and messageTs parameters to resolveSlackSystemEventSessionKey and updated the implementation to use thread-level keys for channels/groups. * fix(slack): preserve DM thread sessions for thread replies The previous change broke thread-level sessions for DMs that have threads. DMs with parent_user_id should still get thread-level sessions. - For channels/groups: always use thread-level sessions - For DMs: use thread-level sessions only when isThreadReply is true * fix(slack): use thread-level sessionKey for previousTimestamp Fixes the bug where previousTimestamp was read from the base channel session key (route.sessionKey) instead of the resolved thread-level sessionKey. This caused the elapsed-time calculation in the inbound envelope to always pull from the channel session rather than the thread session. Also adds regression tests for the thread-level session key behavior. Co-authored-by: Tony Dehnke <tdehnke@gmail.com> * fix(slack): narrow #10686 to surgical thread-session patch * test(slack): satisfy context/account typing in thread-session tests * docs(changelog): record surgical slack thread-session fix --------- Co-authored-by: Pablo Carvalho <pablo@telnyx.com> Co-authored-by: Tony Dehnke <tdehnke@gmail.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 15:04:57 -03:00
// For DMs, preserve existing auto-thread behavior when replyToMode="all".
const autoThreadId =
!isThreadReply && replyToMode === "all" && threadContext.messageTs
? threadContext.messageTs
: undefined;
const roomThreadId =
isThreadReply && threadTs
fix(slack): use thread-level sessions for channels to prevent context mixing (#10686) * fix(slack): use thread-level sessions for channels to prevent context mixing All messages in a Slack channel share a single session, causing context from different threads to mix together. When users have multiple conversations in different threads of the same channel, the agent sees combined context from all threads, leading to confused responses. Session key was: `slack:channel:${channelId}` (no thread identifier) 1. **Thread-level session keys**: Each message in channels/groups now gets its own session based on thread_ts: - Thread replies: use the parent thread's ts - New messages: use the message's own ts (becomes thread root) - DMs: unchanged (no thread-level sessions needed) New session key format: `slack:channel:${channelId}:thread:${threadTs}` 2. **Increased thread cache TTL**: Changed from 60 seconds to 6 hours. Users often pause conversations, and the short TTL caused unnecessary API calls and thread resolution failures. 3. **Increased cache size**: Changed from 500 to 10,000 entries to support busy workspaces with many active threads. 1. Create two threads in the same Slack channel 2. In Thread A: tell the bot your name is "Alice" and ask about "billing" 3. In Thread B: tell the bot your name is "Bob" and ask about "API" 4. Reply in Thread A and ask "what's my name?" - should say "Alice" 5. Check sessions: each thread should have a unique session key with :thread: suffix Fixes context bleed issues related to #758 * fix(slack): also update resolveSlackSystemEventSessionKey for thread-level sessions The context.ts file has a separate function for resolving session keys for system events (reactions, file uploads, etc.). This also needs to support thread-level sessions to ensure all Slack events route to the correct thread-specific session. Added threadTs and messageTs parameters to resolveSlackSystemEventSessionKey and updated the implementation to use thread-level keys for channels/groups. * fix(slack): preserve DM thread sessions for thread replies The previous change broke thread-level sessions for DMs that have threads. DMs with parent_user_id should still get thread-level sessions. - For channels/groups: always use thread-level sessions - For DMs: use thread-level sessions only when isThreadReply is true * fix(slack): use thread-level sessionKey for previousTimestamp Fixes the bug where previousTimestamp was read from the base channel session key (route.sessionKey) instead of the resolved thread-level sessionKey. This caused the elapsed-time calculation in the inbound envelope to always pull from the channel session rather than the thread session. Also adds regression tests for the thread-level session key behavior. Co-authored-by: Tony Dehnke <tdehnke@gmail.com> * fix(slack): narrow #10686 to surgical thread-session patch * test(slack): satisfy context/account typing in thread-session tests * docs(changelog): record surgical slack thread-session fix --------- Co-authored-by: Pablo Carvalho <pablo@telnyx.com> Co-authored-by: Tony Dehnke <tdehnke@gmail.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 15:04:57 -03:00
? threadTs
: replyToMode === "off"
? undefined
: threadContext.messageTs;
const canonicalThreadId = isRoomish ? roomThreadId : isThreadReply ? threadTs : autoThreadId;
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
fix(slack): use thread-level sessions for channels to prevent context mixing (#10686) * fix(slack): use thread-level sessions for channels to prevent context mixing All messages in a Slack channel share a single session, causing context from different threads to mix together. When users have multiple conversations in different threads of the same channel, the agent sees combined context from all threads, leading to confused responses. Session key was: `slack:channel:${channelId}` (no thread identifier) 1. **Thread-level session keys**: Each message in channels/groups now gets its own session based on thread_ts: - Thread replies: use the parent thread's ts - New messages: use the message's own ts (becomes thread root) - DMs: unchanged (no thread-level sessions needed) New session key format: `slack:channel:${channelId}:thread:${threadTs}` 2. **Increased thread cache TTL**: Changed from 60 seconds to 6 hours. Users often pause conversations, and the short TTL caused unnecessary API calls and thread resolution failures. 3. **Increased cache size**: Changed from 500 to 10,000 entries to support busy workspaces with many active threads. 1. Create two threads in the same Slack channel 2. In Thread A: tell the bot your name is "Alice" and ask about "billing" 3. In Thread B: tell the bot your name is "Bob" and ask about "API" 4. Reply in Thread A and ask "what's my name?" - should say "Alice" 5. Check sessions: each thread should have a unique session key with :thread: suffix Fixes context bleed issues related to #758 * fix(slack): also update resolveSlackSystemEventSessionKey for thread-level sessions The context.ts file has a separate function for resolving session keys for system events (reactions, file uploads, etc.). This also needs to support thread-level sessions to ensure all Slack events route to the correct thread-specific session. Added threadTs and messageTs parameters to resolveSlackSystemEventSessionKey and updated the implementation to use thread-level keys for channels/groups. * fix(slack): preserve DM thread sessions for thread replies The previous change broke thread-level sessions for DMs that have threads. DMs with parent_user_id should still get thread-level sessions. - For channels/groups: always use thread-level sessions - For DMs: use thread-level sessions only when isThreadReply is true * fix(slack): use thread-level sessionKey for previousTimestamp Fixes the bug where previousTimestamp was read from the base channel session key (route.sessionKey) instead of the resolved thread-level sessionKey. This caused the elapsed-time calculation in the inbound envelope to always pull from the channel session rather than the thread session. Also adds regression tests for the thread-level session key behavior. Co-authored-by: Tony Dehnke <tdehnke@gmail.com> * fix(slack): narrow #10686 to surgical thread-session patch * test(slack): satisfy context/account typing in thread-session tests * docs(changelog): record surgical slack thread-session fix --------- Co-authored-by: Pablo Carvalho <pablo@telnyx.com> Co-authored-by: Tony Dehnke <tdehnke@gmail.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 15:04:57 -03:00
threadId: canonicalThreadId,
parentSessionKey: canonicalThreadId && ctx.threadInheritParent ? route.sessionKey : undefined,
});
const sessionKey = threadKeys.sessionKey;
const historyKey =
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
return {
route,
chatType,
replyToMode,
threadContext,
threadTs,
isThreadReply,
threadKeys,
sessionKey,
historyKey,
};
}
export async function prepareSlackMessage(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
}): Promise<PreparedSlackMessage | null> {
const { ctx, account, message, opts } = params;
const cfg = ctx.cfg;
const conversation = await resolveSlackConversationContext({ ctx, account, message });
const {
channelInfo,
channelName,
isDirectMessage,
isGroupDm,
isRoom,
isRoomish,
channelConfig,
isBotMessage,
} = conversation;
const authorization = await authorizeSlackInboundMessage({
ctx,
account,
message,
conversation,
});
if (!authorization) {
return null;
}
const { senderId, allowFromLower } = authorization;
const routing = resolveSlackRoutingContext({
ctx,
account,
message,
isDirectMessage,
isGroupDm,
isRoom,
isRoomish,
});
const {
route,
replyToMode,
threadContext,
threadTs,
isThreadReply,
threadKeys,
sessionKey,
historyKey,
} = routing;
const mentionRegexes = resolveCachedMentionRegexes(ctx, route.agentId);
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
const explicitlyMentioned = Boolean(
ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`),
);
2026-01-14 01:08:15 +00:00
const wasMentioned =
opts.wasMentioned ??
(!isDirectMessage &&
matchesMentionWithExplicit({
text: message.text ?? "",
mentionRegexes,
explicit: {
hasAnyMention,
isExplicitlyMentioned: explicitlyMentioned,
canResolveExplicit: Boolean(ctx.botUserId),
},
}));
const implicitMention = Boolean(
!isDirectMessage &&
2026-01-16 22:33:35 +00:00
ctx.botUserId &&
message.thread_ts &&
(message.parent_user_id === ctx.botUserId ||
hasSlackThreadParticipation(account.accountId, message.channel, message.thread_ts)),
);
2026-01-14 01:08:15 +00:00
let resolvedSenderName = message.username?.trim() || undefined;
const resolveSenderName = async (): Promise<string> => {
if (resolvedSenderName) {
return resolvedSenderName;
}
if (message.user) {
const sender = await ctx.resolveUserName(message.user);
const normalized = sender?.name?.trim();
if (normalized) {
resolvedSenderName = normalized;
return resolvedSenderName;
}
}
resolvedSenderName = message.user ?? message.bot_id ?? "unknown";
return resolvedSenderName;
};
const senderNameForAuth = ctx.allowNameMatching ? await resolveSenderName() : undefined;
2026-01-14 01:08:15 +00:00
const channelUserAuthorized = isRoom
? resolveSlackUserAllowed({
allowList: channelConfig?.users,
userId: senderId,
userName: senderNameForAuth,
allowNameMatching: ctx.allowNameMatching,
2026-01-14 01:08:15 +00:00
})
: true;
if (isRoom && !channelUserAuthorized) {
logVerbose(`Blocked unauthorized slack sender ${senderId} (not in channel users)`);
2026-01-14 01:08:15 +00:00
return null;
}
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: "slack",
});
// Strip Slack mentions (<@U123>) before command detection so "@Labrador /new" is recognized
const textForCommandDetection = stripSlackMentionsForCommandDetection(message.text ?? "");
const hasControlCommandInMessage = hasControlCommand(textForCommandDetection, cfg);
const ownerAuthorized = resolveSlackAllowListMatch({
allowList: allowFromLower,
id: senderId,
name: senderNameForAuth,
allowNameMatching: ctx.allowNameMatching,
}).allowed;
const channelUsersAllowlistConfigured =
isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
const channelCommandAuthorized =
isRoom && channelUsersAllowlistConfigured
? resolveSlackUserAllowed({
allowList: channelConfig?.users,
userId: senderId,
userName: senderNameForAuth,
allowNameMatching: ctx.allowNameMatching,
})
: false;
const commandGate = resolveControlCommandGate({
useAccessGroups: ctx.useAccessGroups,
authorizers: [
{ configured: allowFromLower.length > 0, allowed: ownerAuthorized },
{
configured: channelUsersAllowlistConfigured,
allowed: channelCommandAuthorized,
},
],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
if (isRoomish && commandGate.shouldBlock) {
2026-01-23 23:20:07 +00:00
logInboundDrop({
log: logVerbose,
channel: "slack",
reason: "control command (unauthorized)",
target: senderId,
});
return null;
}
const shouldRequireMention = isRoom
? (channelConfig?.requireMention ?? ctx.defaultRequireMention)
: false;
2026-01-14 01:08:15 +00:00
// Allow "control commands" to bypass mention gating if sender is authorized.
const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0;
const mentionGate = resolveMentionGatingWithBypass({
isGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
wasMentioned,
implicitMention,
hasAnyMention,
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isRoom && shouldRequireMention && mentionGate.shouldSkip) {
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
const pendingText = (message.text ?? "").trim();
const fallbackFile = message.files?.[0]?.name
? `[Slack file: ${message.files[0].name}]`
: message.files?.length
? "[Slack file]"
: "";
const pendingBody = pendingText || fallbackFile;
recordPendingHistoryEntryIfEnabled({
historyMap: ctx.channelHistories,
historyKey,
limit: ctx.historyLimit,
entry: pendingBody
? {
sender: await resolveSenderName(),
body: pendingBody,
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
messageId: message.ts,
}
: null,
});
2026-01-14 01:08:15 +00:00
return null;
}
const threadStarter =
isThreadReply && threadTs
? await resolveSlackThreadStarter({
channelId: message.channel,
threadTs,
client: ctx.app.client,
})
: null;
const resolvedMessageContent = await resolveSlackMessageContent({
message,
isThreadReply,
threadStarter,
isBotMessage,
botToken: ctx.botToken,
mediaMaxBytes: ctx.mediaMaxBytes,
});
if (!resolvedMessageContent) {
return null;
}
const { rawBody, effectiveDirectMedia } = resolvedMessageContent;
2026-01-14 01:08:15 +00:00
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "slack",
accountId: account.accountId,
});
2026-01-14 01:08:15 +00:00
const ackReactionValue = ackReaction ?? "";
const shouldAckReaction = () =>
Boolean(
ackReaction &&
shouldAckReactionGate({
scope: ctx.ackReactionScope as AckReactionScope | undefined,
isDirect: isDirectMessage,
isGroup: isRoomish,
isMentionableGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned,
shouldBypassMention: mentionGate.shouldBypassMention,
}),
);
2026-01-14 01:08:15 +00:00
const ackReactionMessageTs = message.ts;
const ackReactionPromise =
shouldAckReaction() && ackReactionMessageTs && ackReactionValue
? reactSlackMessage(message.channel, ackReactionMessageTs, ackReactionValue, {
token: ctx.botToken,
client: ctx.app.client,
}).then(
2026-01-14 01:08:15 +00:00
() => true,
(err) => {
logVerbose(`slack react failed for channel ${message.channel}: ${String(err)}`);
2026-01-14 01:08:15 +00:00
return false;
},
)
: null;
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
const senderName = await resolveSenderName();
2026-01-14 01:08:15 +00:00
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isDirectMessage
? `Slack DM from ${senderName}`
: `Slack message in ${roomLabel} from ${senderName}`;
const slackFrom = isDirectMessage
? `slack:${message.user}`
: isRoom
? `slack:channel:${message.channel}`
: `slack:group:${message.channel}`;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey,
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
});
2026-01-17 04:04:05 +00:00
const envelopeFrom =
resolveConversationLabel({
ChatType: isDirectMessage ? "direct" : "channel",
SenderName: senderName,
GroupSubject: isRoomish ? roomLabel : undefined,
From: slackFrom,
}) ?? (isDirectMessage ? senderName : roomLabel);
const threadInfo =
isThreadReply && threadTs
? ` thread_ts: ${threadTs}${message.parent_user_id ? ` parent_user_id: ${message.parent_user_id}` : ""}`
: "";
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}${threadInfo}]`;
const storePath = resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
fix(slack): use thread-level sessions for channels to prevent context mixing (#10686) * fix(slack): use thread-level sessions for channels to prevent context mixing All messages in a Slack channel share a single session, causing context from different threads to mix together. When users have multiple conversations in different threads of the same channel, the agent sees combined context from all threads, leading to confused responses. Session key was: `slack:channel:${channelId}` (no thread identifier) 1. **Thread-level session keys**: Each message in channels/groups now gets its own session based on thread_ts: - Thread replies: use the parent thread's ts - New messages: use the message's own ts (becomes thread root) - DMs: unchanged (no thread-level sessions needed) New session key format: `slack:channel:${channelId}:thread:${threadTs}` 2. **Increased thread cache TTL**: Changed from 60 seconds to 6 hours. Users often pause conversations, and the short TTL caused unnecessary API calls and thread resolution failures. 3. **Increased cache size**: Changed from 500 to 10,000 entries to support busy workspaces with many active threads. 1. Create two threads in the same Slack channel 2. In Thread A: tell the bot your name is "Alice" and ask about "billing" 3. In Thread B: tell the bot your name is "Bob" and ask about "API" 4. Reply in Thread A and ask "what's my name?" - should say "Alice" 5. Check sessions: each thread should have a unique session key with :thread: suffix Fixes context bleed issues related to #758 * fix(slack): also update resolveSlackSystemEventSessionKey for thread-level sessions The context.ts file has a separate function for resolving session keys for system events (reactions, file uploads, etc.). This also needs to support thread-level sessions to ensure all Slack events route to the correct thread-specific session. Added threadTs and messageTs parameters to resolveSlackSystemEventSessionKey and updated the implementation to use thread-level keys for channels/groups. * fix(slack): preserve DM thread sessions for thread replies The previous change broke thread-level sessions for DMs that have threads. DMs with parent_user_id should still get thread-level sessions. - For channels/groups: always use thread-level sessions - For DMs: use thread-level sessions only when isThreadReply is true * fix(slack): use thread-level sessionKey for previousTimestamp Fixes the bug where previousTimestamp was read from the base channel session key (route.sessionKey) instead of the resolved thread-level sessionKey. This caused the elapsed-time calculation in the inbound envelope to always pull from the channel session rather than the thread session. Also adds regression tests for the thread-level session key behavior. Co-authored-by: Tony Dehnke <tdehnke@gmail.com> * fix(slack): narrow #10686 to surgical thread-session patch * test(slack): satisfy context/account typing in thread-session tests * docs(changelog): record surgical slack thread-session fix --------- Co-authored-by: Pablo Carvalho <pablo@telnyx.com> Co-authored-by: Tony Dehnke <tdehnke@gmail.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 15:04:57 -03:00
sessionKey,
});
2026-01-17 05:21:02 +00:00
const body = formatInboundEnvelope({
2026-01-14 01:08:15 +00:00
channel: "Slack",
from: envelopeFrom,
2026-01-14 01:08:15 +00:00
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
body: textWithId,
2026-01-17 05:21:02 +00:00
chatType: isDirectMessage ? "direct" : "channel",
sender: { name: senderName, id: senderId },
previousTimestamp,
envelope: envelopeOptions,
2026-01-14 01:08:15 +00:00
});
let combinedBody = body;
if (isRoomish && ctx.historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({
2026-01-14 01:08:15 +00:00
historyMap: ctx.channelHistories,
historyKey,
limit: ctx.historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
2026-01-18 19:36:46 +00:00
formatInboundEnvelope({
channel: "Slack",
from: roomLabel,
timestamp: entry.timestamp,
body: `${entry.body}${
entry.messageId ? ` [id:${entry.messageId} channel:${message.channel}]` : ""
}`,
chatType: "channel",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
2026-01-14 01:08:15 +00:00
}
const slackTo = isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`;
2026-01-14 01:08:15 +00:00
const { untrustedChannelMetadata, groupSystemPrompt } = resolveSlackRoomContextHints({
isRoomish,
channelInfo,
channelConfig,
});
2026-01-14 01:08:15 +00:00
const {
threadStarterBody,
threadHistoryBody,
threadSessionPreviousTimestamp,
threadLabel,
threadStarterMedia,
} = await resolveSlackThreadContextData({
ctx,
account,
message,
isThreadReply,
threadTs,
threadStarter,
roomLabel,
storePath,
sessionKey,
envelopeOptions,
effectiveDirectMedia,
});
2026-01-14 01:08:15 +00:00
// Use direct media (including forwarded attachment media) if available, else thread starter media
const effectiveMedia = effectiveDirectMedia ?? threadStarterMedia;
2026-02-14 20:16:02 +07:00
const firstMedia = effectiveMedia?.[0];
const inboundHistory =
isRoomish && ctx.historyLimit > 0
? (ctx.channelHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const commandBody = textForCommandDetection.trim();
2026-01-17 05:04:29 +00:00
const ctxPayload = finalizeInboundContext({
2026-01-14 01:08:15 +00:00
Body: combinedBody,
BodyForAgent: rawBody,
InboundHistory: inboundHistory,
2026-01-14 01:08:15 +00:00
RawBody: rawBody,
CommandBody: commandBody,
BodyForCommands: commandBody,
2026-01-14 01:08:15 +00:00
From: slackFrom,
To: slackTo,
SessionKey: sessionKey,
AccountId: route.accountId,
2026-01-17 04:04:05 +00:00
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: envelopeFrom,
2026-01-14 01:08:15 +00:00
GroupSubject: isRoomish ? roomLabel : undefined,
GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined,
UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined,
2026-01-14 01:08:15 +00:00
SenderName: senderName,
SenderId: senderId,
Provider: "slack" as const,
Surface: "slack" as const,
MessageSid: message.ts,
2026-01-21 20:01:12 +00:00
ReplyToId: threadContext.replyToId,
// Preserve thread context for routed tool notifications.
MessageThreadId: threadContext.messageThreadId,
2026-01-14 01:08:15 +00:00
ParentSessionKey: threadKeys.parentSessionKey,
// Only include thread starter body for NEW sessions (existing sessions already have it in their transcript)
ThreadStarterBody: !threadSessionPreviousTimestamp ? threadStarterBody : undefined,
fix(slack): populate thread session with existing thread history (#7610) * feat(slack): populate thread session with existing thread history When a new session is created for a Slack thread, fetch and inject the full thread history as context. This preserves conversation continuity so the bot knows what it previously said in the thread. - Add resolveSlackThreadHistory() to fetch all thread messages - Add ThreadHistoryBody to context payload - Use thread history instead of just thread starter for new sessions Fixes #4470 * chore: remove redundant comments * fix: use threadContextNote in queue body * fix(slack): address Greptile review feedback - P0: Use thread session key (not base session key) for new-session check This ensures thread history is injected when the thread session is new, even if the base channel session already exists. - P1: Fetch up to 200 messages and take the most recent N Slack API returns messages in chronological order (oldest first). Previously we took the first N, now we take the last N for relevant context. - P1: Batch resolve user names with Promise.all Avoid N sequential API calls when resolving user names in thread history. - P2: Include file-only messages in thread history Messages with attachments but no text are now included with a placeholder like '[attached: image.png, document.pdf]'. - P2: Add documentation about intentional 200-message fetch limit Clarifies that we intentionally don't paginate; 200 covers most threads. * style: add braces for curly lint rule * feat(slack): add thread.initialHistoryLimit config option Allow users to configure the maximum number of thread messages to fetch when starting a new thread session. Defaults to 20. Set to 0 to disable thread history fetching entirely. This addresses the optional configuration request from #2608. * chore: trigger CI * fix(slack): ensure isNewSession=true on first thread turn recordInboundSession() in prepare.ts creates the thread session entry before session.ts reads the store, causing isNewSession to be false on the very first user message in a thread. This prevented thread context (history/starter) from being injected. Add IsFirstThreadTurn flag to message context, set when readSessionUpdatedAt() returns undefined for the thread session key. session.ts uses this flag to force isNewSession=true. * style: format prepare.ts for oxfmt * fix: suppress InboundHistory/ThreadStarterBody when ThreadHistoryBody present (#13912) When ThreadHistoryBody is fetched from the Slack API (conversations.replies), it already contains pending messages and the thread starter. Passing both InboundHistory and ThreadStarterBody alongside ThreadHistoryBody caused duplicate content in the LLM context on new thread sessions. Suppress InboundHistory and ThreadStarterBody when ThreadHistoryBody is present, since it is a strict superset of both. * remove verbose comment * fix(slack): paginate thread history context fetch * fix(slack): wire session file path options after main merge --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 13:51:04 +09:00
ThreadHistoryBody: threadHistoryBody,
IsFirstThreadTurn:
isThreadReply && threadTs && !threadSessionPreviousTimestamp ? true : undefined,
2026-01-14 01:08:15 +00:00
ThreadLabel: threadLabel,
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
2026-02-14 20:16:02 +07:00
MediaPath: firstMedia?.path,
MediaType: firstMedia?.contentType,
2026-02-14 20:16:02 +07:00
MediaUrl: firstMedia?.path,
MediaPaths:
effectiveMedia && effectiveMedia.length > 0 ? effectiveMedia.map((m) => m.path) : undefined,
MediaUrls:
effectiveMedia && effectiveMedia.length > 0 ? effectiveMedia.map((m) => m.path) : undefined,
MediaTypes:
effectiveMedia && effectiveMedia.length > 0
? effectiveMedia.map((m) => m.contentType ?? "")
2026-02-14 20:16:02 +07:00
: undefined,
2026-01-17 10:24:11 +00:00
CommandAuthorized: commandAuthorized,
OriginatingChannel: "slack" as const,
OriginatingTo: slackTo,
}) satisfies FinalizedMsgContext;
const pinnedMainDmOwner = isDirectMessage
? resolvePinnedMainDmOwnerFromAllowlist({
dmScope: cfg.session?.dmScope,
allowFrom: ctx.allowFrom,
normalizeEntry: normalizeSlackAllowOwnerEntry,
})
: null;
2026-01-14 01:08:15 +00:00
await recordInboundSession({
storePath,
sessionKey,
ctx: ctxPayload,
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "slack",
to: `user:${message.user}`,
accountId: route.accountId,
threadId: threadContext.messageThreadId,
mainDmOwnerPin:
pinnedMainDmOwner && message.user
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: message.user.toLowerCase(),
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`slack: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
ctx.logger.warn(
{
error: String(err),
storePath,
sessionKey,
},
"failed updating session meta",
);
},
});
2026-01-14 01:08:15 +00:00
const replyTarget = ctxPayload.To ?? undefined;
if (!replyTarget) {
return null;
}
2026-01-14 01:08:15 +00:00
if (shouldLogVerbose()) {
logVerbose(`slack inbound: channel=${message.channel} from=${slackFrom} preview="${preview}"`);
2026-01-14 01:08:15 +00:00
}
return {
ctx,
account,
message,
route,
channelConfig,
replyTarget,
ctxPayload,
replyToMode,
2026-01-14 01:08:15 +00:00
isDirectMessage,
isRoomish,
historyKey,
preview,
ackReactionMessageTs,
ackReactionValue,
ackReactionPromise,
};
}