* fix(slack): route system events via binding-aware session keys * fix(slack): pass sender to system event session resolver * fix(slack): include sender context for interaction session routing * fix(slack): include modal submitter in session routing * test(slack): cover binding-aware system event routing * test(slack): update interaction session key assertions * test(slack): assert reaction session routing carries sender * docs(changelog): note slack system event routing fix * Update CHANGELOG.md
432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
import type { App } from "@slack/bolt";
|
|
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
|
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
|
|
import type { OpenClawConfig, SlackReactionNotificationMode } from "../../config/config.js";
|
|
import { resolveSessionKey, type SessionScope } from "../../config/sessions.js";
|
|
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
|
|
import { logVerbose } from "../../globals.js";
|
|
import { createDedupeCache } from "../../infra/dedupe.js";
|
|
import { getChildLogger } from "../../logging.js";
|
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
|
import type { RuntimeEnv } from "../../runtime.js";
|
|
import type { SlackMessageEvent } from "../types.js";
|
|
import { normalizeAllowList, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js";
|
|
import type { SlackChannelConfigEntries } from "./channel-config.js";
|
|
import { resolveSlackChannelConfig } from "./channel-config.js";
|
|
import { normalizeSlackChannelType } from "./channel-type.js";
|
|
import { isSlackChannelAllowedByPolicy } from "./policy.js";
|
|
|
|
export { inferSlackChannelType, normalizeSlackChannelType } from "./channel-type.js";
|
|
|
|
export type SlackMonitorContext = {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
botToken: string;
|
|
app: App;
|
|
runtime: RuntimeEnv;
|
|
|
|
botUserId: string;
|
|
teamId: string;
|
|
apiAppId: string;
|
|
|
|
historyLimit: number;
|
|
channelHistories: Map<string, HistoryEntry[]>;
|
|
sessionScope: SessionScope;
|
|
mainKey: string;
|
|
|
|
dmEnabled: boolean;
|
|
dmPolicy: DmPolicy;
|
|
allowFrom: string[];
|
|
allowNameMatching: boolean;
|
|
groupDmEnabled: boolean;
|
|
groupDmChannels: string[];
|
|
defaultRequireMention: boolean;
|
|
channelsConfig?: SlackChannelConfigEntries;
|
|
channelsConfigKeys: string[];
|
|
groupPolicy: GroupPolicy;
|
|
useAccessGroups: boolean;
|
|
reactionMode: SlackReactionNotificationMode;
|
|
reactionAllowlist: Array<string | number>;
|
|
replyToMode: "off" | "first" | "all";
|
|
threadHistoryScope: "thread" | "channel";
|
|
threadInheritParent: boolean;
|
|
slashCommand: Required<import("../../config/config.js").SlackSlashCommandConfig>;
|
|
textLimit: number;
|
|
ackReactionScope: string;
|
|
typingReaction: string;
|
|
mediaMaxBytes: number;
|
|
removeAckAfterReply: boolean;
|
|
|
|
logger: ReturnType<typeof getChildLogger>;
|
|
markMessageSeen: (channelId: string | undefined, ts?: string) => boolean;
|
|
shouldDropMismatchedSlackEvent: (body: unknown) => boolean;
|
|
resolveSlackSystemEventSessionKey: (params: {
|
|
channelId?: string | null;
|
|
channelType?: string | null;
|
|
senderId?: string | null;
|
|
}) => string;
|
|
isChannelAllowed: (params: {
|
|
channelId?: string;
|
|
channelName?: string;
|
|
channelType?: SlackMessageEvent["channel_type"];
|
|
}) => boolean;
|
|
resolveChannelName: (channelId: string) => Promise<{
|
|
name?: string;
|
|
type?: SlackMessageEvent["channel_type"];
|
|
topic?: string;
|
|
purpose?: string;
|
|
}>;
|
|
resolveUserName: (userId: string) => Promise<{ name?: string }>;
|
|
setSlackThreadStatus: (params: {
|
|
channelId: string;
|
|
threadTs?: string;
|
|
status: string;
|
|
}) => Promise<void>;
|
|
};
|
|
|
|
export function createSlackMonitorContext(params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
botToken: string;
|
|
app: App;
|
|
runtime: RuntimeEnv;
|
|
|
|
botUserId: string;
|
|
teamId: string;
|
|
apiAppId: string;
|
|
|
|
historyLimit: number;
|
|
sessionScope: SessionScope;
|
|
mainKey: string;
|
|
|
|
dmEnabled: boolean;
|
|
dmPolicy: DmPolicy;
|
|
allowFrom: Array<string | number> | undefined;
|
|
allowNameMatching: boolean;
|
|
groupDmEnabled: boolean;
|
|
groupDmChannels: Array<string | number> | undefined;
|
|
defaultRequireMention?: boolean;
|
|
channelsConfig?: SlackMonitorContext["channelsConfig"];
|
|
groupPolicy: SlackMonitorContext["groupPolicy"];
|
|
useAccessGroups: boolean;
|
|
reactionMode: SlackReactionNotificationMode;
|
|
reactionAllowlist: Array<string | number>;
|
|
replyToMode: SlackMonitorContext["replyToMode"];
|
|
threadHistoryScope: SlackMonitorContext["threadHistoryScope"];
|
|
threadInheritParent: SlackMonitorContext["threadInheritParent"];
|
|
slashCommand: SlackMonitorContext["slashCommand"];
|
|
textLimit: number;
|
|
ackReactionScope: string;
|
|
typingReaction: string;
|
|
mediaMaxBytes: number;
|
|
removeAckAfterReply: boolean;
|
|
}): SlackMonitorContext {
|
|
const channelHistories = new Map<string, HistoryEntry[]>();
|
|
const logger = getChildLogger({ module: "slack-auto-reply" });
|
|
|
|
const channelCache = new Map<
|
|
string,
|
|
{
|
|
name?: string;
|
|
type?: SlackMessageEvent["channel_type"];
|
|
topic?: string;
|
|
purpose?: string;
|
|
}
|
|
>();
|
|
const userCache = new Map<string, { name?: string }>();
|
|
const seenMessages = createDedupeCache({ ttlMs: 60_000, maxSize: 500 });
|
|
|
|
const allowFrom = normalizeAllowList(params.allowFrom);
|
|
const groupDmChannels = normalizeAllowList(params.groupDmChannels);
|
|
const groupDmChannelsLower = normalizeAllowListLower(groupDmChannels);
|
|
const defaultRequireMention = params.defaultRequireMention ?? true;
|
|
const hasChannelAllowlistConfig = Object.keys(params.channelsConfig ?? {}).length > 0;
|
|
const channelsConfigKeys = Object.keys(params.channelsConfig ?? {});
|
|
|
|
const markMessageSeen = (channelId: string | undefined, ts?: string) => {
|
|
if (!channelId || !ts) {
|
|
return false;
|
|
}
|
|
return seenMessages.check(`${channelId}:${ts}`);
|
|
};
|
|
|
|
const resolveSlackSystemEventSessionKey = (p: {
|
|
channelId?: string | null;
|
|
channelType?: string | null;
|
|
senderId?: string | null;
|
|
}) => {
|
|
const channelId = p.channelId?.trim() ?? "";
|
|
if (!channelId) {
|
|
return params.mainKey;
|
|
}
|
|
const channelType = normalizeSlackChannelType(p.channelType, channelId);
|
|
const isDirectMessage = channelType === "im";
|
|
const isGroup = channelType === "mpim";
|
|
const from = isDirectMessage
|
|
? `slack:${channelId}`
|
|
: isGroup
|
|
? `slack:group:${channelId}`
|
|
: `slack:channel:${channelId}`;
|
|
const chatType = isDirectMessage ? "direct" : isGroup ? "group" : "channel";
|
|
const senderId = p.senderId?.trim() ?? "";
|
|
|
|
// Resolve through shared channel/account bindings so system events route to
|
|
// the same agent session as regular inbound messages.
|
|
try {
|
|
const peerKind = isDirectMessage ? "direct" : isGroup ? "group" : "channel";
|
|
const peerId = isDirectMessage ? senderId : channelId;
|
|
if (peerId) {
|
|
const route = resolveAgentRoute({
|
|
cfg: params.cfg,
|
|
channel: "slack",
|
|
accountId: params.accountId,
|
|
teamId: params.teamId,
|
|
peer: { kind: peerKind, id: peerId },
|
|
});
|
|
return route.sessionKey;
|
|
}
|
|
} catch {
|
|
// Fall through to legacy key derivation.
|
|
}
|
|
|
|
return resolveSessionKey(
|
|
params.sessionScope,
|
|
{ From: from, ChatType: chatType, Provider: "slack" },
|
|
params.mainKey,
|
|
);
|
|
};
|
|
|
|
const resolveChannelName = async (channelId: string) => {
|
|
const cached = channelCache.get(channelId);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
try {
|
|
const info = await params.app.client.conversations.info({
|
|
token: params.botToken,
|
|
channel: channelId,
|
|
});
|
|
const name = info.channel && "name" in info.channel ? info.channel.name : undefined;
|
|
const channel = info.channel ?? undefined;
|
|
const type: SlackMessageEvent["channel_type"] | undefined = channel?.is_im
|
|
? "im"
|
|
: channel?.is_mpim
|
|
? "mpim"
|
|
: channel?.is_channel
|
|
? "channel"
|
|
: channel?.is_group
|
|
? "group"
|
|
: undefined;
|
|
const topic = channel && "topic" in channel ? (channel.topic?.value ?? undefined) : undefined;
|
|
const purpose =
|
|
channel && "purpose" in channel ? (channel.purpose?.value ?? undefined) : undefined;
|
|
const entry = { name, type, topic, purpose };
|
|
channelCache.set(channelId, entry);
|
|
return entry;
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const resolveUserName = async (userId: string) => {
|
|
const cached = userCache.get(userId);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
try {
|
|
const info = await params.app.client.users.info({
|
|
token: params.botToken,
|
|
user: userId,
|
|
});
|
|
const profile = info.user?.profile;
|
|
const name = profile?.display_name || profile?.real_name || info.user?.name || undefined;
|
|
const entry = { name };
|
|
userCache.set(userId, entry);
|
|
return entry;
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const setSlackThreadStatus = async (p: {
|
|
channelId: string;
|
|
threadTs?: string;
|
|
status: string;
|
|
}) => {
|
|
if (!p.threadTs) {
|
|
return;
|
|
}
|
|
const payload = {
|
|
token: params.botToken,
|
|
channel_id: p.channelId,
|
|
thread_ts: p.threadTs,
|
|
status: p.status,
|
|
};
|
|
const client = params.app.client as unknown as {
|
|
assistant?: {
|
|
threads?: {
|
|
setStatus?: (args: typeof payload) => Promise<unknown>;
|
|
};
|
|
};
|
|
apiCall?: (method: string, args: typeof payload) => Promise<unknown>;
|
|
};
|
|
try {
|
|
if (client.assistant?.threads?.setStatus) {
|
|
await client.assistant.threads.setStatus(payload);
|
|
return;
|
|
}
|
|
if (typeof client.apiCall === "function") {
|
|
await client.apiCall("assistant.threads.setStatus", payload);
|
|
}
|
|
} catch (err) {
|
|
logVerbose(`slack status update failed for channel ${p.channelId}: ${String(err)}`);
|
|
}
|
|
};
|
|
|
|
const isChannelAllowed = (p: {
|
|
channelId?: string;
|
|
channelName?: string;
|
|
channelType?: SlackMessageEvent["channel_type"];
|
|
}) => {
|
|
const channelType = normalizeSlackChannelType(p.channelType, p.channelId);
|
|
const isDirectMessage = channelType === "im";
|
|
const isGroupDm = channelType === "mpim";
|
|
const isRoom = channelType === "channel" || channelType === "group";
|
|
|
|
if (isDirectMessage && !params.dmEnabled) {
|
|
return false;
|
|
}
|
|
if (isGroupDm && !params.groupDmEnabled) {
|
|
return false;
|
|
}
|
|
|
|
if (isGroupDm && groupDmChannels.length > 0) {
|
|
const candidates = [
|
|
p.channelId,
|
|
p.channelName ? `#${p.channelName}` : undefined,
|
|
p.channelName,
|
|
p.channelName ? normalizeSlackSlug(p.channelName) : undefined,
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
.map((value) => value.toLowerCase());
|
|
const permitted =
|
|
groupDmChannelsLower.includes("*") ||
|
|
candidates.some((candidate) => groupDmChannelsLower.includes(candidate));
|
|
if (!permitted) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (isRoom && p.channelId) {
|
|
const channelConfig = resolveSlackChannelConfig({
|
|
channelId: p.channelId,
|
|
channelName: p.channelName,
|
|
channels: params.channelsConfig,
|
|
channelKeys: channelsConfigKeys,
|
|
defaultRequireMention,
|
|
});
|
|
const channelMatchMeta = formatAllowlistMatchMeta(channelConfig);
|
|
const channelAllowed = channelConfig?.allowed !== false;
|
|
const channelAllowlistConfigured = hasChannelAllowlistConfig;
|
|
if (
|
|
!isSlackChannelAllowedByPolicy({
|
|
groupPolicy: params.groupPolicy,
|
|
channelAllowlistConfigured,
|
|
channelAllowed,
|
|
})
|
|
) {
|
|
logVerbose(
|
|
`slack: drop channel ${p.channelId} (groupPolicy=${params.groupPolicy}, ${channelMatchMeta})`,
|
|
);
|
|
return false;
|
|
}
|
|
// When groupPolicy is "open", only block channels that are EXPLICITLY denied
|
|
// (i.e., have a matching config entry with allow:false). Channels not in the
|
|
// config (matchSource undefined) should be allowed under open policy.
|
|
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
|
|
if (!channelAllowed && (params.groupPolicy !== "open" || hasExplicitConfig)) {
|
|
logVerbose(`slack: drop channel ${p.channelId} (${channelMatchMeta})`);
|
|
return false;
|
|
}
|
|
logVerbose(`slack: allow channel ${p.channelId} (${channelMatchMeta})`);
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const shouldDropMismatchedSlackEvent = (body: unknown) => {
|
|
if (!body || typeof body !== "object") {
|
|
return false;
|
|
}
|
|
const raw = body as {
|
|
api_app_id?: unknown;
|
|
team_id?: unknown;
|
|
team?: { id?: unknown };
|
|
};
|
|
const incomingApiAppId = typeof raw.api_app_id === "string" ? raw.api_app_id : "";
|
|
const incomingTeamId =
|
|
typeof raw.team_id === "string"
|
|
? raw.team_id
|
|
: typeof raw.team?.id === "string"
|
|
? raw.team.id
|
|
: "";
|
|
|
|
if (params.apiAppId && incomingApiAppId && incomingApiAppId !== params.apiAppId) {
|
|
logVerbose(
|
|
`slack: drop event with api_app_id=${incomingApiAppId} (expected ${params.apiAppId})`,
|
|
);
|
|
return true;
|
|
}
|
|
if (params.teamId && incomingTeamId && incomingTeamId !== params.teamId) {
|
|
logVerbose(`slack: drop event with team_id=${incomingTeamId} (expected ${params.teamId})`);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return {
|
|
cfg: params.cfg,
|
|
accountId: params.accountId,
|
|
botToken: params.botToken,
|
|
app: params.app,
|
|
runtime: params.runtime,
|
|
botUserId: params.botUserId,
|
|
teamId: params.teamId,
|
|
apiAppId: params.apiAppId,
|
|
historyLimit: params.historyLimit,
|
|
channelHistories,
|
|
sessionScope: params.sessionScope,
|
|
mainKey: params.mainKey,
|
|
dmEnabled: params.dmEnabled,
|
|
dmPolicy: params.dmPolicy,
|
|
allowFrom,
|
|
allowNameMatching: params.allowNameMatching,
|
|
groupDmEnabled: params.groupDmEnabled,
|
|
groupDmChannels,
|
|
defaultRequireMention,
|
|
channelsConfig: params.channelsConfig,
|
|
channelsConfigKeys,
|
|
groupPolicy: params.groupPolicy,
|
|
useAccessGroups: params.useAccessGroups,
|
|
reactionMode: params.reactionMode,
|
|
reactionAllowlist: params.reactionAllowlist,
|
|
replyToMode: params.replyToMode,
|
|
threadHistoryScope: params.threadHistoryScope,
|
|
threadInheritParent: params.threadInheritParent,
|
|
slashCommand: params.slashCommand,
|
|
textLimit: params.textLimit,
|
|
ackReactionScope: params.ackReactionScope,
|
|
typingReaction: params.typingReaction,
|
|
mediaMaxBytes: params.mediaMaxBytes,
|
|
removeAckAfterReply: params.removeAckAfterReply,
|
|
logger,
|
|
markMessageSeen,
|
|
shouldDropMismatchedSlackEvent,
|
|
resolveSlackSystemEventSessionKey,
|
|
isChannelAllowed,
|
|
resolveChannelName,
|
|
resolveUserName,
|
|
setSlackThreadStatus,
|
|
};
|
|
}
|