refactor(src): split oversized modules
This commit is contained in:
51
src/slack/monitor/allow-list.ts
Normal file
51
src/slack/monitor/allow-list.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export function normalizeSlackSlug(raw?: string) {
|
||||
const trimmed = raw?.trim().toLowerCase() ?? "";
|
||||
if (!trimmed) return "";
|
||||
const dashed = trimmed.replace(/\s+/g, "-");
|
||||
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
|
||||
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
||||
}
|
||||
|
||||
export function normalizeAllowList(list?: Array<string | number>) {
|
||||
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeAllowListLower(list?: Array<string | number>) {
|
||||
return normalizeAllowList(list).map((entry) => entry.toLowerCase());
|
||||
}
|
||||
|
||||
export function allowListMatches(params: {
|
||||
allowList: string[];
|
||||
id?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
const allowList = params.allowList;
|
||||
if (allowList.length === 0) return false;
|
||||
if (allowList.includes("*")) return true;
|
||||
const id = params.id?.toLowerCase();
|
||||
const name = params.name?.toLowerCase();
|
||||
const slug = normalizeSlackSlug(name);
|
||||
const candidates = [
|
||||
id,
|
||||
id ? `slack:${id}` : undefined,
|
||||
id ? `user:${id}` : undefined,
|
||||
name,
|
||||
name ? `slack:${name}` : undefined,
|
||||
slug,
|
||||
].filter(Boolean) as string[];
|
||||
return candidates.some((value) => allowList.includes(value));
|
||||
}
|
||||
|
||||
export function resolveSlackUserAllowed(params: {
|
||||
allowList?: Array<string | number>;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
}) {
|
||||
const allowList = normalizeAllowListLower(params.allowList);
|
||||
if (allowList.length === 0) return true;
|
||||
return allowListMatches({
|
||||
allowList,
|
||||
id: params.userId,
|
||||
name: params.userName,
|
||||
});
|
||||
}
|
||||
33
src/slack/monitor/auth.ts
Normal file
33
src/slack/monitor/auth.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
|
||||
|
||||
import {
|
||||
allowListMatches,
|
||||
normalizeAllowList,
|
||||
normalizeAllowListLower,
|
||||
} from "./allow-list.js";
|
||||
import type { SlackMonitorContext } from "./context.js";
|
||||
|
||||
export async function resolveSlackEffectiveAllowFrom(ctx: SlackMonitorContext) {
|
||||
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(
|
||||
() => [],
|
||||
);
|
||||
const allowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]);
|
||||
const allowFromLower = normalizeAllowListLower(allowFrom);
|
||||
return { allowFrom, allowFromLower };
|
||||
}
|
||||
|
||||
export function isSlackSenderAllowListed(params: {
|
||||
allowListLower: string[];
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
}) {
|
||||
const { allowListLower, senderId, senderName } = params;
|
||||
return (
|
||||
allowListLower.length === 0 ||
|
||||
allowListMatches({
|
||||
allowList: allowListLower,
|
||||
id: senderId,
|
||||
name: senderName,
|
||||
})
|
||||
);
|
||||
}
|
||||
141
src/slack/monitor/channel-config.ts
Normal file
141
src/slack/monitor/channel-config.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { SlackReactionNotificationMode } from "../../config/config.js";
|
||||
import type { SlackMessageEvent } from "../types.js";
|
||||
import {
|
||||
allowListMatches,
|
||||
normalizeAllowListLower,
|
||||
normalizeSlackSlug,
|
||||
} from "./allow-list.js";
|
||||
|
||||
export type SlackChannelConfigResolved = {
|
||||
allowed: boolean;
|
||||
requireMention: boolean;
|
||||
allowBots?: boolean;
|
||||
users?: Array<string | number>;
|
||||
skills?: string[];
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
function firstDefined<T>(...values: Array<T | undefined>) {
|
||||
for (const value of values) {
|
||||
if (typeof value !== "undefined") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function shouldEmitSlackReactionNotification(params: {
|
||||
mode: SlackReactionNotificationMode | undefined;
|
||||
botId?: string | null;
|
||||
messageAuthorId?: string | null;
|
||||
userId: string;
|
||||
userName?: string | null;
|
||||
allowlist?: Array<string | number> | null;
|
||||
}) {
|
||||
const { mode, botId, messageAuthorId, userId, userName, allowlist } = params;
|
||||
const effectiveMode = mode ?? "own";
|
||||
if (effectiveMode === "off") return false;
|
||||
if (effectiveMode === "own") {
|
||||
if (!botId || !messageAuthorId) return false;
|
||||
return messageAuthorId === botId;
|
||||
}
|
||||
if (effectiveMode === "allowlist") {
|
||||
if (!Array.isArray(allowlist) || allowlist.length === 0) return false;
|
||||
const users = normalizeAllowListLower(allowlist);
|
||||
return allowListMatches({
|
||||
allowList: users,
|
||||
id: userId,
|
||||
name: userName ?? undefined,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveSlackChannelLabel(params: {
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
}) {
|
||||
const channelName = params.channelName?.trim();
|
||||
if (channelName) {
|
||||
const slug = normalizeSlackSlug(channelName);
|
||||
return `#${slug || channelName}`;
|
||||
}
|
||||
const channelId = params.channelId?.trim();
|
||||
return channelId ? `#${channelId}` : "unknown channel";
|
||||
}
|
||||
|
||||
export function resolveSlackChannelConfig(params: {
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
channels?: Record<
|
||||
string,
|
||||
{
|
||||
enabled?: boolean;
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
allowBots?: boolean;
|
||||
users?: Array<string | number>;
|
||||
skills?: string[];
|
||||
systemPrompt?: string;
|
||||
}
|
||||
>;
|
||||
}): SlackChannelConfigResolved | null {
|
||||
const { channelId, channelName, channels } = params;
|
||||
const entries = channels ?? {};
|
||||
const keys = Object.keys(entries);
|
||||
const normalizedName = channelName ? normalizeSlackSlug(channelName) : "";
|
||||
const directName = channelName ? channelName.trim() : "";
|
||||
const candidates = [
|
||||
channelId,
|
||||
channelName ? `#${directName}` : "",
|
||||
directName,
|
||||
normalizedName,
|
||||
].filter(Boolean);
|
||||
|
||||
let matched:
|
||||
| {
|
||||
enabled?: boolean;
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
allowBots?: boolean;
|
||||
users?: Array<string | number>;
|
||||
skills?: string[];
|
||||
systemPrompt?: string;
|
||||
}
|
||||
| undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && entries[candidate]) {
|
||||
matched = entries[candidate];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const fallback = entries["*"];
|
||||
|
||||
if (keys.length === 0) {
|
||||
return { allowed: true, requireMention: true };
|
||||
}
|
||||
if (!matched && !fallback) {
|
||||
return { allowed: false, requireMention: true };
|
||||
}
|
||||
|
||||
const resolved = matched ?? fallback ?? {};
|
||||
const allowed =
|
||||
firstDefined(
|
||||
resolved.enabled,
|
||||
resolved.allow,
|
||||
fallback?.enabled,
|
||||
fallback?.allow,
|
||||
true,
|
||||
) ?? true;
|
||||
const requireMention =
|
||||
firstDefined(resolved.requireMention, fallback?.requireMention, true) ??
|
||||
true;
|
||||
const allowBots = firstDefined(resolved.allowBots, fallback?.allowBots);
|
||||
const users = firstDefined(resolved.users, fallback?.users);
|
||||
const skills = firstDefined(resolved.skills, fallback?.skills);
|
||||
const systemPrompt = firstDefined(
|
||||
resolved.systemPrompt,
|
||||
fallback?.systemPrompt,
|
||||
);
|
||||
return { allowed, requireMention, allowBots, users, skills, systemPrompt };
|
||||
}
|
||||
|
||||
export type { SlackMessageEvent };
|
||||
25
src/slack/monitor/commands.ts
Normal file
25
src/slack/monitor/commands.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { SlackSlashCommandConfig } from "../../config/config.js";
|
||||
|
||||
export function normalizeSlackSlashCommandName(raw: string) {
|
||||
return raw.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
export function resolveSlackSlashCommandConfig(
|
||||
raw?: SlackSlashCommandConfig,
|
||||
): Required<SlackSlashCommandConfig> {
|
||||
const normalizedName = normalizeSlackSlashCommandName(
|
||||
raw?.name?.trim() || "clawd",
|
||||
);
|
||||
const name = normalizedName || "clawd";
|
||||
return {
|
||||
enabled: raw?.enabled === true,
|
||||
name,
|
||||
sessionPrefix: raw?.sessionPrefix?.trim() || "slack:slash",
|
||||
ephemeral: raw?.ephemeral !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSlackSlashCommandMatcher(name: string) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`^/?${escaped}$`);
|
||||
}
|
||||
352
src/slack/monitor/context.ts
Normal file
352
src/slack/monitor/context.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import type { App } from "@slack/bolt";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import type {
|
||||
ClawdbotConfig,
|
||||
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 type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { SlackMessageEvent } from "../types.js";
|
||||
|
||||
import {
|
||||
normalizeAllowList,
|
||||
normalizeAllowListLower,
|
||||
normalizeSlackSlug,
|
||||
} from "./allow-list.js";
|
||||
import { resolveSlackChannelConfig } from "./channel-config.js";
|
||||
import { isSlackRoomAllowedByPolicy } from "./policy.js";
|
||||
|
||||
export type SlackMonitorContext = {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
botToken: string;
|
||||
app: App;
|
||||
runtime: RuntimeEnv;
|
||||
|
||||
botUserId: string;
|
||||
teamId: string;
|
||||
|
||||
historyLimit: number;
|
||||
channelHistories: Map<string, HistoryEntry[]>;
|
||||
sessionScope: SessionScope;
|
||||
mainKey: string;
|
||||
|
||||
dmEnabled: boolean;
|
||||
dmPolicy: DmPolicy;
|
||||
allowFrom: string[];
|
||||
groupDmEnabled: boolean;
|
||||
groupDmChannels: string[];
|
||||
channelsConfig?: Record<
|
||||
string,
|
||||
{
|
||||
enabled?: boolean;
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
allowBots?: boolean;
|
||||
users?: Array<string | number>;
|
||||
skills?: string[];
|
||||
systemPrompt?: string;
|
||||
}
|
||||
>;
|
||||
groupPolicy: GroupPolicy;
|
||||
useAccessGroups: boolean;
|
||||
reactionMode: SlackReactionNotificationMode;
|
||||
reactionAllowlist: Array<string | number>;
|
||||
replyToMode: "off" | "first" | "all";
|
||||
slashCommand: Required<
|
||||
import("../../config/config.js").SlackSlashCommandConfig
|
||||
>;
|
||||
textLimit: number;
|
||||
ackReactionScope: string;
|
||||
mediaMaxBytes: number;
|
||||
removeAckAfterReply: boolean;
|
||||
|
||||
logger: ReturnType<typeof getChildLogger>;
|
||||
markMessageSeen: (channelId: string | undefined, ts?: string) => boolean;
|
||||
resolveSlackSystemEventSessionKey: (params: {
|
||||
channelId?: string | null;
|
||||
channelType?: 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: ClawdbotConfig;
|
||||
accountId: string;
|
||||
botToken: string;
|
||||
app: App;
|
||||
runtime: RuntimeEnv;
|
||||
|
||||
botUserId: string;
|
||||
teamId: string;
|
||||
|
||||
historyLimit: number;
|
||||
sessionScope: SessionScope;
|
||||
mainKey: string;
|
||||
|
||||
dmEnabled: boolean;
|
||||
dmPolicy: DmPolicy;
|
||||
allowFrom: Array<string | number> | undefined;
|
||||
groupDmEnabled: boolean;
|
||||
groupDmChannels: Array<string | number> | undefined;
|
||||
channelsConfig?: SlackMonitorContext["channelsConfig"];
|
||||
groupPolicy: SlackMonitorContext["groupPolicy"];
|
||||
useAccessGroups: boolean;
|
||||
reactionMode: SlackReactionNotificationMode;
|
||||
reactionAllowlist: Array<string | number>;
|
||||
replyToMode: SlackMonitorContext["replyToMode"];
|
||||
slashCommand: SlackMonitorContext["slashCommand"];
|
||||
textLimit: number;
|
||||
ackReactionScope: 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 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;
|
||||
}) => {
|
||||
const channelId = p.channelId?.trim() ?? "";
|
||||
if (!channelId) return params.mainKey;
|
||||
const channelType = p.channelType?.trim().toLowerCase() ?? "";
|
||||
const isRoom = channelType === "channel" || channelType === "group";
|
||||
const isGroup = channelType === "mpim";
|
||||
const from = isRoom
|
||||
? `slack:channel:${channelId}`
|
||||
: isGroup
|
||||
? `slack:group:${channelId}`
|
||||
: `slack:${channelId}`;
|
||||
const chatType = isRoom ? "room" : isGroup ? "group" : "direct";
|
||||
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 = p.channelType;
|
||||
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 allowList = normalizeAllowListLower(groupDmChannels);
|
||||
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 =
|
||||
allowList.includes("*") ||
|
||||
candidates.some((candidate) => allowList.includes(candidate));
|
||||
if (!permitted) return false;
|
||||
}
|
||||
|
||||
if (isRoom && p.channelId) {
|
||||
const channelConfig = resolveSlackChannelConfig({
|
||||
channelId: p.channelId,
|
||||
channelName: p.channelName,
|
||||
channels: params.channelsConfig,
|
||||
});
|
||||
const channelAllowed = channelConfig?.allowed !== false;
|
||||
const channelAllowlistConfigured =
|
||||
Boolean(params.channelsConfig) &&
|
||||
Object.keys(params.channelsConfig ?? {}).length > 0;
|
||||
if (
|
||||
!isSlackRoomAllowedByPolicy({
|
||||
groupPolicy: params.groupPolicy,
|
||||
channelAllowlistConfigured,
|
||||
channelAllowed,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!channelAllowed) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
botToken: params.botToken,
|
||||
app: params.app,
|
||||
runtime: params.runtime,
|
||||
botUserId: params.botUserId,
|
||||
teamId: params.teamId,
|
||||
historyLimit: params.historyLimit,
|
||||
channelHistories,
|
||||
sessionScope: params.sessionScope,
|
||||
mainKey: params.mainKey,
|
||||
dmEnabled: params.dmEnabled,
|
||||
dmPolicy: params.dmPolicy,
|
||||
allowFrom,
|
||||
groupDmEnabled: params.groupDmEnabled,
|
||||
groupDmChannels,
|
||||
channelsConfig: params.channelsConfig,
|
||||
groupPolicy: params.groupPolicy,
|
||||
useAccessGroups: params.useAccessGroups,
|
||||
reactionMode: params.reactionMode,
|
||||
reactionAllowlist: params.reactionAllowlist,
|
||||
replyToMode: params.replyToMode,
|
||||
slashCommand: params.slashCommand,
|
||||
textLimit: params.textLimit,
|
||||
ackReactionScope: params.ackReactionScope,
|
||||
mediaMaxBytes: params.mediaMaxBytes,
|
||||
removeAckAfterReply: params.removeAckAfterReply,
|
||||
logger,
|
||||
markMessageSeen,
|
||||
resolveSlackSystemEventSessionKey,
|
||||
isChannelAllowed,
|
||||
resolveChannelName,
|
||||
resolveUserName,
|
||||
setSlackThreadStatus,
|
||||
};
|
||||
}
|
||||
24
src/slack/monitor/events.ts
Normal file
24
src/slack/monitor/events.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ResolvedSlackAccount } from "../accounts.js";
|
||||
|
||||
import type { SlackMonitorContext } from "./context.js";
|
||||
import { registerSlackChannelEvents } from "./events/channels.js";
|
||||
import { registerSlackMemberEvents } from "./events/members.js";
|
||||
import { registerSlackMessageEvents } from "./events/messages.js";
|
||||
import { registerSlackPinEvents } from "./events/pins.js";
|
||||
import { registerSlackReactionEvents } from "./events/reactions.js";
|
||||
import type { SlackMessageHandler } from "./message-handler.js";
|
||||
|
||||
export function registerSlackMonitorEvents(params: {
|
||||
ctx: SlackMonitorContext;
|
||||
account: ResolvedSlackAccount;
|
||||
handleSlackMessage: SlackMessageHandler;
|
||||
}) {
|
||||
registerSlackMessageEvents({
|
||||
ctx: params.ctx,
|
||||
handleSlackMessage: params.handleSlackMessage,
|
||||
});
|
||||
registerSlackReactionEvents({ ctx: params.ctx });
|
||||
registerSlackMemberEvents({ ctx: params.ctx });
|
||||
registerSlackChannelEvents({ ctx: params.ctx });
|
||||
registerSlackPinEvents({ ctx: params.ctx });
|
||||
}
|
||||
84
src/slack/monitor/events/channels.ts
Normal file
84
src/slack/monitor/events/channels.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
|
||||
|
||||
import { danger } from "../../../globals.js";
|
||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||
|
||||
import { resolveSlackChannelLabel } from "../channel-config.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import type {
|
||||
SlackChannelCreatedEvent,
|
||||
SlackChannelRenamedEvent,
|
||||
} from "../types.js";
|
||||
|
||||
export function registerSlackChannelEvents(params: {
|
||||
ctx: SlackMonitorContext;
|
||||
}) {
|
||||
const { ctx } = params;
|
||||
|
||||
ctx.app.event(
|
||||
"channel_created",
|
||||
async ({ event }: SlackEventMiddlewareArgs<"channel_created">) => {
|
||||
try {
|
||||
const payload = event as SlackChannelCreatedEvent;
|
||||
const channelId = payload.channel?.id;
|
||||
const channelName = payload.channel?.name;
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType: "channel",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const label = resolveSlackChannelLabel({ channelId, channelName });
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId,
|
||||
channelType: "channel",
|
||||
});
|
||||
enqueueSystemEvent(`Slack channel created: ${label}.`, {
|
||||
sessionKey,
|
||||
contextKey: `slack:channel:created:${channelId ?? channelName ?? "unknown"}`,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.runtime.error?.(
|
||||
danger(`slack channel created handler failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.app.event(
|
||||
"channel_rename",
|
||||
async ({ event }: SlackEventMiddlewareArgs<"channel_rename">) => {
|
||||
try {
|
||||
const payload = event as SlackChannelRenamedEvent;
|
||||
const channelId = payload.channel?.id;
|
||||
const channelName =
|
||||
payload.channel?.name_normalized ?? payload.channel?.name;
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType: "channel",
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const label = resolveSlackChannelLabel({ channelId, channelName });
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId,
|
||||
channelType: "channel",
|
||||
});
|
||||
enqueueSystemEvent(`Slack channel renamed: ${label}.`, {
|
||||
sessionKey,
|
||||
contextKey: `slack:channel:renamed:${channelId ?? channelName ?? "unknown"}`,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.runtime.error?.(
|
||||
danger(`slack channel rename handler failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
100
src/slack/monitor/events/members.ts
Normal file
100
src/slack/monitor/events/members.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
|
||||
|
||||
import { danger } from "../../../globals.js";
|
||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||
|
||||
import { resolveSlackChannelLabel } from "../channel-config.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import type { SlackMemberChannelEvent } from "../types.js";
|
||||
|
||||
export function registerSlackMemberEvents(params: {
|
||||
ctx: SlackMonitorContext;
|
||||
}) {
|
||||
const { ctx } = params;
|
||||
|
||||
ctx.app.event(
|
||||
"member_joined_channel",
|
||||
async ({ event }: SlackEventMiddlewareArgs<"member_joined_channel">) => {
|
||||
try {
|
||||
const payload = event as SlackMemberChannelEvent;
|
||||
const channelId = payload.channel;
|
||||
const channelInfo = channelId
|
||||
? await ctx.resolveChannelName(channelId)
|
||||
: {};
|
||||
const channelType = payload.channel_type ?? channelInfo?.type;
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
channelType,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const userInfo = payload.user
|
||||
? await ctx.resolveUserName(payload.user)
|
||||
: {};
|
||||
const userLabel = userInfo?.name ?? payload.user ?? "someone";
|
||||
const label = resolveSlackChannelLabel({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
});
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId,
|
||||
channelType,
|
||||
});
|
||||
enqueueSystemEvent(`Slack: ${userLabel} joined ${label}.`, {
|
||||
sessionKey,
|
||||
contextKey: `slack:member:joined:${channelId ?? "unknown"}:${payload.user ?? "unknown"}`,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.runtime.error?.(
|
||||
danger(`slack join handler failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.app.event(
|
||||
"member_left_channel",
|
||||
async ({ event }: SlackEventMiddlewareArgs<"member_left_channel">) => {
|
||||
try {
|
||||
const payload = event as SlackMemberChannelEvent;
|
||||
const channelId = payload.channel;
|
||||
const channelInfo = channelId
|
||||
? await ctx.resolveChannelName(channelId)
|
||||
: {};
|
||||
const channelType = payload.channel_type ?? channelInfo?.type;
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
channelType,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const userInfo = payload.user
|
||||
? await ctx.resolveUserName(payload.user)
|
||||
: {};
|
||||
const userLabel = userInfo?.name ?? payload.user ?? "someone";
|
||||
const label = resolveSlackChannelLabel({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
});
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId,
|
||||
channelType,
|
||||
});
|
||||
enqueueSystemEvent(`Slack: ${userLabel} left ${label}.`, {
|
||||
sessionKey,
|
||||
contextKey: `slack:member:left:${channelId ?? "unknown"}:${payload.user ?? "unknown"}`,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.runtime.error?.(
|
||||
danger(`slack leave handler failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
142
src/slack/monitor/events/messages.ts
Normal file
142
src/slack/monitor/events/messages.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
|
||||
|
||||
import { danger } from "../../../globals.js";
|
||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||
|
||||
import type { SlackAppMentionEvent, SlackMessageEvent } from "../../types.js";
|
||||
import { resolveSlackChannelLabel } from "../channel-config.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import type { SlackMessageHandler } from "../message-handler.js";
|
||||
import type {
|
||||
SlackMessageChangedEvent,
|
||||
SlackMessageDeletedEvent,
|
||||
SlackThreadBroadcastEvent,
|
||||
} from "../types.js";
|
||||
|
||||
export function registerSlackMessageEvents(params: {
|
||||
ctx: SlackMonitorContext;
|
||||
handleSlackMessage: SlackMessageHandler;
|
||||
}) {
|
||||
const { ctx, handleSlackMessage } = params;
|
||||
|
||||
ctx.app.event(
|
||||
"message",
|
||||
async ({ event }: SlackEventMiddlewareArgs<"message">) => {
|
||||
try {
|
||||
const message = event as SlackMessageEvent;
|
||||
if (message.subtype === "message_changed") {
|
||||
const changed = event as SlackMessageChangedEvent;
|
||||
const channelId = changed.channel;
|
||||
const channelInfo = channelId
|
||||
? await ctx.resolveChannelName(channelId)
|
||||
: {};
|
||||
const channelType = channelInfo?.type;
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
channelType,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const messageId = changed.message?.ts ?? changed.previous_message?.ts;
|
||||
const label = resolveSlackChannelLabel({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
});
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId,
|
||||
channelType,
|
||||
});
|
||||
enqueueSystemEvent(`Slack message edited in ${label}.`, {
|
||||
sessionKey,
|
||||
contextKey: `slack:message:changed:${channelId ?? "unknown"}:${messageId ?? changed.event_ts ?? "unknown"}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.subtype === "message_deleted") {
|
||||
const deleted = event as SlackMessageDeletedEvent;
|
||||
const channelId = deleted.channel;
|
||||
const channelInfo = channelId
|
||||
? await ctx.resolveChannelName(channelId)
|
||||
: {};
|
||||
const channelType = channelInfo?.type;
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
channelType,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const label = resolveSlackChannelLabel({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
});
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId,
|
||||
channelType,
|
||||
});
|
||||
enqueueSystemEvent(`Slack message deleted in ${label}.`, {
|
||||
sessionKey,
|
||||
contextKey: `slack:message:deleted:${channelId ?? "unknown"}:${deleted.deleted_ts ?? deleted.event_ts ?? "unknown"}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.subtype === "thread_broadcast") {
|
||||
const thread = event as SlackThreadBroadcastEvent;
|
||||
const channelId = thread.channel;
|
||||
const channelInfo = channelId
|
||||
? await ctx.resolveChannelName(channelId)
|
||||
: {};
|
||||
const channelType = channelInfo?.type;
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
channelType,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const label = resolveSlackChannelLabel({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
});
|
||||
const messageId = thread.message?.ts ?? thread.event_ts;
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId,
|
||||
channelType,
|
||||
});
|
||||
enqueueSystemEvent(`Slack thread reply broadcast in ${label}.`, {
|
||||
sessionKey,
|
||||
contextKey: `slack:thread:broadcast:${channelId ?? "unknown"}:${messageId ?? "unknown"}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await handleSlackMessage(message, { source: "message" });
|
||||
} catch (err) {
|
||||
ctx.runtime.error?.(danger(`slack handler failed: ${String(err)}`));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.app.event(
|
||||
"app_mention",
|
||||
async ({ event }: SlackEventMiddlewareArgs<"app_mention">) => {
|
||||
try {
|
||||
const mention = event as SlackAppMentionEvent;
|
||||
await handleSlackMessage(mention as unknown as SlackMessageEvent, {
|
||||
source: "app_mention",
|
||||
wasMentioned: true,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.runtime.error?.(
|
||||
danger(`slack mention handler failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
106
src/slack/monitor/events/pins.ts
Normal file
106
src/slack/monitor/events/pins.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
|
||||
|
||||
import { danger } from "../../../globals.js";
|
||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||
|
||||
import { resolveSlackChannelLabel } from "../channel-config.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import type { SlackPinEvent } from "../types.js";
|
||||
|
||||
export function registerSlackPinEvents(params: { ctx: SlackMonitorContext }) {
|
||||
const { ctx } = params;
|
||||
|
||||
ctx.app.event(
|
||||
"pin_added",
|
||||
async ({ event }: SlackEventMiddlewareArgs<"pin_added">) => {
|
||||
try {
|
||||
const payload = event as SlackPinEvent;
|
||||
const channelId = payload.channel_id;
|
||||
const channelInfo = channelId
|
||||
? await ctx.resolveChannelName(channelId)
|
||||
: {};
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
channelType: channelInfo?.type,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const label = resolveSlackChannelLabel({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
});
|
||||
const userInfo = payload.user
|
||||
? await ctx.resolveUserName(payload.user)
|
||||
: {};
|
||||
const userLabel = userInfo?.name ?? payload.user ?? "someone";
|
||||
const itemType = payload.item?.type ?? "item";
|
||||
const messageId = payload.item?.message?.ts ?? payload.event_ts;
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId,
|
||||
channelType: channelInfo?.type ?? undefined,
|
||||
});
|
||||
enqueueSystemEvent(
|
||||
`Slack: ${userLabel} pinned a ${itemType} in ${label}.`,
|
||||
{
|
||||
sessionKey,
|
||||
contextKey: `slack:pin:added:${channelId ?? "unknown"}:${messageId ?? "unknown"}`,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
ctx.runtime.error?.(
|
||||
danger(`slack pin added handler failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.app.event(
|
||||
"pin_removed",
|
||||
async ({ event }: SlackEventMiddlewareArgs<"pin_removed">) => {
|
||||
try {
|
||||
const payload = event as SlackPinEvent;
|
||||
const channelId = payload.channel_id;
|
||||
const channelInfo = channelId
|
||||
? await ctx.resolveChannelName(channelId)
|
||||
: {};
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
channelType: channelInfo?.type,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const label = resolveSlackChannelLabel({
|
||||
channelId,
|
||||
channelName: channelInfo?.name,
|
||||
});
|
||||
const userInfo = payload.user
|
||||
? await ctx.resolveUserName(payload.user)
|
||||
: {};
|
||||
const userLabel = userInfo?.name ?? payload.user ?? "someone";
|
||||
const itemType = payload.item?.type ?? "item";
|
||||
const messageId = payload.item?.message?.ts ?? payload.event_ts;
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId,
|
||||
channelType: channelInfo?.type ?? undefined,
|
||||
});
|
||||
enqueueSystemEvent(
|
||||
`Slack: ${userLabel} unpinned a ${itemType} in ${label}.`,
|
||||
{
|
||||
sessionKey,
|
||||
contextKey: `slack:pin:removed:${channelId ?? "unknown"}:${messageId ?? "unknown"}`,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
ctx.runtime.error?.(
|
||||
danger(`slack pin removed handler failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
104
src/slack/monitor/events/reactions.ts
Normal file
104
src/slack/monitor/events/reactions.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
|
||||
|
||||
import { danger } from "../../../globals.js";
|
||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||
|
||||
import { normalizeSlackSlug } from "../allow-list.js";
|
||||
import {
|
||||
resolveSlackChannelConfig,
|
||||
shouldEmitSlackReactionNotification,
|
||||
} from "../channel-config.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import type { SlackReactionEvent } from "../types.js";
|
||||
|
||||
export function registerSlackReactionEvents(params: {
|
||||
ctx: SlackMonitorContext;
|
||||
}) {
|
||||
const { ctx } = params;
|
||||
|
||||
const handleReactionEvent = async (
|
||||
event: SlackReactionEvent,
|
||||
action: "added" | "removed",
|
||||
) => {
|
||||
try {
|
||||
const item = event.item;
|
||||
if (!event.user) return;
|
||||
if (!item?.channel || !item?.ts) return;
|
||||
if (item.type && item.type !== "message") return;
|
||||
if (ctx.botUserId && event.user === ctx.botUserId) return;
|
||||
|
||||
const channelInfo = await ctx.resolveChannelName(item.channel);
|
||||
const channelType = channelInfo?.type;
|
||||
const channelName = channelInfo?.name;
|
||||
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId: item.channel,
|
||||
channelName,
|
||||
channelType,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRoom = channelType === "channel" || channelType === "group";
|
||||
if (isRoom) {
|
||||
const channelConfig = resolveSlackChannelConfig({
|
||||
channelId: item.channel,
|
||||
channelName,
|
||||
channels: ctx.channelsConfig,
|
||||
});
|
||||
if (channelConfig?.allowed === false) return;
|
||||
}
|
||||
|
||||
const actor = await ctx.resolveUserName(event.user);
|
||||
const shouldNotify = shouldEmitSlackReactionNotification({
|
||||
mode: ctx.reactionMode,
|
||||
botId: ctx.botUserId,
|
||||
messageAuthorId: event.item_user ?? undefined,
|
||||
userId: event.user,
|
||||
userName: actor?.name ?? undefined,
|
||||
allowlist: ctx.reactionAllowlist,
|
||||
});
|
||||
if (!shouldNotify) return;
|
||||
|
||||
const emojiLabel = event.reaction ?? "emoji";
|
||||
const actorLabel = actor?.name ?? event.user;
|
||||
const channelLabel = channelName
|
||||
? `#${normalizeSlackSlug(channelName) || channelName}`
|
||||
: `#${item.channel}`;
|
||||
const authorInfo = event.item_user
|
||||
? await ctx.resolveUserName(event.item_user)
|
||||
: undefined;
|
||||
const authorLabel = authorInfo?.name ?? event.item_user;
|
||||
const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${channelLabel} msg ${item.ts}`;
|
||||
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId: item.channel,
|
||||
channelType,
|
||||
});
|
||||
enqueueSystemEvent(text, {
|
||||
sessionKey,
|
||||
contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}`,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.runtime.error?.(
|
||||
danger(`slack reaction handler failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.app.event(
|
||||
"reaction_added",
|
||||
async ({ event }: SlackEventMiddlewareArgs<"reaction_added">) => {
|
||||
await handleReactionEvent(event as SlackReactionEvent, "added");
|
||||
},
|
||||
);
|
||||
|
||||
ctx.app.event(
|
||||
"reaction_removed",
|
||||
async ({ event }: SlackEventMiddlewareArgs<"reaction_removed">) => {
|
||||
await handleReactionEvent(event as SlackReactionEvent, "removed");
|
||||
},
|
||||
);
|
||||
}
|
||||
88
src/slack/monitor/media.ts
Normal file
88
src/slack/monitor/media.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||
|
||||
import type { FetchLike } from "../../media/fetch.js";
|
||||
import { fetchRemoteMedia } from "../../media/fetch.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import type { SlackFile } from "../types.js";
|
||||
|
||||
export async function resolveSlackMedia(params: {
|
||||
files?: SlackFile[];
|
||||
token: string;
|
||||
maxBytes: number;
|
||||
}): Promise<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
} | null> {
|
||||
const files = params.files ?? [];
|
||||
for (const file of files) {
|
||||
const url = file.url_private_download ?? file.url_private;
|
||||
if (!url) continue;
|
||||
try {
|
||||
const fetchImpl: FetchLike = (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${params.token}`);
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url,
|
||||
fetchImpl,
|
||||
filePathHint: file.name,
|
||||
});
|
||||
if (fetched.buffer.byteLength > params.maxBytes) continue;
|
||||
const saved = await saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType ?? file.mimetype,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
const label = fetched.fileName ?? file.name;
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: label ? `[Slack file: ${label}]` : "[Slack file]",
|
||||
};
|
||||
} catch {
|
||||
// Ignore download failures and fall through to the next file.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type SlackThreadStarter = {
|
||||
text: string;
|
||||
userId?: string;
|
||||
ts?: string;
|
||||
};
|
||||
|
||||
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
|
||||
|
||||
export async function resolveSlackThreadStarter(params: {
|
||||
channelId: string;
|
||||
threadTs: string;
|
||||
client: SlackWebClient;
|
||||
}): Promise<SlackThreadStarter | null> {
|
||||
const cacheKey = `${params.channelId}:${params.threadTs}`;
|
||||
const cached = THREAD_STARTER_CACHE.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const response = (await params.client.conversations.replies({
|
||||
channel: params.channelId,
|
||||
ts: params.threadTs,
|
||||
limit: 1,
|
||||
inclusive: true,
|
||||
})) as { messages?: Array<{ text?: string; user?: string; ts?: string }> };
|
||||
const message = response?.messages?.[0];
|
||||
const text = (message?.text ?? "").trim();
|
||||
if (!message || !text) return null;
|
||||
const starter: SlackThreadStarter = {
|
||||
text,
|
||||
userId: message.user,
|
||||
ts: message.ts,
|
||||
};
|
||||
THREAD_STARTER_CACHE.set(cacheKey, starter);
|
||||
return starter;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
33
src/slack/monitor/message-handler.ts
Normal file
33
src/slack/monitor/message-handler.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ResolvedSlackAccount } from "../accounts.js";
|
||||
import type { SlackMessageEvent } from "../types.js";
|
||||
import type { SlackMonitorContext } from "./context.js";
|
||||
import { dispatchPreparedSlackMessage } from "./message-handler/dispatch.js";
|
||||
import { prepareSlackMessage } from "./message-handler/prepare.js";
|
||||
|
||||
export type SlackMessageHandler = (
|
||||
message: SlackMessageEvent,
|
||||
opts: { source: "message" | "app_mention"; wasMentioned?: boolean },
|
||||
) => Promise<void>;
|
||||
|
||||
export function createSlackMessageHandler(params: {
|
||||
ctx: SlackMonitorContext;
|
||||
account: ResolvedSlackAccount;
|
||||
}): SlackMessageHandler {
|
||||
const { ctx, account } = params;
|
||||
|
||||
return async (message, opts) => {
|
||||
if (opts.source === "message" && message.type !== "message") return;
|
||||
if (
|
||||
opts.source === "message" &&
|
||||
message.subtype &&
|
||||
message.subtype !== "file_share" &&
|
||||
message.subtype !== "bot_message"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (ctx.markMessageSeen(message.channel, message.ts)) return;
|
||||
const prepared = await prepareSlackMessage({ ctx, account, message, opts });
|
||||
if (!prepared) return;
|
||||
await dispatchPreparedSlackMessage(prepared);
|
||||
};
|
||||
}
|
||||
168
src/slack/monitor/message-handler/dispatch.ts
Normal file
168
src/slack/monitor/message-handler/dispatch.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
} from "../../../agents/identity.js";
|
||||
import { dispatchReplyFromConfig } from "../../../auto-reply/reply/dispatch-from-config.js";
|
||||
import { clearHistoryEntries } from "../../../auto-reply/reply/history.js";
|
||||
import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
|
||||
import { removeSlackReaction } from "../../actions.js";
|
||||
import { resolveSlackThreadTargets } from "../../threading.js";
|
||||
|
||||
import { createSlackReplyDeliveryPlan, deliverReplies } from "../replies.js";
|
||||
|
||||
import type { PreparedSlackMessage } from "./types.js";
|
||||
|
||||
export async function dispatchPreparedSlackMessage(
|
||||
prepared: PreparedSlackMessage,
|
||||
) {
|
||||
const { ctx, account, message, route } = prepared;
|
||||
const cfg = ctx.cfg;
|
||||
const runtime = ctx.runtime;
|
||||
|
||||
if (prepared.isDirectMessage) {
|
||||
const sessionCfg = cfg.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
await updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "slack",
|
||||
to: `user:${message.user}`,
|
||||
accountId: route.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const { statusThreadTs } = resolveSlackThreadTargets({
|
||||
message,
|
||||
replyToMode: ctx.replyToMode,
|
||||
});
|
||||
|
||||
const messageTs = message.ts ?? message.event_ts;
|
||||
const incomingThreadTs = message.thread_ts;
|
||||
let didSetStatus = false;
|
||||
|
||||
// Shared mutable ref for "replyToMode=first". Both tool + auto-reply flows
|
||||
// mark this to ensure only the first reply is threaded.
|
||||
const hasRepliedRef = { value: false };
|
||||
const replyPlan = createSlackReplyDeliveryPlan({
|
||||
replyToMode: ctx.replyToMode,
|
||||
incomingThreadTs,
|
||||
messageTs,
|
||||
hasRepliedRef,
|
||||
});
|
||||
|
||||
const onReplyStart = async () => {
|
||||
didSetStatus = true;
|
||||
await ctx.setSlackThreadStatus({
|
||||
channelId: message.channel,
|
||||
threadTs: statusThreadTs,
|
||||
status: "is typing...",
|
||||
});
|
||||
};
|
||||
|
||||
let didSendReply = false;
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||
.responsePrefix,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
const replyThreadTs = replyPlan.nextThreadTs();
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target: prepared.replyTarget,
|
||||
token: ctx.botToken,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
textLimit: ctx.textLimit,
|
||||
replyThreadTs,
|
||||
});
|
||||
didSendReply = true;
|
||||
replyPlan.markSent();
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
danger(`slack ${info.kind} reply failed: ${String(err)}`),
|
||||
);
|
||||
if (didSetStatus) {
|
||||
void ctx.setSlackThreadStatus({
|
||||
channelId: message.channel,
|
||||
threadTs: statusThreadTs,
|
||||
status: "",
|
||||
});
|
||||
}
|
||||
},
|
||||
onReplyStart,
|
||||
});
|
||||
|
||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
||||
ctx: prepared.ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
skillFilter: prepared.channelConfig?.skills,
|
||||
hasRepliedRef,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
|
||||
if (didSetStatus) {
|
||||
await ctx.setSlackThreadStatus({
|
||||
channelId: message.channel,
|
||||
threadTs: statusThreadTs,
|
||||
status: "",
|
||||
});
|
||||
}
|
||||
|
||||
if (!queuedFinal) {
|
||||
if (prepared.isRoomish && ctx.historyLimit > 0 && didSendReply) {
|
||||
clearHistoryEntries({
|
||||
historyMap: ctx.channelHistories,
|
||||
historyKey: prepared.historyKey,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const finalCount = counts.final;
|
||||
logVerbose(
|
||||
`slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${prepared.replyTarget}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
ctx.removeAckAfterReply &&
|
||||
prepared.ackReactionPromise &&
|
||||
prepared.ackReactionMessageTs
|
||||
) {
|
||||
const messageTs = prepared.ackReactionMessageTs;
|
||||
const ackValue = prepared.ackReactionValue;
|
||||
void prepared.ackReactionPromise.then((didAck) => {
|
||||
if (!didAck) return;
|
||||
removeSlackReaction(message.channel, messageTs, ackValue, {
|
||||
token: ctx.botToken,
|
||||
client: ctx.app.client,
|
||||
}).catch((err) => {
|
||||
logVerbose(
|
||||
`slack: failed to remove ack reaction from ${message.channel}/${message.ts}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (prepared.isRoomish && ctx.historyLimit > 0 && didSendReply) {
|
||||
clearHistoryEntries({
|
||||
historyMap: ctx.channelHistories,
|
||||
historyKey: prepared.historyKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
473
src/slack/monitor/message-handler/prepare.ts
Normal file
473
src/slack/monitor/message-handler/prepare.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { resolveAckReaction } from "../../../agents/identity.js";
|
||||
import { hasControlCommand } from "../../../auto-reply/command-detection.js";
|
||||
import { shouldHandleTextCommands } from "../../../auto-reply/commands-registry.js";
|
||||
import {
|
||||
formatAgentEnvelope,
|
||||
formatThreadStarterEnvelope,
|
||||
} from "../../../auto-reply/envelope.js";
|
||||
import { buildHistoryContextFromMap } from "../../../auto-reply/reply/history.js";
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
} from "../../../auto-reply/reply/mentions.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
|
||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||
import { buildPairingReply } from "../../../pairing/pairing-messages.js";
|
||||
import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
|
||||
|
||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import { reactSlackMessage } from "../../actions.js";
|
||||
import { sendMessageSlack } from "../../send.js";
|
||||
import type { SlackMessageEvent } from "../../types.js";
|
||||
|
||||
import { allowListMatches, resolveSlackUserAllowed } from "../allow-list.js";
|
||||
import {
|
||||
isSlackSenderAllowListed,
|
||||
resolveSlackEffectiveAllowFrom,
|
||||
} from "../auth.js";
|
||||
import { resolveSlackChannelConfig } from "../channel-config.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js";
|
||||
|
||||
import type { PreparedSlackMessage } from "./types.js";
|
||||
|
||||
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;
|
||||
|
||||
let channelInfo: {
|
||||
name?: string;
|
||||
type?: SlackMessageEvent["channel_type"];
|
||||
topic?: string;
|
||||
purpose?: string;
|
||||
} = {};
|
||||
let channelType = message.channel_type;
|
||||
if (!channelType || channelType !== "im") {
|
||||
channelInfo = await ctx.resolveChannelName(message.channel);
|
||||
channelType = channelType ?? channelInfo.type;
|
||||
}
|
||||
const channelName = channelInfo?.name;
|
||||
const resolvedChannelType = channelType;
|
||||
const isDirectMessage = resolvedChannelType === "im";
|
||||
const isGroupDm = resolvedChannelType === "mpim";
|
||||
const isRoom =
|
||||
resolvedChannelType === "channel" || resolvedChannelType === "group";
|
||||
const isRoomish = isRoom || isGroupDm;
|
||||
|
||||
const channelConfig = isRoom
|
||||
? resolveSlackChannelConfig({
|
||||
channelId: message.channel,
|
||||
channelName,
|
||||
channels: ctx.channelsConfig,
|
||||
})
|
||||
: null;
|
||||
|
||||
const allowBots =
|
||||
channelConfig?.allowBots ??
|
||||
account.config?.allowBots ??
|
||||
cfg.channels?.slack?.allowBots ??
|
||||
false;
|
||||
|
||||
const isBotMessage = Boolean(message.bot_id);
|
||||
if (isBotMessage) {
|
||||
if (message.user && ctx.botUserId && message.user === ctx.botUserId)
|
||||
return null;
|
||||
if (!allowBots) {
|
||||
logVerbose(
|
||||
`slack: drop bot message ${message.bot_id ?? "unknown"} (allowBots=false)`,
|
||||
);
|
||||
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);
|
||||
|
||||
if (isDirectMessage) {
|
||||
const directUserId = message.user;
|
||||
if (!directUserId) {
|
||||
logVerbose("slack: drop dm message (missing user id)");
|
||||
return null;
|
||||
}
|
||||
if (!ctx.dmEnabled || ctx.dmPolicy === "disabled") {
|
||||
logVerbose("slack: drop dm (dms disabled)");
|
||||
return null;
|
||||
}
|
||||
if (ctx.dmPolicy !== "open") {
|
||||
const permitted = allowListMatches({
|
||||
allowList: allowFromLower,
|
||||
id: directUserId,
|
||||
});
|
||||
if (!permitted) {
|
||||
if (ctx.dmPolicy === "pairing") {
|
||||
const sender = await ctx.resolveUserName(directUserId);
|
||||
const senderName = sender?.name ?? undefined;
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
channel: "slack",
|
||||
id: directUserId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(
|
||||
`slack pairing request sender=${directUserId} name=${senderName ?? "unknown"}`,
|
||||
);
|
||||
try {
|
||||
await sendMessageSlack(
|
||||
message.channel,
|
||||
buildPairingReply({
|
||||
channel: "slack",
|
||||
idLine: `Your Slack user id: ${directUserId}`,
|
||||
code,
|
||||
}),
|
||||
{
|
||||
token: ctx.botToken,
|
||||
client: ctx.app.client,
|
||||
accountId: account.accountId,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`slack pairing reply failed for ${message.user}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
`Blocked unauthorized slack sender ${message.user} (dmPolicy=${ctx.dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: account.accountId,
|
||||
teamId: ctx.teamId || undefined,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
||||
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
|
||||
},
|
||||
});
|
||||
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
const wasMentioned =
|
||||
opts.wasMentioned ??
|
||||
(!isDirectMessage &&
|
||||
(Boolean(
|
||||
ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`),
|
||||
) ||
|
||||
matchesMentionPatterns(message.text ?? "", mentionRegexes)));
|
||||
|
||||
const sender = message.user ? await ctx.resolveUserName(message.user) : null;
|
||||
const senderName =
|
||||
sender?.name ??
|
||||
message.username?.trim() ??
|
||||
message.user ??
|
||||
message.bot_id ??
|
||||
"unknown";
|
||||
|
||||
const channelUserAuthorized = isRoom
|
||||
? resolveSlackUserAllowed({
|
||||
allowList: channelConfig?.users,
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
})
|
||||
: true;
|
||||
if (isRoom && !channelUserAuthorized) {
|
||||
logVerbose(
|
||||
`Blocked unauthorized slack sender ${senderId} (not in channel users)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandAuthorized =
|
||||
isSlackSenderAllowListed({
|
||||
allowListLower: allowFromLower,
|
||||
senderId,
|
||||
senderName,
|
||||
}) && channelUserAuthorized;
|
||||
|
||||
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "slack",
|
||||
});
|
||||
const shouldRequireMention = isRoom
|
||||
? (channelConfig?.requireMention ?? true)
|
||||
: false;
|
||||
|
||||
// Allow "control commands" to bypass mention gating if sender is authorized.
|
||||
const shouldBypassMention =
|
||||
allowTextCommands &&
|
||||
isRoom &&
|
||||
shouldRequireMention &&
|
||||
!wasMentioned &&
|
||||
!hasAnyMention &&
|
||||
commandAuthorized &&
|
||||
hasControlCommand(message.text ?? "", cfg);
|
||||
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
||||
const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0;
|
||||
if (
|
||||
isRoom &&
|
||||
shouldRequireMention &&
|
||||
canDetectMention &&
|
||||
!wasMentioned &&
|
||||
!shouldBypassMention
|
||||
) {
|
||||
ctx.logger.info(
|
||||
{ channel: message.channel, reason: "no-mention" },
|
||||
"skipping room message",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const media = await resolveSlackMedia({
|
||||
files: message.files,
|
||||
token: ctx.botToken,
|
||||
maxBytes: ctx.mediaMaxBytes,
|
||||
});
|
||||
const rawBody = (message.text ?? "").trim() || media?.placeholder || "";
|
||||
if (!rawBody) return null;
|
||||
|
||||
const ackReaction = resolveAckReaction(cfg, route.agentId);
|
||||
const ackReactionValue = ackReaction ?? "";
|
||||
|
||||
const shouldAckReaction = () => {
|
||||
if (!ackReaction) return false;
|
||||
if (ctx.ackReactionScope === "all") return true;
|
||||
if (ctx.ackReactionScope === "direct") return isDirectMessage;
|
||||
if (ctx.ackReactionScope === "group-all") return isRoomish;
|
||||
if (ctx.ackReactionScope === "group-mentions") {
|
||||
if (!isRoom) return false;
|
||||
if (!shouldRequireMention) return false;
|
||||
if (!canDetectMention) return false;
|
||||
return wasMentioned || shouldBypassMention;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const ackReactionMessageTs = message.ts;
|
||||
const ackReactionPromise =
|
||||
shouldAckReaction() && ackReactionMessageTs && ackReactionValue
|
||||
? reactSlackMessage(
|
||||
message.channel,
|
||||
ackReactionMessageTs,
|
||||
ackReactionValue,
|
||||
{
|
||||
token: ctx.botToken,
|
||||
client: ctx.app.client,
|
||||
},
|
||||
).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
logVerbose(
|
||||
`slack react failed for channel ${message.channel}: ${String(err)}`,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
||||
const historyKey = message.channel;
|
||||
const historyEntry =
|
||||
isRoomish && ctx.historyLimit > 0
|
||||
? {
|
||||
sender: senderName,
|
||||
body: rawBody,
|
||||
timestamp: message.ts
|
||||
? Math.round(Number(message.ts) * 1000)
|
||||
: undefined,
|
||||
messageId: message.ts,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
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}`;
|
||||
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const threadTs = message.thread_ts;
|
||||
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
|
||||
const isThreadReply =
|
||||
hasThreadTs && (threadTs !== message.ts || Boolean(message.parent_user_id));
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId: isThreadReply ? threadTs : undefined,
|
||||
parentSessionKey: isThreadReply ? baseSessionKey : undefined,
|
||||
});
|
||||
const sessionKey = threadKeys.sessionKey;
|
||||
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
sessionKey,
|
||||
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
|
||||
});
|
||||
|
||||
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "Slack",
|
||||
from: senderName,
|
||||
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||
body: textWithId,
|
||||
});
|
||||
|
||||
let combinedBody = body;
|
||||
if (isRoomish && ctx.historyLimit > 0) {
|
||||
combinedBody = buildHistoryContextFromMap({
|
||||
historyMap: ctx.channelHistories,
|
||||
historyKey,
|
||||
limit: ctx.historyLimit,
|
||||
entry: historyEntry,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatAgentEnvelope({
|
||||
channel: "Slack",
|
||||
from: roomLabel,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.sender}: ${entry.body}${
|
||||
entry.messageId
|
||||
? ` [id:${entry.messageId} channel:${message.channel}]`
|
||||
: ""
|
||||
}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const slackTo = isDirectMessage
|
||||
? `user:${message.user}`
|
||||
: `channel:${message.channel}`;
|
||||
|
||||
const channelDescription = [channelInfo?.topic, channelInfo?.purpose]
|
||||
.map((entry) => entry?.trim())
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.filter((entry, index, list) => list.indexOf(entry) === index)
|
||||
.join("\n");
|
||||
const systemPromptParts = [
|
||||
channelDescription ? `Channel description: ${channelDescription}` : null,
|
||||
channelConfig?.systemPrompt?.trim() || null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const groupSystemPrompt =
|
||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
if (isThreadReply && threadTs) {
|
||||
const starter = await resolveSlackThreadStarter({
|
||||
channelId: message.channel,
|
||||
threadTs,
|
||||
client: ctx.app.client,
|
||||
});
|
||||
if (starter?.text) {
|
||||
const starterUser = starter.userId
|
||||
? await ctx.resolveUserName(starter.userId)
|
||||
: null;
|
||||
const starterName = starterUser?.name ?? starter.userId ?? "Unknown";
|
||||
const starterWithId = `${starter.text}\n[slack message id: ${starter.ts ?? threadTs} channel: ${message.channel}]`;
|
||||
threadStarterBody = formatThreadStarterEnvelope({
|
||||
channel: "Slack",
|
||||
author: starterName,
|
||||
timestamp: starter.ts
|
||||
? Math.round(Number(starter.ts) * 1000)
|
||||
: undefined,
|
||||
body: starterWithId,
|
||||
});
|
||||
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
|
||||
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
|
||||
} else {
|
||||
threadLabel = `Slack thread ${roomLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
const ctxPayload = {
|
||||
Body: combinedBody,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: slackFrom,
|
||||
To: slackTo,
|
||||
SessionKey: sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group",
|
||||
GroupSubject: isRoomish ? roomLabel : undefined,
|
||||
GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
Provider: "slack" as const,
|
||||
Surface: "slack" as const,
|
||||
MessageSid: message.ts,
|
||||
ReplyToId: message.thread_ts ?? message.ts,
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
ThreadLabel: threadLabel,
|
||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
||||
MediaPath: media?.path,
|
||||
MediaType: media?.contentType,
|
||||
MediaUrl: media?.path,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
OriginatingChannel: "slack" as const,
|
||||
OriginatingTo: slackTo,
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
const replyTarget = ctxPayload.To ?? undefined;
|
||||
if (!replyTarget) return null;
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`slack inbound: channel=${message.channel} from=${slackFrom} preview="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ctx,
|
||||
account,
|
||||
message,
|
||||
route,
|
||||
channelConfig,
|
||||
replyTarget,
|
||||
ctxPayload,
|
||||
isDirectMessage,
|
||||
isRoomish,
|
||||
historyKey,
|
||||
preview,
|
||||
ackReactionMessageTs,
|
||||
ackReactionValue,
|
||||
ackReactionPromise,
|
||||
};
|
||||
}
|
||||
22
src/slack/monitor/message-handler/types.ts
Normal file
22
src/slack/monitor/message-handler/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ResolvedAgentRoute } from "../../../routing/resolve-route.js";
|
||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import type { SlackMessageEvent } from "../../types.js";
|
||||
import type { SlackChannelConfigResolved } from "../channel-config.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
|
||||
export type PreparedSlackMessage = {
|
||||
ctx: SlackMonitorContext;
|
||||
account: ResolvedSlackAccount;
|
||||
message: SlackMessageEvent;
|
||||
route: ResolvedAgentRoute;
|
||||
channelConfig: SlackChannelConfigResolved | null;
|
||||
replyTarget: string;
|
||||
ctxPayload: Record<string, unknown>;
|
||||
isDirectMessage: boolean;
|
||||
isRoomish: boolean;
|
||||
historyKey: string;
|
||||
preview: string;
|
||||
ackReactionMessageTs?: string;
|
||||
ackReactionValue: string;
|
||||
ackReactionPromise: Promise<boolean> | null;
|
||||
};
|
||||
11
src/slack/monitor/policy.ts
Normal file
11
src/slack/monitor/policy.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function isSlackRoomAllowedByPolicy(params: {
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
channelAllowlistConfigured: boolean;
|
||||
channelAllowed: boolean;
|
||||
}): boolean {
|
||||
const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params;
|
||||
if (groupPolicy === "disabled") return false;
|
||||
if (groupPolicy === "open") return true;
|
||||
if (!channelAllowlistConfigured) return false;
|
||||
return channelAllowed;
|
||||
}
|
||||
147
src/slack/monitor/provider.ts
Normal file
147
src/slack/monitor/provider.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { App } from "@slack/bolt";
|
||||
|
||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import type { SessionScope } from "../../config/sessions.js";
|
||||
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
import { resolveSlackAccount } from "../accounts.js";
|
||||
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
|
||||
import { resolveSlackSlashCommandConfig } from "./commands.js";
|
||||
import { createSlackMonitorContext } from "./context.js";
|
||||
import { registerSlackMonitorEvents } from "./events.js";
|
||||
import { createSlackMessageHandler } from "./message-handler.js";
|
||||
import { registerSlackMonitorSlashCommands } from "./slash.js";
|
||||
|
||||
import type { MonitorSlackOpts } from "./types.js";
|
||||
|
||||
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
|
||||
const account = resolveSlackAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
account.config.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
|
||||
const sessionCfg = cfg.session;
|
||||
const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
||||
|
||||
const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
|
||||
const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
|
||||
if (!botToken || !appToken) {
|
||||
throw new Error(
|
||||
`Slack bot + app tokens missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
const slackCfg = account.config;
|
||||
const dmConfig = slackCfg.dm;
|
||||
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const dmPolicy = (dmConfig?.policy ?? "pairing") as DmPolicy;
|
||||
const allowFrom = dmConfig?.allowFrom;
|
||||
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
||||
const groupDmChannels = dmConfig?.groupChannels;
|
||||
const channelsConfig = slackCfg.channels;
|
||||
const groupPolicy = (slackCfg.groupPolicy ?? "open") as GroupPolicy;
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const reactionMode = slackCfg.reactionNotifications ?? "own";
|
||||
const reactionAllowlist = slackCfg.reactionAllowlist ?? [];
|
||||
const replyToMode = slackCfg.replyToMode ?? "off";
|
||||
const slashCommand = resolveSlackSlashCommandConfig(
|
||||
opts.slashCommand ?? slackCfg.slashCommand,
|
||||
);
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||
|
||||
const app = new App({
|
||||
token: botToken,
|
||||
appToken,
|
||||
socketMode: true,
|
||||
});
|
||||
|
||||
let botUserId = "";
|
||||
let teamId = "";
|
||||
try {
|
||||
const auth = await app.client.auth.test({ token: botToken });
|
||||
botUserId = auth.user_id ?? "";
|
||||
teamId = auth.team_id ?? "";
|
||||
} catch {
|
||||
// auth test failing is non-fatal; message handler falls back to regex mentions.
|
||||
}
|
||||
|
||||
const ctx = createSlackMonitorContext({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
botToken,
|
||||
app,
|
||||
runtime,
|
||||
botUserId,
|
||||
teamId,
|
||||
historyLimit,
|
||||
sessionScope,
|
||||
mainKey,
|
||||
dmEnabled,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupDmEnabled,
|
||||
groupDmChannels,
|
||||
channelsConfig,
|
||||
groupPolicy,
|
||||
useAccessGroups,
|
||||
reactionMode,
|
||||
reactionAllowlist,
|
||||
replyToMode,
|
||||
slashCommand,
|
||||
textLimit,
|
||||
ackReactionScope,
|
||||
mediaMaxBytes,
|
||||
removeAckAfterReply,
|
||||
});
|
||||
|
||||
const handleSlackMessage = createSlackMessageHandler({ ctx, account });
|
||||
|
||||
registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
|
||||
registerSlackMonitorSlashCommands({ ctx, account });
|
||||
|
||||
const stopOnAbort = () => {
|
||||
if (opts.abortSignal?.aborted) void app.stop();
|
||||
};
|
||||
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
|
||||
|
||||
try {
|
||||
await app.start();
|
||||
runtime.log?.("slack socket mode connected");
|
||||
if (opts.abortSignal?.aborted) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
opts.abortSignal?.addEventListener("abort", () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
|
||||
await app.stop().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
157
src/slack/monitor/replies.ts
Normal file
157
src/slack/monitor/replies.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
|
||||
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
|
||||
import {
|
||||
isSilentReplyText,
|
||||
SILENT_REPLY_TOKEN,
|
||||
} from "../../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { sendMessageSlack } from "../send.js";
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
token: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
textLimit: number;
|
||||
replyThreadTs?: string;
|
||||
}) {
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
for (const payload of params.replies) {
|
||||
const threadTs = payload.replyToId ?? params.replyThreadTs;
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN))
|
||||
continue;
|
||||
await sendMessageSlack(params.target, trimmed, {
|
||||
token: params.token,
|
||||
threadTs,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
await sendMessageSlack(params.target, caption, {
|
||||
token: params.token,
|
||||
mediaUrl,
|
||||
threadTs,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
params.runtime.log?.(`delivered reply to ${params.target}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type SlackRespondFn = (payload: {
|
||||
text: string;
|
||||
response_type?: "ephemeral" | "in_channel";
|
||||
}) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Compute effective threadTs for a Slack reply based on replyToMode.
|
||||
* - "off": stay in thread if already in one, otherwise main channel
|
||||
* - "first": first reply goes to thread, subsequent replies to main channel
|
||||
* - "all": all replies go to thread
|
||||
*/
|
||||
export function resolveSlackThreadTs(params: {
|
||||
replyToMode: "off" | "first" | "all";
|
||||
incomingThreadTs: string | undefined;
|
||||
messageTs: string | undefined;
|
||||
hasReplied: boolean;
|
||||
}): string | undefined {
|
||||
const planner = createSlackReplyReferencePlanner({
|
||||
replyToMode: params.replyToMode,
|
||||
incomingThreadTs: params.incomingThreadTs,
|
||||
messageTs: params.messageTs,
|
||||
hasReplied: params.hasReplied,
|
||||
});
|
||||
return planner.use();
|
||||
}
|
||||
|
||||
type SlackReplyDeliveryPlan = {
|
||||
nextThreadTs: () => string | undefined;
|
||||
markSent: () => void;
|
||||
};
|
||||
|
||||
function createSlackReplyReferencePlanner(params: {
|
||||
replyToMode: "off" | "first" | "all";
|
||||
incomingThreadTs: string | undefined;
|
||||
messageTs: string | undefined;
|
||||
hasReplied?: boolean;
|
||||
}) {
|
||||
return createReplyReferencePlanner({
|
||||
replyToMode: params.replyToMode,
|
||||
existingId: params.incomingThreadTs,
|
||||
startId: params.messageTs,
|
||||
hasReplied: params.hasReplied,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSlackReplyDeliveryPlan(params: {
|
||||
replyToMode: "off" | "first" | "all";
|
||||
incomingThreadTs: string | undefined;
|
||||
messageTs: string | undefined;
|
||||
hasRepliedRef: { value: boolean };
|
||||
}): SlackReplyDeliveryPlan {
|
||||
const replyReference = createSlackReplyReferencePlanner({
|
||||
replyToMode: params.replyToMode,
|
||||
incomingThreadTs: params.incomingThreadTs,
|
||||
messageTs: params.messageTs,
|
||||
hasReplied: params.hasRepliedRef.value,
|
||||
});
|
||||
return {
|
||||
nextThreadTs: () => replyReference.use(),
|
||||
markSent: () => {
|
||||
replyReference.markSent();
|
||||
params.hasRepliedRef.value = replyReference.hasReplied();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function deliverSlackSlashReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
respond: SlackRespondFn;
|
||||
ephemeral: boolean;
|
||||
textLimit: number;
|
||||
}) {
|
||||
const messages: string[] = [];
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
for (const payload of params.replies) {
|
||||
const textRaw = payload.text?.trim() ?? "";
|
||||
const text =
|
||||
textRaw && !isSilentReplyText(textRaw, SILENT_REPLY_TOKEN)
|
||||
? textRaw
|
||||
: undefined;
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const combined = [
|
||||
text ?? "",
|
||||
...mediaList.map((url) => url.trim()).filter(Boolean),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!combined) continue;
|
||||
for (const chunk of chunkMarkdownText(combined, chunkLimit)) {
|
||||
messages.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) return;
|
||||
|
||||
// Slack slash command responses can be multi-part by sending follow-ups via response_url.
|
||||
const responseType = params.ephemeral ? "ephemeral" : "in_channel";
|
||||
for (const text of messages) {
|
||||
await params.respond({ text, response_type: responseType });
|
||||
}
|
||||
}
|
||||
330
src/slack/monitor/slash.ts
Normal file
330
src/slack/monitor/slash.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import type { SlackCommandMiddlewareArgs } from "@slack/bolt";
|
||||
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||
import {
|
||||
buildCommandText,
|
||||
listNativeCommandSpecsForConfig,
|
||||
} from "../../auto-reply/commands-registry.js";
|
||||
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||
import { resolveNativeCommandsEnabled } from "../../config/commands.js";
|
||||
import { danger, logVerbose } from "../../globals.js";
|
||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
|
||||
import type { ResolvedSlackAccount } from "../accounts.js";
|
||||
|
||||
import {
|
||||
allowListMatches,
|
||||
normalizeAllowList,
|
||||
normalizeAllowListLower,
|
||||
resolveSlackUserAllowed,
|
||||
} from "./allow-list.js";
|
||||
import {
|
||||
resolveSlackChannelConfig,
|
||||
type SlackChannelConfigResolved,
|
||||
} from "./channel-config.js";
|
||||
import {
|
||||
buildSlackSlashCommandMatcher,
|
||||
resolveSlackSlashCommandConfig,
|
||||
} from "./commands.js";
|
||||
import type { SlackMonitorContext } from "./context.js";
|
||||
import { isSlackRoomAllowedByPolicy } from "./policy.js";
|
||||
import { deliverSlackSlashReplies } from "./replies.js";
|
||||
|
||||
export function registerSlackMonitorSlashCommands(params: {
|
||||
ctx: SlackMonitorContext;
|
||||
account: ResolvedSlackAccount;
|
||||
}) {
|
||||
const { ctx, account } = params;
|
||||
const cfg = ctx.cfg;
|
||||
const runtime = ctx.runtime;
|
||||
|
||||
const slashCommand = resolveSlackSlashCommandConfig(
|
||||
ctx.slashCommand ?? account.config.slashCommand,
|
||||
);
|
||||
|
||||
const handleSlashCommand = async (p: {
|
||||
command: SlackCommandMiddlewareArgs["command"];
|
||||
ack: SlackCommandMiddlewareArgs["ack"];
|
||||
respond: SlackCommandMiddlewareArgs["respond"];
|
||||
prompt: string;
|
||||
}) => {
|
||||
const { command, ack, respond, prompt } = p;
|
||||
try {
|
||||
if (!prompt.trim()) {
|
||||
await ack({
|
||||
text: "Message required.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await ack();
|
||||
|
||||
if (ctx.botUserId && command.user_id === ctx.botUserId) return;
|
||||
|
||||
const channelInfo = await ctx.resolveChannelName(command.channel_id);
|
||||
const channelType =
|
||||
channelInfo?.type ??
|
||||
(command.channel_name === "directmessage" ? "im" : undefined);
|
||||
const isDirectMessage = channelType === "im";
|
||||
const isGroupDm = channelType === "mpim";
|
||||
const isRoom = channelType === "channel" || channelType === "group";
|
||||
|
||||
if (
|
||||
!ctx.isChannelAllowed({
|
||||
channelId: command.channel_id,
|
||||
channelName: channelInfo?.name,
|
||||
channelType,
|
||||
})
|
||||
) {
|
||||
await respond({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(
|
||||
() => [],
|
||||
);
|
||||
const effectiveAllowFrom = normalizeAllowList([
|
||||
...ctx.allowFrom,
|
||||
...storeAllowFrom,
|
||||
]);
|
||||
const effectiveAllowFromLower =
|
||||
normalizeAllowListLower(effectiveAllowFrom);
|
||||
|
||||
let commandAuthorized = true;
|
||||
let channelConfig: SlackChannelConfigResolved | null = null;
|
||||
if (isDirectMessage) {
|
||||
if (!ctx.dmEnabled || ctx.dmPolicy === "disabled") {
|
||||
await respond({
|
||||
text: "Slack DMs are disabled.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ctx.dmPolicy !== "open") {
|
||||
const sender = await ctx.resolveUserName(command.user_id);
|
||||
const senderName = sender?.name ?? undefined;
|
||||
const permitted = allowListMatches({
|
||||
allowList: effectiveAllowFromLower,
|
||||
id: command.user_id,
|
||||
name: senderName,
|
||||
});
|
||||
if (!permitted) {
|
||||
if (ctx.dmPolicy === "pairing") {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
channel: "slack",
|
||||
id: command.user_id,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
if (created) {
|
||||
await respond({
|
||||
text: buildPairingReply({
|
||||
channel: "slack",
|
||||
idLine: `Your Slack user id: ${command.user_id}`,
|
||||
code,
|
||||
}),
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await respond({
|
||||
text: "You are not authorized to use this command.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
commandAuthorized = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRoom) {
|
||||
channelConfig = resolveSlackChannelConfig({
|
||||
channelId: command.channel_id,
|
||||
channelName: channelInfo?.name,
|
||||
channels: ctx.channelsConfig,
|
||||
});
|
||||
if (ctx.useAccessGroups) {
|
||||
const channelAllowlistConfigured =
|
||||
Boolean(ctx.channelsConfig) &&
|
||||
Object.keys(ctx.channelsConfig ?? {}).length > 0;
|
||||
const channelAllowed = channelConfig?.allowed !== false;
|
||||
if (
|
||||
!isSlackRoomAllowedByPolicy({
|
||||
groupPolicy: ctx.groupPolicy,
|
||||
channelAllowlistConfigured,
|
||||
channelAllowed,
|
||||
}) ||
|
||||
!channelAllowed
|
||||
) {
|
||||
await respond({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (ctx.useAccessGroups && channelConfig?.allowed === false) {
|
||||
await respond({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const sender = await ctx.resolveUserName(command.user_id);
|
||||
const senderName = sender?.name ?? command.user_name ?? command.user_id;
|
||||
const channelUserAllowed = isRoom
|
||||
? resolveSlackUserAllowed({
|
||||
allowList: channelConfig?.users,
|
||||
userId: command.user_id,
|
||||
userName: senderName,
|
||||
})
|
||||
: true;
|
||||
if (isRoom && !channelUserAllowed) {
|
||||
await respond({
|
||||
text: "You are not authorized to use this command here.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const channelName = channelInfo?.name;
|
||||
const roomLabel = channelName
|
||||
? `#${channelName}`
|
||||
: `#${command.channel_id}`;
|
||||
const isRoomish = isRoom || isGroupDm;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: account.accountId,
|
||||
teamId: ctx.teamId || undefined,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
||||
id: isDirectMessage ? command.user_id : command.channel_id,
|
||||
},
|
||||
});
|
||||
|
||||
const channelDescription = [channelInfo?.topic, channelInfo?.purpose]
|
||||
.map((entry) => entry?.trim())
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.filter((entry, index, list) => list.indexOf(entry) === index)
|
||||
.join("\n");
|
||||
const systemPromptParts = [
|
||||
channelDescription
|
||||
? `Channel description: ${channelDescription}`
|
||||
: null,
|
||||
channelConfig?.systemPrompt?.trim() || null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const groupSystemPrompt =
|
||||
systemPromptParts.length > 0
|
||||
? systemPromptParts.join("\n\n")
|
||||
: undefined;
|
||||
|
||||
const ctxPayload = {
|
||||
Body: prompt,
|
||||
From: isDirectMessage
|
||||
? `slack:${command.user_id}`
|
||||
: isRoom
|
||||
? `slack:channel:${command.channel_id}`
|
||||
: `slack:group:${command.channel_id}`,
|
||||
To: `slash:${command.user_id}`,
|
||||
ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group",
|
||||
GroupSubject: isRoomish ? roomLabel : undefined,
|
||||
GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: command.user_id,
|
||||
Provider: "slack" as const,
|
||||
Surface: "slack" as const,
|
||||
WasMentioned: true,
|
||||
MessageSid: command.trigger_id,
|
||||
Timestamp: Date.now(),
|
||||
SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`,
|
||||
CommandTargetSessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
CommandSource: "native" as const,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
OriginatingChannel: "slack" as const,
|
||||
OriginatingTo: `user:${command.user_id}`,
|
||||
};
|
||||
|
||||
const { counts } = await dispatchReplyWithDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||
.responsePrefix,
|
||||
deliver: async (payload) => {
|
||||
await deliverSlackSlashReplies({
|
||||
replies: [payload],
|
||||
respond,
|
||||
ephemeral: slashCommand.ephemeral,
|
||||
textLimit: ctx.textLimit,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(
|
||||
danger(`slack slash ${info.kind} reply failed: ${String(err)}`),
|
||||
);
|
||||
},
|
||||
},
|
||||
replyOptions: { skillFilter: channelConfig?.skills },
|
||||
});
|
||||
if (counts.final + counts.tool + counts.block === 0) {
|
||||
await deliverSlackSlashReplies({
|
||||
replies: [],
|
||||
respond,
|
||||
ephemeral: slashCommand.ephemeral,
|
||||
textLimit: ctx.textLimit,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`slack slash handler failed: ${String(err)}`));
|
||||
await respond({
|
||||
text: "Sorry, something went wrong handling that command.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: account.config.commands?.native,
|
||||
globalSetting: cfg.commands?.native,
|
||||
});
|
||||
const nativeCommands = nativeEnabled
|
||||
? listNativeCommandSpecsForConfig(cfg)
|
||||
: [];
|
||||
if (nativeCommands.length > 0) {
|
||||
for (const command of nativeCommands) {
|
||||
ctx.app.command(
|
||||
`/${command.name}`,
|
||||
async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => {
|
||||
const prompt = buildCommandText(command.name, cmd.text);
|
||||
await handleSlashCommand({ command: cmd, ack, respond, prompt });
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if (slashCommand.enabled) {
|
||||
ctx.app.command(
|
||||
buildSlackSlashCommandMatcher(slashCommand.name),
|
||||
async ({ command, ack, respond }: SlackCommandMiddlewareArgs) => {
|
||||
await handleSlashCommand({
|
||||
command,
|
||||
ack,
|
||||
respond,
|
||||
prompt: command.text?.trim() ?? "",
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
logVerbose("slack: slash commands disabled");
|
||||
}
|
||||
}
|
||||
85
src/slack/monitor/types.ts
Normal file
85
src/slack/monitor/types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
ClawdbotConfig,
|
||||
SlackSlashCommandConfig,
|
||||
} from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { SlackFile, SlackMessageEvent } from "../types.js";
|
||||
|
||||
export type MonitorSlackOpts = {
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
accountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
mediaMaxMb?: number;
|
||||
slashCommand?: SlackSlashCommandConfig;
|
||||
};
|
||||
|
||||
export type SlackReactionEvent = {
|
||||
type: "reaction_added" | "reaction_removed";
|
||||
user?: string;
|
||||
reaction?: string;
|
||||
item?: {
|
||||
type?: string;
|
||||
channel?: string;
|
||||
ts?: string;
|
||||
};
|
||||
item_user?: string;
|
||||
event_ts?: string;
|
||||
};
|
||||
|
||||
export type SlackMemberChannelEvent = {
|
||||
type: "member_joined_channel" | "member_left_channel";
|
||||
user?: string;
|
||||
channel?: string;
|
||||
channel_type?: SlackMessageEvent["channel_type"];
|
||||
event_ts?: string;
|
||||
};
|
||||
|
||||
export type SlackChannelCreatedEvent = {
|
||||
type: "channel_created";
|
||||
channel?: { id?: string; name?: string };
|
||||
event_ts?: string;
|
||||
};
|
||||
|
||||
export type SlackChannelRenamedEvent = {
|
||||
type: "channel_rename";
|
||||
channel?: { id?: string; name?: string; name_normalized?: string };
|
||||
event_ts?: string;
|
||||
};
|
||||
|
||||
export type SlackPinEvent = {
|
||||
type: "pin_added" | "pin_removed";
|
||||
channel_id?: string;
|
||||
user?: string;
|
||||
item?: { type?: string; message?: { ts?: string } };
|
||||
event_ts?: string;
|
||||
};
|
||||
|
||||
export type SlackMessageChangedEvent = {
|
||||
type: "message";
|
||||
subtype: "message_changed";
|
||||
channel?: string;
|
||||
message?: { ts?: string };
|
||||
previous_message?: { ts?: string };
|
||||
event_ts?: string;
|
||||
};
|
||||
|
||||
export type SlackMessageDeletedEvent = {
|
||||
type: "message";
|
||||
subtype: "message_deleted";
|
||||
channel?: string;
|
||||
deleted_ts?: string;
|
||||
event_ts?: string;
|
||||
};
|
||||
|
||||
export type SlackThreadBroadcastEvent = {
|
||||
type: "message";
|
||||
subtype: "thread_broadcast";
|
||||
channel?: string;
|
||||
message?: { ts?: string };
|
||||
event_ts?: string;
|
||||
};
|
||||
|
||||
export type { SlackFile, SlackMessageEvent };
|
||||
Reference in New Issue
Block a user