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,
|
2026-01-18 18:42:34 +00:00
|
|
|
resolveEnvelopeFormatOptions,
|
2026-01-17 05:21:02 +00:00
|
|
|
} from "../../../auto-reply/envelope.js";
|
2026-01-16 23:52:14 +00:00
|
|
|
import {
|
|
|
|
|
buildPendingHistoryContextFromMap,
|
2026-01-23 22:36:43 +00:00
|
|
|
recordPendingHistoryEntryIfEnabled,
|
2026-01-16 23:52:14 +00:00
|
|
|
} from "../../../auto-reply/reply/history.js";
|
2026-01-17 05:04:29 +00:00
|
|
|
import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js";
|
2026-01-24 11:09:15 +00:00
|
|
|
import {
|
|
|
|
|
buildMentionRegexes,
|
|
|
|
|
matchesMentionWithExplicit,
|
|
|
|
|
} from "../../../auto-reply/reply/mentions.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
import type { FinalizedMsgContext } from "../../../auto-reply/templating.js";
|
2026-01-23 22:17:14 +00:00
|
|
|
import {
|
|
|
|
|
shouldAckReaction as shouldAckReactionGate,
|
|
|
|
|
type AckReactionScope,
|
|
|
|
|
} from "../../../channels/ack-reactions.js";
|
2026-01-18 01:21:27 +00:00
|
|
|
import { resolveControlCommandGate } from "../../../channels/command-gating.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { resolveConversationLabel } from "../../../channels/conversation-label.js";
|
2026-01-23 23:20:07 +00:00
|
|
|
import { logInboundDrop } from "../../../channels/logging.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js";
|
2026-01-23 22:48:03 +00:00
|
|
|
import { recordInboundSession } from "../../../channels/session.js";
|
|
|
|
|
import { readSessionUpdatedAt, resolveStorePath } from "../../../config/sessions.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
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";
|
2026-03-02 21:22:32 +00:00
|
|
|
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../security/dm-policy-shared.js";
|
2026-03-01 08:21:01 -08:00
|
|
|
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";
|
2026-03-01 12:42:12 -04:00
|
|
|
import { hasSlackThreadParticipation } from "../../sent-thread-cache.js";
|
2026-01-21 20:01:12 +00:00
|
|
|
import { resolveSlackThreadContext } from "../../threading.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
import type { SlackMessageEvent } from "../../types.js";
|
2026-03-02 21:22:32 +00:00
|
|
|
import {
|
|
|
|
|
normalizeSlackAllowOwnerEntry,
|
|
|
|
|
resolveSlackAllowListMatch,
|
|
|
|
|
resolveSlackUserAllowed,
|
|
|
|
|
} from "../allow-list.js";
|
2026-01-17 05:25:37 +00:00
|
|
|
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { resolveSlackChannelConfig } from "../channel-config.js";
|
2026-02-12 01:41:48 +09:00
|
|
|
import { stripSlackMentionsForCommandDetection } from "../commands.js";
|
2026-01-15 08:00:07 +00:00
|
|
|
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js";
|
2026-02-26 22:36:05 +01:00
|
|
|
import { authorizeSlackDirectMessage } from "../dm-auth.js";
|
2026-03-02 22:30:15 +00:00
|
|
|
import { resolveSlackThreadStarter } from "../media.js";
|
2026-02-15 17:06:17 +00:00
|
|
|
import { resolveSlackRoomContextHints } from "../room-context.js";
|
2026-03-02 22:30:15 +00:00
|
|
|
import { resolveSlackMessageContent } from "./prepare-content.js";
|
|
|
|
|
import { resolveSlackThreadContextData } from "./prepare-thread-context.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
import type { PreparedSlackMessage } from "./types.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-03-02 20:18:41 +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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 21:54:53 +00:00
|
|
|
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;
|
2026-03-02 21:54:53 +00:00
|
|
|
}): 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;
|
|
|
|
|
} = {};
|
2026-03-02 19:47:38 +00:00
|
|
|
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);
|
2026-03-02 19:47:38 +00:00
|
|
|
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";
|
2026-01-14 14:31:43 +00:00
|
|
|
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,
|
2026-03-02 21:07:34 +00:00
|
|
|
channelKeys: ctx.channelsConfigKeys,
|
2026-01-13 14:21:23 +00:00
|
|
|
defaultRequireMention: ctx.defaultRequireMention,
|
2026-01-14 01:08:15 +00:00
|
|
|
})
|
|
|
|
|
: null;
|
|
|
|
|
const allowBots =
|
|
|
|
|
channelConfig?.allowBots ??
|
|
|
|
|
account.config?.allowBots ??
|
|
|
|
|
cfg.channels?.slack?.allowBots ??
|
|
|
|
|
false;
|
|
|
|
|
|
2026-03-02 21:54:53 +00:00
|
|
|
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) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (message.user && ctx.botUserId && message.user === ctx.botUserId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
if (!allowBots) {
|
2026-01-14 14:31:43 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 18:15:57 +01:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-26 22:36:05 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 21:54:53 +00:00
|
|
|
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({
|
2026-03-02 21:54:53 +00:00
|
|
|
cfg: ctx.cfg,
|
2026-01-14 01:08:15 +00:00
|
|
|
channel: "slack",
|
|
|
|
|
accountId: account.accountId,
|
|
|
|
|
teamId: ctx.teamId || undefined,
|
|
|
|
|
peer: {
|
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,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-01 08:21:01 -08:00
|
|
|
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;
|
2026-03-02 14:36:11 -07:00
|
|
|
// Keep true thread replies thread-scoped, but preserve channel-level sessions
|
|
|
|
|
// for top-level room turns when replyToMode is off.
|
2026-03-01 15:04:57 -03:00
|
|
|
// For DMs, preserve existing auto-thread behavior when replyToMode="all".
|
2026-03-01 09:24:45 -08:00
|
|
|
const autoThreadId =
|
|
|
|
|
!isThreadReply && replyToMode === "all" && threadContext.messageTs
|
|
|
|
|
? threadContext.messageTs
|
|
|
|
|
: undefined;
|
2026-03-02 14:36:11 -07:00
|
|
|
const roomThreadId =
|
|
|
|
|
isThreadReply && threadTs
|
2026-03-01 15:04:57 -03:00
|
|
|
? threadTs
|
2026-03-02 14:36:11 -07:00
|
|
|
: replyToMode === "off"
|
|
|
|
|
? undefined
|
|
|
|
|
: threadContext.messageTs;
|
|
|
|
|
const canonicalThreadId = isRoomish ? roomThreadId : isThreadReply ? threadTs : autoThreadId;
|
2026-01-16 23:52:14 +00:00
|
|
|
const threadKeys = resolveThreadSessionKeys({
|
2026-03-02 21:54:53 +00:00
|
|
|
baseSessionKey: route.sessionKey,
|
2026-03-01 15:04:57 -03:00
|
|
|
threadId: canonicalThreadId,
|
2026-03-02 21:54:53 +00:00
|
|
|
parentSessionKey: canonicalThreadId && ctx.threadInheritParent ? route.sessionKey : undefined,
|
2026-01-16 23:52:14 +00:00
|
|
|
});
|
|
|
|
|
const sessionKey = threadKeys.sessionKey;
|
|
|
|
|
const historyKey =
|
|
|
|
|
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
|
|
|
|
|
|
2026-03-02 21:54:53 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-03-02 20:18:41 +00:00
|
|
|
const mentionRegexes = resolveCachedMentionRegexes(ctx, route.agentId);
|
2026-01-24 11:09:15 +00:00
|
|
|
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 &&
|
2026-01-24 11:09:15 +00:00
|
|
|
matchesMentionWithExplicit({
|
|
|
|
|
text: message.text ?? "",
|
|
|
|
|
mentionRegexes,
|
|
|
|
|
explicit: {
|
|
|
|
|
hasAnyMention,
|
|
|
|
|
isExplicitlyMentioned: explicitlyMentioned,
|
|
|
|
|
canResolveExplicit: Boolean(ctx.botUserId),
|
|
|
|
|
},
|
|
|
|
|
}));
|
2026-01-16 21:50:44 +00:00
|
|
|
const implicitMention = Boolean(
|
|
|
|
|
!isDirectMessage &&
|
2026-01-16 22:33:35 +00:00
|
|
|
ctx.botUserId &&
|
|
|
|
|
message.thread_ts &&
|
2026-03-01 12:42:12 -04:00
|
|
|
(message.parent_user_id === ctx.botUserId ||
|
|
|
|
|
hasSlackThreadParticipation(account.accountId, message.channel, message.thread_ts)),
|
2026-01-16 21:50:44 +00:00
|
|
|
);
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-03-02 21:07:34 +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,
|
2026-03-02 21:07:34 +00:00
|
|
|
userName: senderNameForAuth,
|
2026-02-24 01:01:51 +00:00
|
|
|
allowNameMatching: ctx.allowNameMatching,
|
2026-01-14 01:08:15 +00:00
|
|
|
})
|
|
|
|
|
: true;
|
|
|
|
|
if (isRoom && !channelUserAuthorized) {
|
2026-01-14 14:31:43 +00:00
|
|
|
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",
|
|
|
|
|
});
|
2026-02-12 01:41:48 +09:00
|
|
|
// Strip Slack mentions (<@U123>) before command detection so "@Labrador /new" is recognized
|
|
|
|
|
const textForCommandDetection = stripSlackMentionsForCommandDetection(message.text ?? "");
|
|
|
|
|
const hasControlCommandInMessage = hasControlCommand(textForCommandDetection, cfg);
|
2026-01-17 05:25:37 +00:00
|
|
|
|
2026-01-18 00:14:41 +00:00
|
|
|
const ownerAuthorized = resolveSlackAllowListMatch({
|
2026-01-17 05:25:37 +00:00
|
|
|
allowList: allowFromLower,
|
|
|
|
|
id: senderId,
|
2026-03-02 21:07:34 +00:00
|
|
|
name: senderNameForAuth,
|
2026-02-24 01:01:51 +00:00
|
|
|
allowNameMatching: ctx.allowNameMatching,
|
2026-01-18 00:14:41 +00:00
|
|
|
}).allowed;
|
2026-01-17 05:25:37 +00:00
|
|
|
const channelUsersAllowlistConfigured =
|
|
|
|
|
isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
|
|
|
|
|
const channelCommandAuthorized =
|
|
|
|
|
isRoom && channelUsersAllowlistConfigured
|
|
|
|
|
? resolveSlackUserAllowed({
|
|
|
|
|
allowList: channelConfig?.users,
|
|
|
|
|
userId: senderId,
|
2026-03-02 21:07:34 +00:00
|
|
|
userName: senderNameForAuth,
|
2026-02-24 01:01:51 +00:00
|
|
|
allowNameMatching: ctx.allowNameMatching,
|
2026-01-17 05:25:37 +00:00
|
|
|
})
|
|
|
|
|
: false;
|
2026-01-18 01:21:27 +00:00
|
|
|
const commandGate = resolveControlCommandGate({
|
2026-01-17 06:49:17 +00:00
|
|
|
useAccessGroups: ctx.useAccessGroups,
|
|
|
|
|
authorizers: [
|
|
|
|
|
{ configured: allowFromLower.length > 0, allowed: ownerAuthorized },
|
2026-03-01 12:42:12 -04:00
|
|
|
{
|
|
|
|
|
configured: channelUsersAllowlistConfigured,
|
|
|
|
|
allowed: channelCommandAuthorized,
|
|
|
|
|
},
|
2026-01-17 06:49:17 +00:00
|
|
|
],
|
2026-01-18 01:21:27 +00:00
|
|
|
allowTextCommands,
|
|
|
|
|
hasControlCommand: hasControlCommandInMessage,
|
2026-01-17 06:49:17 +00:00
|
|
|
});
|
2026-01-18 01:21:27 +00:00
|
|
|
const commandAuthorized = commandGate.commandAuthorized;
|
2026-01-17 05:25:37 +00:00
|
|
|
|
2026-01-18 01:21:27 +00:00
|
|
|
if (isRoomish && commandGate.shouldBlock) {
|
2026-01-23 23:20:07 +00:00
|
|
|
logInboundDrop({
|
|
|
|
|
log: logVerbose,
|
|
|
|
|
channel: "slack",
|
|
|
|
|
reason: "control command (unauthorized)",
|
|
|
|
|
target: senderId,
|
|
|
|
|
});
|
2026-01-17 05:25:37 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 14:21:23 +00:00
|
|
|
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;
|
2026-01-18 01:21:27 +00:00
|
|
|
const mentionGate = resolveMentionGatingWithBypass({
|
|
|
|
|
isGroup: isRoom,
|
2026-01-16 21:50:44 +00:00
|
|
|
requireMention: Boolean(shouldRequireMention),
|
|
|
|
|
canDetectMention,
|
|
|
|
|
wasMentioned,
|
|
|
|
|
implicitMention,
|
2026-01-18 01:21:27 +00:00
|
|
|
hasAnyMention,
|
|
|
|
|
allowTextCommands,
|
|
|
|
|
hasControlCommand: hasControlCommandInMessage,
|
|
|
|
|
commandAuthorized,
|
2026-01-16 21:50:44 +00:00
|
|
|
});
|
|
|
|
|
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
|
|
|
|
if (isRoom && shouldRequireMention && mentionGate.shouldSkip) {
|
2026-01-17 06:48:34 +00:00
|
|
|
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
|
2026-01-23 22:36:43 +00:00
|
|
|
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
|
|
|
|
|
? {
|
2026-03-02 21:07:34 +00:00
|
|
|
sender: await resolveSenderName(),
|
2026-01-16 23:52:14 +00:00
|
|
|
body: pendingBody,
|
|
|
|
|
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
|
|
|
|
messageId: message.ts,
|
2026-01-23 22:36:43 +00:00
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 22:30:15 +00:00
|
|
|
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,
|
2026-02-17 00:44:41 +02:00
|
|
|
});
|
2026-03-02 22:30:15 +00:00
|
|
|
if (!resolvedMessageContent) {
|
2026-01-31 16:19:20 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
2026-03-02 22:30:15 +00:00
|
|
|
const { rawBody, effectiveDirectMedia } = resolvedMessageContent;
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-02-15 11:29:51 -06:00
|
|
|
const ackReaction = resolveAckReaction(cfg, route.agentId, {
|
|
|
|
|
channel: "slack",
|
|
|
|
|
accountId: account.accountId,
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
const ackReactionValue = ackReaction ?? "";
|
|
|
|
|
|
2026-01-23 22:17:14 +00:00
|
|
|
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
|
2026-01-14 14:31:43 +00:00
|
|
|
? reactSlackMessage(message.channel, ackReactionMessageTs, ackReactionValue, {
|
|
|
|
|
token: ctx.botToken,
|
|
|
|
|
client: ctx.app.client,
|
|
|
|
|
}).then(
|
2026-01-14 01:08:15 +00:00
|
|
|
() => true,
|
|
|
|
|
(err) => {
|
2026-01-14 14:31:43 +00:00
|
|
|
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}`;
|
2026-03-02 21:07:34 +00:00
|
|
|
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);
|
2026-02-13 05:16:24 +01:00
|
|
|
const threadInfo =
|
|
|
|
|
isThreadReply && threadTs
|
|
|
|
|
? ` thread_ts: ${threadTs}${message.parent_user_id ? ` parent_user_id: ${message.parent_user_id}` : ""}`
|
|
|
|
|
: "";
|
2026-02-12 07:13:58 -05:00
|
|
|
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}${threadInfo}]`;
|
2026-01-18 18:42:34 +00:00
|
|
|
const storePath = resolveStorePath(ctx.cfg.session?.store, {
|
|
|
|
|
agentId: route.agentId,
|
|
|
|
|
});
|
|
|
|
|
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
|
|
|
|
const previousTimestamp = readSessionUpdatedAt({
|
|
|
|
|
storePath,
|
2026-03-01 15:04:57 -03:00
|
|
|
sessionKey,
|
2026-01-18 18:42:34 +00:00
|
|
|
});
|
2026-01-17 05:21:02 +00:00
|
|
|
const body = formatInboundEnvelope({
|
2026-01-14 01:08:15 +00:00
|
|
|
channel: "Slack",
|
2026-01-17 03:31:57 +00:00
|
|
|
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 },
|
2026-01-18 18:42:34 +00:00
|
|
|
previousTimestamp,
|
|
|
|
|
envelope: envelopeOptions,
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let combinedBody = body;
|
|
|
|
|
if (isRoomish && ctx.historyLimit > 0) {
|
2026-01-16 23:52:14 +00:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
const slackTo = isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`;
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-02-15 17:06:17 +00:00
|
|
|
const { untrustedChannelMetadata, groupSystemPrompt } = resolveSlackRoomContextHints({
|
|
|
|
|
isRoomish,
|
|
|
|
|
channelInfo,
|
|
|
|
|
channelConfig,
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-03-02 22:30: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
|
|
|
|
2026-02-17 00:44:41 +02:00
|
|
|
// Use direct media (including forwarded attachment media) if available, else thread starter media
|
|
|
|
|
const effectiveMedia = effectiveDirectMedia ?? threadStarterMedia;
|
fix(slack): download all files in multi-image messages (#15447)
* fix(slack): download all files in multi-image messages
resolveSlackMedia() previously returned after downloading the first
file, causing multi-image Slack messages to lose all but the first
attachment. This changes the function to collect all successfully
downloaded files into an array, matching the pattern already used by
Telegram, Line, Discord, and iMessage adapters.
The prepare handler now populates MediaPaths, MediaUrls, and
MediaTypes arrays so downstream media processing (vision, sandbox
staging, media notes) works correctly with multiple attachments.
Fixes #11892, #7536
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(slack): preserve MediaTypes index alignment with MediaPaths/MediaUrls
The filter(Boolean) on MediaTypes removed entries with undefined contentType,
shrinking the array and breaking index correlation with MediaPaths and MediaUrls.
Downstream code (media-note.ts, attachments.ts) requires these arrays to have
equal lengths for correct per-attachment MIME type lookup. Replace filter(Boolean)
with a nullish coalescing fallback to "application/octet-stream".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(slack): align MediaType fallback and tests (#15447) (thanks @CommanderCrowCode)
* fix: unblock plugin-sdk account-id typing (#15447)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 20:16:02 +07:00
|
|
|
const firstMedia = effectiveMedia?.[0];
|
2026-01-22 21:55:03 -05:00
|
|
|
|
2026-02-10 00:35:56 -06:00
|
|
|
const inboundHistory =
|
|
|
|
|
isRoomish && ctx.historyLimit > 0
|
|
|
|
|
? (ctx.channelHistories.get(historyKey) ?? []).map((entry) => ({
|
|
|
|
|
sender: entry.sender,
|
|
|
|
|
body: entry.body,
|
|
|
|
|
timestamp: entry.timestamp,
|
|
|
|
|
}))
|
|
|
|
|
: undefined;
|
2026-03-01 23:11:20 +00:00
|
|
|
const commandBody = textForCommandDetection.trim();
|
2026-02-10 00:35:56 -06:00
|
|
|
|
2026-01-17 05:04:29 +00:00
|
|
|
const ctxPayload = finalizeInboundContext({
|
2026-01-14 01:08:15 +00:00
|
|
|
Body: combinedBody,
|
2026-02-10 00:35:56 -06:00
|
|
|
BodyForAgent: rawBody,
|
|
|
|
|
InboundHistory: inboundHistory,
|
2026-01-14 01:08:15 +00:00
|
|
|
RawBody: rawBody,
|
2026-03-01 23:11:20 +00:00
|
|
|
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,
|
2026-02-03 23:02:28 -08:00
|
|
|
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,
|
2026-03-02 15:11:10 -05:00
|
|
|
// Only include thread starter body for NEW sessions (existing sessions already have it in their transcript)
|
|
|
|
|
ThreadStarterBody: !threadSessionPreviousTimestamp ? threadStarterBody : undefined,
|
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,
|
fix(slack): download all files in multi-image messages (#15447)
* fix(slack): download all files in multi-image messages
resolveSlackMedia() previously returned after downloading the first
file, causing multi-image Slack messages to lose all but the first
attachment. This changes the function to collect all successfully
downloaded files into an array, matching the pattern already used by
Telegram, Line, Discord, and iMessage adapters.
The prepare handler now populates MediaPaths, MediaUrls, and
MediaTypes arrays so downstream media processing (vision, sandbox
staging, media notes) works correctly with multiple attachments.
Fixes #11892, #7536
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(slack): preserve MediaTypes index alignment with MediaPaths/MediaUrls
The filter(Boolean) on MediaTypes removed entries with undefined contentType,
shrinking the array and breaking index correlation with MediaPaths and MediaUrls.
Downstream code (media-note.ts, attachments.ts) requires these arrays to have
equal lengths for correct per-attachment MIME type lookup. Replace filter(Boolean)
with a nullish coalescing fallback to "application/octet-stream".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(slack): align MediaType fallback and tests (#15447) (thanks @CommanderCrowCode)
* fix: unblock plugin-sdk account-id typing (#15447)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 20:16:02 +07:00
|
|
|
MediaPath: firstMedia?.path,
|
2026-02-14 15:06:13 +01:00
|
|
|
MediaType: firstMedia?.contentType,
|
fix(slack): download all files in multi-image messages (#15447)
* fix(slack): download all files in multi-image messages
resolveSlackMedia() previously returned after downloading the first
file, causing multi-image Slack messages to lose all but the first
attachment. This changes the function to collect all successfully
downloaded files into an array, matching the pattern already used by
Telegram, Line, Discord, and iMessage adapters.
The prepare handler now populates MediaPaths, MediaUrls, and
MediaTypes arrays so downstream media processing (vision, sandbox
staging, media notes) works correctly with multiple attachments.
Fixes #11892, #7536
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(slack): preserve MediaTypes index alignment with MediaPaths/MediaUrls
The filter(Boolean) on MediaTypes removed entries with undefined contentType,
shrinking the array and breaking index correlation with MediaPaths and MediaUrls.
Downstream code (media-note.ts, attachments.ts) requires these arrays to have
equal lengths for correct per-attachment MIME type lookup. Replace filter(Boolean)
with a nullish coalescing fallback to "application/octet-stream".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(slack): align MediaType fallback and tests (#15447) (thanks @CommanderCrowCode)
* fix: unblock plugin-sdk account-id typing (#15447)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
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
|
2026-02-14 15:06:13 +01:00
|
|
|
? effectiveMedia.map((m) => m.contentType ?? "")
|
fix(slack): download all files in multi-image messages (#15447)
* fix(slack): download all files in multi-image messages
resolveSlackMedia() previously returned after downloading the first
file, causing multi-image Slack messages to lose all but the first
attachment. This changes the function to collect all successfully
downloaded files into an array, matching the pattern already used by
Telegram, Line, Discord, and iMessage adapters.
The prepare handler now populates MediaPaths, MediaUrls, and
MediaTypes arrays so downstream media processing (vision, sandbox
staging, media notes) works correctly with multiple attachments.
Fixes #11892, #7536
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(slack): preserve MediaTypes index alignment with MediaPaths/MediaUrls
The filter(Boolean) on MediaTypes removed entries with undefined contentType,
shrinking the array and breaking index correlation with MediaPaths and MediaUrls.
Downstream code (media-note.ts, attachments.ts) requires these arrays to have
equal lengths for correct per-attachment MIME type lookup. Replace filter(Boolean)
with a nullish coalescing fallback to "application/octet-stream".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(slack): align MediaType fallback and tests (#15447) (thanks @CommanderCrowCode)
* fix: unblock plugin-sdk account-id typing (#15447)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
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;
|
2026-03-02 21:22:32 +00:00
|
|
|
const pinnedMainDmOwner = isDirectMessage
|
|
|
|
|
? resolvePinnedMainDmOwnerFromAllowlist({
|
|
|
|
|
dmScope: cfg.session?.dmScope,
|
|
|
|
|
allowFrom: ctx.allowFrom,
|
|
|
|
|
normalizeEntry: normalizeSlackAllowOwnerEntry,
|
|
|
|
|
})
|
|
|
|
|
: null;
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-01-23 22:48:03 +00:00
|
|
|
await recordInboundSession({
|
2026-01-18 02:41:06 +00:00
|
|
|
storePath,
|
2026-01-23 22:48:03 +00:00
|
|
|
sessionKey,
|
2026-01-18 02:41:06 +00:00
|
|
|
ctx: ctxPayload,
|
2026-01-23 22:48:03 +00:00
|
|
|
updateLastRoute: isDirectMessage
|
|
|
|
|
? {
|
|
|
|
|
sessionKey: route.mainSessionKey,
|
|
|
|
|
channel: "slack",
|
|
|
|
|
to: `user:${message.user}`,
|
|
|
|
|
accountId: route.accountId,
|
2026-02-22 13:26:31 -05:00
|
|
|
threadId: threadContext.messageThreadId,
|
2026-03-02 21:22:32 +00:00
|
|
|
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,
|
2026-01-23 22:48:03 +00:00
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
onRecordError: (err) => {
|
|
|
|
|
ctx.logger.warn(
|
|
|
|
|
{
|
|
|
|
|
error: String(err),
|
|
|
|
|
storePath,
|
|
|
|
|
sessionKey,
|
|
|
|
|
},
|
|
|
|
|
"failed updating session meta",
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-01-18 02:41:06 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-14 01:08:15 +00:00
|
|
|
const replyTarget = ctxPayload.To ?? undefined;
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!replyTarget) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
|
|
|
|
|
if (shouldLogVerbose()) {
|
2026-01-14 14:31:43 +00:00
|
|
|
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,
|
2026-03-01 08:21:01 -08:00
|
|
|
replyToMode,
|
2026-01-14 01:08:15 +00:00
|
|
|
isDirectMessage,
|
|
|
|
|
isRoomish,
|
|
|
|
|
historyKey,
|
|
|
|
|
preview,
|
|
|
|
|
ackReactionMessageTs,
|
|
|
|
|
ackReactionValue,
|
|
|
|
|
ackReactionPromise,
|
|
|
|
|
};
|
|
|
|
|
}
|