refactor(channels): unify dm pairing policy flows

This commit is contained in:
Peter Steinberger
2026-02-26 22:36:05 +01:00
parent 7e0b3f16e3
commit 564be6b402
9 changed files with 443 additions and 252 deletions

View File

@@ -0,0 +1,127 @@
import {
formatAllowlistMatchMeta,
issuePairingChallenge,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk";
import {
normalizeMatrixAllowList,
resolveMatrixAllowListMatch,
resolveMatrixAllowListMatches,
} from "./allowlist.js";
type MatrixDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
type MatrixGroupPolicy = "open" | "allowlist" | "disabled";
export async function resolveMatrixAccessState(params: {
isDirectMessage: boolean;
resolvedAccountId: string;
dmPolicy: MatrixDmPolicy;
groupPolicy: MatrixGroupPolicy;
allowFrom: string[];
groupAllowFrom: Array<string | number>;
senderId: string;
readStoreForDmPolicy: (provider: string, accountId: string) => Promise<string[]>;
}) {
const storeAllowFrom = params.isDirectMessage
? await readStoreAllowFromForDmPolicy({
provider: "matrix",
accountId: params.resolvedAccountId,
dmPolicy: params.dmPolicy,
readStore: params.readStoreForDmPolicy,
})
: [];
const normalizedGroupAllowFrom = normalizeMatrixAllowList(params.groupAllowFrom);
const senderGroupPolicy =
params.groupPolicy === "disabled"
? "disabled"
: normalizedGroupAllowFrom.length > 0
? "allowlist"
: "open";
const access = resolveDmGroupAccessWithLists({
isGroup: !params.isDirectMessage,
dmPolicy: params.dmPolicy,
groupPolicy: senderGroupPolicy,
allowFrom: params.allowFrom,
groupAllowFrom: normalizedGroupAllowFrom,
storeAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) =>
resolveMatrixAllowListMatches({
allowList: normalizeMatrixAllowList(allowFrom),
userId: params.senderId,
}),
});
const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom);
const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom);
return {
access,
effectiveAllowFrom,
effectiveGroupAllowFrom,
groupAllowConfigured: effectiveGroupAllowFrom.length > 0,
};
}
export async function enforceMatrixDirectMessageAccess(params: {
dmEnabled: boolean;
dmPolicy: MatrixDmPolicy;
accessDecision: "allow" | "block" | "pairing";
senderId: string;
senderName: string;
effectiveAllowFrom: string[];
upsertPairingRequest: (input: {
id: string;
meta?: Record<string, string | undefined>;
}) => Promise<{
code: string;
created: boolean;
}>;
sendPairingReply: (text: string) => Promise<void>;
logVerboseMessage: (message: string) => void;
}): Promise<boolean> {
if (!params.dmEnabled) {
return false;
}
if (params.accessDecision === "allow") {
return true;
}
const allowMatch = resolveMatrixAllowListMatch({
allowList: params.effectiveAllowFrom,
userId: params.senderId,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (params.accessDecision === "pairing") {
await issuePairingChallenge({
channel: "matrix",
senderId: params.senderId,
senderIdLine: `Matrix user id: ${params.senderId}`,
meta: { name: params.senderName },
upsertPairingRequest: params.upsertPairingRequest,
buildReplyText: ({ code }) =>
[
"OpenClaw: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"openclaw pairing approve matrix <code>",
].join("\n"),
sendPairingReply: params.sendPairingReply,
onCreated: () => {
params.logVerboseMessage(
`matrix pairing request sender=${params.senderId} name=${params.senderName ?? "unknown"} (${allowMatchMeta})`,
);
},
onReplyError: (err) => {
params.logVerboseMessage(
`matrix pairing reply failed for ${params.senderId}: ${String(err)}`,
);
},
});
return false;
}
params.logVerboseMessage(
`matrix: blocked dm sender ${params.senderId} (dmPolicy=${params.dmPolicy}, ${allowMatchMeta})`,
);
return false;
}

View File

@@ -7,9 +7,7 @@ import {
formatAllowlistMatchMeta,
logInboundDrop,
logTypingFailure,
readStoreAllowFromForDmPolicy,
resolveControlCommandGate,
resolveDmGroupAccessWithLists,
type PluginRuntime,
type RuntimeEnv,
type RuntimeLogger,
@@ -23,6 +21,7 @@ import {
type PollStartContent,
} from "../poll-types.js";
import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
import { enforceMatrixDirectMessageAccess, resolveMatrixAccessState } from "./access-policy.js";
import {
normalizeMatrixAllowList,
resolveMatrixAllowListMatch,
@@ -234,81 +233,34 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
senderId,
senderUsername,
});
const storeAllowFrom = isDirectMessage
? await readStoreAllowFromForDmPolicy({
provider: "matrix",
accountId: resolvedAccountId,
dmPolicy,
readStore: pairing.readStoreForDmPolicy,
})
: [];
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
const normalizedGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
const senderGroupPolicy =
groupPolicy === "disabled"
? "disabled"
: normalizedGroupAllowFrom.length > 0
? "allowlist"
: "open";
const access = resolveDmGroupAccessWithLists({
isGroup: isRoom,
dmPolicy,
groupPolicy: senderGroupPolicy,
allowFrom,
groupAllowFrom: normalizedGroupAllowFrom,
storeAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) =>
resolveMatrixAllowListMatches({
allowList: normalizeMatrixAllowList(allowFrom),
userId: senderId,
}),
});
const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom);
const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
const { access, effectiveAllowFrom, effectiveGroupAllowFrom, groupAllowConfigured } =
await resolveMatrixAccessState({
isDirectMessage,
resolvedAccountId,
dmPolicy,
groupPolicy,
allowFrom,
groupAllowFrom,
senderId,
readStoreForDmPolicy: pairing.readStoreForDmPolicy,
});
if (isDirectMessage) {
if (!dmEnabled) {
return;
}
if (access.decision !== "allow") {
const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom,
userId: senderId,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (access.decision === "pairing") {
const { code, created } = await pairing.upsertPairingRequest({
id: senderId,
meta: { name: senderName },
});
if (created) {
logVerboseMessage(
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
await sendMessageMatrix(
`room:${roomId}`,
[
"OpenClaw: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"openclaw pairing approve matrix <code>",
].join("\n"),
{ client },
);
} catch (err) {
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
}
}
} else {
logVerboseMessage(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
const allowedDirectMessage = await enforceMatrixDirectMessageAccess({
dmEnabled,
dmPolicy,
accessDecision: access.decision,
senderId,
senderName,
effectiveAllowFrom,
upsertPairingRequest: pairing.upsertPairingRequest,
sendPairingReply: async (text) => {
await sendMessageMatrix(`room:${roomId}`, text, { client });
},
logVerboseMessage,
});
if (!allowedDirectMessage) {
return;
}
}

View File

@@ -0,0 +1,48 @@
import { buildPairingReply } from "./pairing-messages.js";
type PairingMeta = Record<string, string | undefined>;
export type PairingChallengeParams = {
channel: string;
senderId: string;
senderIdLine: string;
meta?: PairingMeta;
upsertPairingRequest: (params: {
id: string;
meta?: PairingMeta;
}) => Promise<{ code: string; created: boolean }>;
sendPairingReply: (text: string) => Promise<void>;
buildReplyText?: (params: { code: string; senderIdLine: string }) => string;
onCreated?: (params: { code: string }) => void;
onReplyError?: (err: unknown) => void;
};
/**
* Shared pairing challenge issuance for DM pairing policy pathways.
* Ensures every channel follows the same create-if-missing + reply flow.
*/
export async function issuePairingChallenge(
params: PairingChallengeParams,
): Promise<{ created: boolean; code?: string }> {
const { code, created } = await params.upsertPairingRequest({
id: params.senderId,
meta: params.meta,
});
if (!created) {
return { created: false };
}
params.onCreated?.({ code });
const replyText =
params.buildReplyText?.({ code, senderIdLine: params.senderIdLine }) ??
buildPairingReply({
channel: params.channel,
idLine: params.senderIdLine,
code,
});
try {
await params.sendPairingReply(replyText);
} catch (err) {
params.onReplyError?.(err);
}
return { created: true, code };
}

View File

@@ -217,6 +217,7 @@ export {
} from "./group-access.js";
export { resolveSenderCommandAuthorization } from "./command-auth.js";
export { createScopedPairingAccess } from "./pairing-access.js";
export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
export { handleSlackMessageAction } from "./slack-message-actions.js";
export { extractToolSend } from "./tool-send.js";
export {

View File

@@ -0,0 +1,87 @@
import { issuePairingChallenge } from "../../pairing/pairing-challenge.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "../../security/dm-policy-shared.js";
import { isSignalSenderAllowed, type SignalSender } from "../identity.js";
type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
type SignalGroupPolicy = "open" | "allowlist" | "disabled";
export async function resolveSignalAccessState(params: {
accountId: string;
dmPolicy: SignalDmPolicy;
groupPolicy: SignalGroupPolicy;
allowFrom: string[];
groupAllowFrom: string[];
sender: SignalSender;
}) {
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "signal",
accountId: params.accountId,
dmPolicy: params.dmPolicy,
});
const resolveAccessDecision = (isGroup: boolean) =>
resolveDmGroupAccessWithLists({
isGroup,
dmPolicy: params.dmPolicy,
groupPolicy: params.groupPolicy,
allowFrom: params.allowFrom,
groupAllowFrom: params.groupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowEntries) => isSignalSenderAllowed(params.sender, allowEntries),
});
const dmAccess = resolveAccessDecision(false);
return {
resolveAccessDecision,
dmAccess,
effectiveDmAllow: dmAccess.effectiveAllowFrom,
effectiveGroupAllow: dmAccess.effectiveGroupAllowFrom,
};
}
export async function handleSignalDirectMessageAccess(params: {
dmPolicy: SignalDmPolicy;
dmAccessDecision: "allow" | "block" | "pairing";
senderId: string;
senderIdLine: string;
senderDisplay: string;
senderName?: string;
accountId: string;
sendPairingReply: (text: string) => Promise<void>;
log: (message: string) => void;
}): Promise<boolean> {
if (params.dmAccessDecision === "allow") {
return true;
}
if (params.dmAccessDecision === "block") {
if (params.dmPolicy !== "disabled") {
params.log(`Blocked signal sender ${params.senderDisplay} (dmPolicy=${params.dmPolicy})`);
}
return false;
}
if (params.dmPolicy === "pairing") {
await issuePairingChallenge({
channel: "signal",
senderId: params.senderId,
senderIdLine: params.senderIdLine,
meta: { name: params.senderName },
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "signal",
id,
accountId: params.accountId,
meta,
}),
sendPairingReply: params.sendPairingReply,
onCreated: () => {
params.log(`signal pairing request sender=${params.senderId}`);
},
onReplyError: (err) => {
params.log(`signal pairing reply failed for ${params.senderId}: ${String(err)}`);
},
});
}
return false;
}

View File

@@ -30,14 +30,8 @@ import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { mediaKindFromMime } from "../../media/constants.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import {
DM_GROUP_ACCESS_REASON,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "../../security/dm-policy-shared.js";
import { DM_GROUP_ACCESS_REASON } from "../../security/dm-policy-shared.js";
import { normalizeE164 } from "../../utils.js";
import {
formatSignalPairingIdLine,
@@ -50,6 +44,7 @@ import {
type SignalSender,
} from "../identity.js";
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js";
import type {
SignalEnvelope,
SignalEventHandlerDeps,
@@ -454,24 +449,15 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const hasBodyContent =
Boolean(messageText || quoteText) || Boolean(!reaction && dataMessage?.attachments?.length);
const senderDisplay = formatSignalSenderDisplay(sender);
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "signal",
accountId: deps.accountId,
dmPolicy: deps.dmPolicy,
});
const resolveAccessDecision = (isGroup: boolean) =>
resolveDmGroupAccessWithLists({
isGroup,
const { resolveAccessDecision, dmAccess, effectiveDmAllow, effectiveGroupAllow } =
await resolveSignalAccessState({
accountId: deps.accountId,
dmPolicy: deps.dmPolicy,
groupPolicy: deps.groupPolicy,
allowFrom: deps.allowFrom,
groupAllowFrom: deps.groupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowEntries) => isSignalSenderAllowed(sender, allowEntries),
sender,
});
const dmAccess = resolveAccessDecision(false);
const effectiveDmAllow = dmAccess.effectiveAllowFrom;
const effectiveGroupAllow = dmAccess.effectiveGroupAllowFrom;
if (
reaction &&
@@ -502,43 +488,25 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const isGroup = Boolean(groupId);
if (!isGroup) {
if (dmAccess.decision === "block") {
if (deps.dmPolicy !== "disabled") {
logVerbose(`Blocked signal sender ${senderDisplay} (dmPolicy=${deps.dmPolicy})`);
}
return;
}
if (dmAccess.decision === "pairing") {
if (deps.dmPolicy === "pairing") {
const senderId = senderAllowId;
const { code, created } = await upsertChannelPairingRequest({
channel: "signal",
id: senderId,
const allowedDirectMessage = await handleSignalDirectMessageAccess({
dmPolicy: deps.dmPolicy,
dmAccessDecision: dmAccess.decision,
senderId: senderAllowId,
senderIdLine,
senderDisplay,
senderName: envelope.sourceName ?? undefined,
accountId: deps.accountId,
sendPairingReply: async (text) => {
await sendMessageSignal(`signal:${senderRecipient}`, text, {
baseUrl: deps.baseUrl,
account: deps.account,
maxBytes: deps.mediaMaxBytes,
accountId: deps.accountId,
meta: { name: envelope.sourceName ?? undefined },
});
if (created) {
logVerbose(`signal pairing request sender=${senderId}`);
try {
await sendMessageSignal(
`signal:${senderRecipient}`,
buildPairingReply({
channel: "signal",
idLine: senderIdLine,
code,
}),
{
baseUrl: deps.baseUrl,
account: deps.account,
maxBytes: deps.mediaMaxBytes,
accountId: deps.accountId,
},
);
} catch (err) {
logVerbose(`signal pairing reply failed for ${senderId}: ${String(err)}`);
}
}
}
},
log: logVerbose,
});
if (!allowedDirectMessage) {
return;
}
}

View File

@@ -0,0 +1,67 @@
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
import { issuePairingChallenge } from "../../pairing/pairing-challenge.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import { resolveSlackAllowListMatch } from "./allow-list.js";
import type { SlackMonitorContext } from "./context.js";
export async function authorizeSlackDirectMessage(params: {
ctx: SlackMonitorContext;
accountId: string;
senderId: string;
allowFromLower: string[];
resolveSenderName: (senderId: string) => Promise<{ name?: string }>;
sendPairingReply: (text: string) => Promise<void>;
onDisabled: () => Promise<void> | void;
onUnauthorized: (params: { allowMatchMeta: string; senderName?: string }) => Promise<void> | void;
log: (message: string) => void;
}): Promise<boolean> {
if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") {
await params.onDisabled();
return false;
}
if (params.ctx.dmPolicy === "open") {
return true;
}
const sender = await params.resolveSenderName(params.senderId);
const senderName = sender?.name ?? undefined;
const allowMatch = resolveSlackAllowListMatch({
allowList: params.allowFromLower,
id: params.senderId,
name: senderName,
allowNameMatching: params.ctx.allowNameMatching,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (allowMatch.allowed) {
return true;
}
if (params.ctx.dmPolicy === "pairing") {
await issuePairingChallenge({
channel: "slack",
senderId: params.senderId,
senderIdLine: `Your Slack user id: ${params.senderId}`,
meta: { name: senderName },
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "slack",
id,
accountId: params.accountId,
meta,
}),
sendPairingReply: params.sendPairingReply,
onCreated: () => {
params.log(
`slack pairing request sender=${params.senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
},
onReplyError: (err) => {
params.log(`slack pairing reply failed for ${params.senderId}: ${String(err)}`);
},
});
return false;
}
await params.onUnauthorized({ allowMatchMeta, senderName });
return false;
}

View File

@@ -19,7 +19,6 @@ import {
shouldAckReaction as shouldAckReactionGate,
type AckReactionScope,
} from "../../../channels/ack-reactions.js";
import { formatAllowlistMatchMeta } from "../../../channels/allowlist-match.js";
import { resolveControlCommandGate } from "../../../channels/command-gating.js";
import { resolveConversationLabel } from "../../../channels/conversation-label.js";
import { logInboundDrop } from "../../../channels/logging.js";
@@ -28,8 +27,6 @@ import { recordInboundSession } from "../../../channels/session.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../../config/sessions.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { 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";
@@ -42,6 +39,7 @@ import { resolveSlackEffectiveAllowFrom } from "../auth.js";
import { resolveSlackChannelConfig } from "../channel-config.js";
import { stripSlackMentionsForCommandDetection } from "../commands.js";
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js";
import { authorizeSlackDirectMessage } from "../dm-auth.js";
import {
resolveSlackAttachmentContent,
MAX_SLACK_MEDIA_FILES,
@@ -137,59 +135,32 @@ export async function prepareSlackMessage(params: {
logVerbose("slack: drop dm message (missing user id)");
return null;
}
if (!ctx.dmEnabled || ctx.dmPolicy === "disabled") {
logVerbose("slack: drop dm (dms disabled)");
const allowed = await authorizeSlackDirectMessage({
ctx,
accountId: account.accountId,
senderId: directUserId,
allowFromLower,
resolveSenderName: ctx.resolveUserName,
sendPairingReply: async (text) => {
await sendMessageSlack(message.channel, text, {
token: ctx.botToken,
client: ctx.app.client,
accountId: account.accountId,
});
},
onDisabled: () => {
logVerbose("slack: drop dm (dms disabled)");
},
onUnauthorized: ({ allowMatchMeta }) => {
logVerbose(
`Blocked unauthorized slack sender ${message.user} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`,
);
},
log: logVerbose,
});
if (!allowed) {
return null;
}
if (ctx.dmPolicy !== "open") {
const allowMatch = resolveSlackAllowListMatch({
allowList: allowFromLower,
id: directUserId,
allowNameMatching: ctx.allowNameMatching,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) {
if (ctx.dmPolicy === "pairing") {
const sender = await ctx.resolveUserName(directUserId);
const senderName = sender?.name ?? undefined;
const { code, created } = await upsertChannelPairingRequest({
channel: "slack",
id: directUserId,
accountId: account.accountId,
meta: { name: senderName },
});
if (created) {
logVerbose(
`slack pairing request sender=${directUserId} name=${
senderName ?? "unknown"
} (${allowMatchMeta})`,
);
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}, ${allowMatchMeta})`,
);
}
return null;
}
}
}
const route = resolveAgentRoute({

View File

@@ -1,25 +1,18 @@
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
import { danger, logVerbose } from "../../globals.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js";
import { chunkItems } from "../../utils/chunk-items.js";
import type { ResolvedSlackAccount } from "../accounts.js";
import {
normalizeAllowList,
normalizeAllowListLower,
resolveSlackAllowListMatch,
resolveSlackUserAllowed,
} from "./allow-list.js";
import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "./allow-list.js";
import { resolveSlackEffectiveAllowFrom } from "./auth.js";
import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./channel-config.js";
import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js";
import type { SlackMonitorContext } from "./context.js";
import { normalizeSlackChannelType } from "./context.js";
import { authorizeSlackDirectMessage } from "./dm-auth.js";
import {
createSlackExternalArgMenuStore,
SLACK_EXTERNAL_ARG_MENU_PREFIX,
@@ -333,73 +326,50 @@ export async function registerSlackMonitorSlashCommands(params: {
return;
}
const storeAllowFrom = isDirectMessage
? await readStoreAllowFromForDmPolicy({
provider: "slack",
accountId: ctx.accountId,
dmPolicy: ctx.dmPolicy,
})
: [];
const effectiveAllowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]);
const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom);
const { allowFromLower: effectiveAllowFromLower } = await resolveSlackEffectiveAllowFrom(
ctx,
{
includePairingStore: isDirectMessage,
},
);
// Privileged command surface: compute CommandAuthorized, don't assume true.
// Keep this aligned with the Slack message path (message-handler/prepare.ts).
let commandAuthorized = false;
let channelConfig: SlackChannelConfigResolved | null = null;
if (isDirectMessage) {
if (!ctx.dmEnabled || ctx.dmPolicy === "disabled") {
await respond({
text: "Slack DMs are disabled.",
response_type: "ephemeral",
});
const allowed = await authorizeSlackDirectMessage({
ctx,
accountId: ctx.accountId,
senderId: command.user_id,
allowFromLower: effectiveAllowFromLower,
resolveSenderName: ctx.resolveUserName,
sendPairingReply: async (text) => {
await respond({
text,
response_type: "ephemeral",
});
},
onDisabled: async () => {
await respond({
text: "Slack DMs are disabled.",
response_type: "ephemeral",
});
},
onUnauthorized: async ({ allowMatchMeta }) => {
logVerbose(
`slack: blocked slash sender ${command.user_id} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`,
);
await respond({
text: "You are not authorized to use this command.",
response_type: "ephemeral",
});
},
log: logVerbose,
});
if (!allowed) {
return;
}
if (ctx.dmPolicy !== "open") {
const sender = await ctx.resolveUserName(command.user_id);
const senderName = sender?.name ?? undefined;
const allowMatch = resolveSlackAllowListMatch({
allowList: effectiveAllowFromLower,
id: command.user_id,
name: senderName,
allowNameMatching: ctx.allowNameMatching,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) {
if (ctx.dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
channel: "slack",
id: command.user_id,
accountId: ctx.accountId,
meta: { name: senderName },
});
if (created) {
logVerbose(
`slack pairing request sender=${command.user_id} name=${
senderName ?? "unknown"
} (${allowMatchMeta})`,
);
await respond({
text: buildPairingReply({
channel: "slack",
idLine: `Your Slack user id: ${command.user_id}`,
code,
}),
response_type: "ephemeral",
});
}
} else {
logVerbose(
`slack: blocked slash sender ${command.user_id} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`,
);
await respond({
text: "You are not authorized to use this command.",
response_type: "ephemeral",
});
}
return;
}
}
}
if (isRoom) {