mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-21 03:54:45 +00:00
refactor: centralize channel ingress access
This commit is contained in:
@@ -1080,10 +1080,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
|
||||
useAccessGroups: true,
|
||||
authorizers: [{ configured: false, allowed: false }],
|
||||
});
|
||||
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
|
||||
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ChatType: "group",
|
||||
@@ -1164,10 +1161,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
|
||||
useAccessGroups: true,
|
||||
authorizers: [{ configured: true, allowed: true }],
|
||||
});
|
||||
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
|
||||
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ChatType: "group",
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import { resolveOpenDmAllowlistAccess } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import {
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
import {
|
||||
buildAgentMediaPayload,
|
||||
evaluateSupplementalContextVisibility,
|
||||
filterSupplementalContextItems,
|
||||
normalizeAgentId,
|
||||
resolveChannelContextVisibilityMode,
|
||||
} from "./bot-runtime-api.js";
|
||||
@@ -47,9 +45,10 @@ import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
|
||||
import {
|
||||
hasExplicitFeishuGroupConfig,
|
||||
isFeishuGroupAllowed,
|
||||
resolveFeishuAllowlistMatch,
|
||||
resolveFeishuDmIngressAccess,
|
||||
resolveFeishuGroupConfig,
|
||||
resolveFeishuGroupConversationIngressAccess,
|
||||
resolveFeishuGroupSenderActivationIngressAccess,
|
||||
resolveFeishuReplyPolicy,
|
||||
} from "./policy.js";
|
||||
import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js";
|
||||
@@ -353,44 +352,33 @@ export function buildFeishuAgentBody(params: {
|
||||
return messageBody;
|
||||
}
|
||||
|
||||
function isFetchedGroupContextSenderAllowed(params: {
|
||||
isGroup: boolean;
|
||||
allowFrom: Array<string | number>;
|
||||
senderId?: string;
|
||||
senderType?: string;
|
||||
}): boolean {
|
||||
if (!params.isGroup || params.allowFrom.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (params.senderType === "app") {
|
||||
return true;
|
||||
}
|
||||
const senderId = params.senderId?.trim();
|
||||
const senderAllowed =
|
||||
!!senderId &&
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: params.allowFrom,
|
||||
senderId,
|
||||
senderName: undefined,
|
||||
});
|
||||
return senderAllowed;
|
||||
}
|
||||
|
||||
function shouldIncludeFetchedGroupContextMessage(params: {
|
||||
async function shouldIncludeFetchedGroupContextMessage(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
chatId: string;
|
||||
isGroup: boolean;
|
||||
allowFrom: Array<string | number>;
|
||||
mode: "all" | "allowlist" | "allowlist_quote";
|
||||
kind: "quote" | "thread" | "history";
|
||||
senderId?: string;
|
||||
senderType?: string;
|
||||
}): boolean {
|
||||
const senderAllowed = isFetchedGroupContextSenderAllowed({
|
||||
isGroup: params.isGroup,
|
||||
allowFrom: params.allowFrom,
|
||||
senderId: params.senderId,
|
||||
senderType: params.senderType,
|
||||
});
|
||||
}): Promise<boolean> {
|
||||
let senderAllowed =
|
||||
!params.isGroup || params.allowFrom.length === 0 || params.senderType === "app";
|
||||
const senderId = params.senderId?.trim();
|
||||
if (!senderAllowed && senderId) {
|
||||
const access = await resolveFeishuGroupSenderActivationIngressAccess({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
chatId: params.chatId,
|
||||
allowFrom: params.allowFrom,
|
||||
senderOpenId: senderId,
|
||||
senderUserId: senderId,
|
||||
requireMention: false,
|
||||
mentionedBot: true,
|
||||
});
|
||||
senderAllowed = access.senderAccess.decision === "allow";
|
||||
}
|
||||
return evaluateSupplementalContextVisibility({
|
||||
mode: params.mode,
|
||||
kind: params.kind,
|
||||
@@ -398,29 +386,38 @@ function shouldIncludeFetchedGroupContextMessage(params: {
|
||||
}).include;
|
||||
}
|
||||
|
||||
function filterFetchedGroupContextMessages<
|
||||
async function filterFetchedGroupContextMessages<
|
||||
T extends Pick<FeishuMessageInfo, "senderId" | "senderType">,
|
||||
>(
|
||||
messages: readonly T[],
|
||||
params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
chatId: string;
|
||||
isGroup: boolean;
|
||||
allowFrom: Array<string | number>;
|
||||
mode: "all" | "allowlist" | "allowlist_quote";
|
||||
kind: "quote" | "thread" | "history";
|
||||
},
|
||||
): T[] {
|
||||
return filterSupplementalContextItems({
|
||||
items: messages,
|
||||
mode: params.mode,
|
||||
kind: params.kind,
|
||||
isSenderAllowed: (message) =>
|
||||
isFetchedGroupContextSenderAllowed({
|
||||
): Promise<T[]> {
|
||||
const results: Array<T | undefined> = await Promise.all(
|
||||
messages.map(async (message) =>
|
||||
(await shouldIncludeFetchedGroupContextMessage({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
chatId: params.chatId,
|
||||
isGroup: params.isGroup,
|
||||
allowFrom: params.allowFrom,
|
||||
mode: params.mode,
|
||||
kind: params.kind,
|
||||
senderId: message.senderId,
|
||||
senderType: message.senderType,
|
||||
}),
|
||||
}).items;
|
||||
}))
|
||||
? message
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
return results.filter((message): message is T => message !== undefined);
|
||||
}
|
||||
|
||||
export async function handleFeishuMessage(params: {
|
||||
@@ -595,7 +592,6 @@ export async function handleFeishuMessage(params: {
|
||||
const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined;
|
||||
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = feishuCfg?.allowFrom ?? [];
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const rawBroadcastAgents = isGroup ? resolveBroadcastAgents(cfg, ctx.chatId) : null;
|
||||
const broadcastAgents = rawBroadcastAgents
|
||||
? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
|
||||
@@ -639,39 +635,22 @@ export async function handleFeishuMessage(params: {
|
||||
groupId: ctx.chatId,
|
||||
});
|
||||
|
||||
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
|
||||
const groupAllowed =
|
||||
groupPolicy !== "disabled" &&
|
||||
(groupExplicitlyConfigured ||
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy,
|
||||
allowFrom: groupAllowFrom,
|
||||
senderId: ctx.chatId, // Check group ID, not sender ID
|
||||
senderName: undefined,
|
||||
}));
|
||||
const groupIngress = await resolveFeishuGroupConversationIngressAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
groupPolicy,
|
||||
groupAllowFrom,
|
||||
groupExplicitlyConfigured,
|
||||
});
|
||||
|
||||
if (!groupAllowed) {
|
||||
if (groupIngress.ingress.admission !== "dispatch") {
|
||||
log(
|
||||
`feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
|
||||
if (effectiveGroupSenderAllowFrom.length > 0) {
|
||||
const senderAllowed = isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: effectiveGroupSenderAllowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
senderIds: [senderUserId],
|
||||
senderName: ctx.senderName,
|
||||
});
|
||||
if (!senderAllowed) {
|
||||
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
({ requireMention } = resolveFeishuReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
cfg,
|
||||
@@ -680,7 +659,21 @@ export async function handleFeishuMessage(params: {
|
||||
groupPolicy,
|
||||
}));
|
||||
|
||||
if (requireMention && !ctx.mentionedBot) {
|
||||
const groupSenderActivationIngress = await resolveFeishuGroupSenderActivationIngressAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
allowFrom: effectiveGroupSenderAllowFrom,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
senderUserId,
|
||||
requireMention,
|
||||
mentionedBot: ctx.mentionedBot,
|
||||
});
|
||||
if (groupSenderActivationIngress.senderAccess.decision !== "allow") {
|
||||
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
|
||||
return;
|
||||
}
|
||||
if (groupSenderActivationIngress.ingress.admission !== "dispatch") {
|
||||
log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot`);
|
||||
// Record to pending history for non-broadcast groups only. For broadcast groups,
|
||||
// the mentioned handler's broadcast dispatch writes the turn directly into all
|
||||
@@ -715,34 +708,22 @@ export async function handleFeishuMessage(params: {
|
||||
commandProbeBody,
|
||||
cfg,
|
||||
);
|
||||
const storeAllowFrom =
|
||||
!isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open"
|
||||
? await pairing.readAllowFromStore().catch(() => [])
|
||||
: [];
|
||||
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const dmAllowed = resolveFeishuAllowlistMatch({
|
||||
allowFrom: effectiveDmAllowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
senderIds: [senderUserId],
|
||||
senderName: ctx.senderName,
|
||||
}).allowed;
|
||||
|
||||
const dmAccessAllowed =
|
||||
dmPolicy === "open"
|
||||
? resolveOpenDmAllowlistAccess({
|
||||
effectiveAllowFrom: effectiveDmAllowFrom,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
senderIds: [senderUserId],
|
||||
senderName: ctx.senderName,
|
||||
}).allowed,
|
||||
}).decision === "allow"
|
||||
: dmAllowed;
|
||||
|
||||
if (isDirect && !dmAccessAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const dmIngress = isDirect
|
||||
? await resolveFeishuDmIngressAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
allowFrom: configAllowFrom,
|
||||
readAllowFromStore: pairing.readAllowFromStore,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
senderUserId,
|
||||
conversationId: ctx.senderOpenId,
|
||||
mayPair: true,
|
||||
...(shouldComputeCommandAuthorized ? { command: { hasControlCommand: true } } : {}),
|
||||
})
|
||||
: null;
|
||||
if (isDirect && dmIngress?.ingress.admission !== "dispatch") {
|
||||
if (dmIngress?.ingress.admission === "pairing-required") {
|
||||
await pairing.issueChallenge({
|
||||
senderId: ctx.senderOpenId,
|
||||
senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
||||
@@ -774,13 +755,7 @@ export async function handleFeishuMessage(params: {
|
||||
|
||||
const commandAllowFrom = isGroup
|
||||
? (groupConfig?.allowFrom ?? configAllowFrom)
|
||||
: effectiveDmAllowFrom;
|
||||
const senderAllowedForCommands = resolveFeishuAllowlistMatch({
|
||||
allowFrom: commandAllowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
senderIds: [senderUserId],
|
||||
senderName: ctx.senderName,
|
||||
}).allowed;
|
||||
: (dmIngress?.senderAccess.effectiveAllowFrom ?? configAllowFrom);
|
||||
|
||||
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
||||
// Using a group-scoped From causes the agent to treat different users as the same person.
|
||||
@@ -982,12 +957,36 @@ export async function handleFeishuMessage(params: {
|
||||
? shouldComputeCommandAuthorized
|
||||
: core.channel.commands.shouldComputeCommandAuthorized(effectiveCommandProbeBody, cfg);
|
||||
const commandAuthorized = shouldComputeEffectiveCommandAuthorized
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
],
|
||||
})
|
||||
? isDirect && audioTranscript === undefined && dmIngress
|
||||
? dmIngress.commandAccess.authorized
|
||||
: isGroup
|
||||
? (
|
||||
await resolveFeishuGroupSenderActivationIngressAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
allowFrom: commandAllowFrom,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
senderUserId,
|
||||
requireMention: false,
|
||||
mentionedBot: true,
|
||||
command: { hasControlCommand: true },
|
||||
})
|
||||
).commandAccess.authorized
|
||||
: (
|
||||
await resolveFeishuDmIngressAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
allowFrom: configAllowFrom,
|
||||
readAllowFromStore: pairing.readAllowFromStore,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
senderUserId,
|
||||
conversationId: ctx.senderOpenId,
|
||||
mayPair: false,
|
||||
command: { hasControlCommand: true },
|
||||
})
|
||||
).commandAccess.authorized
|
||||
: undefined;
|
||||
|
||||
// Fetch quoted/replied message content if parentId exists
|
||||
@@ -1002,14 +1001,17 @@ export async function handleFeishuMessage(params: {
|
||||
});
|
||||
if (
|
||||
quotedMessageInfo &&
|
||||
shouldIncludeFetchedGroupContextMessage({
|
||||
(await shouldIncludeFetchedGroupContextMessage({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
isGroup,
|
||||
allowFrom: effectiveGroupSenderAllowFrom,
|
||||
mode: contextVisibilityMode,
|
||||
kind: "quote",
|
||||
senderId: quotedMessageInfo.senderId,
|
||||
senderType: quotedMessageInfo.senderType,
|
||||
})
|
||||
}))
|
||||
) {
|
||||
quotedContent = quotedMessageInfo.content;
|
||||
log(
|
||||
@@ -1115,14 +1117,17 @@ export async function handleFeishuMessage(params: {
|
||||
rootMessageThreadId = rootMessageInfo?.threadId;
|
||||
if (
|
||||
rootMessageInfo &&
|
||||
!shouldIncludeFetchedGroupContextMessage({
|
||||
!(await shouldIncludeFetchedGroupContextMessage({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
isGroup,
|
||||
allowFrom: effectiveGroupSenderAllowFrom,
|
||||
mode: contextVisibilityMode,
|
||||
kind: "thread",
|
||||
senderId: rootMessageInfo.senderId,
|
||||
senderType: rootMessageInfo.senderType,
|
||||
})
|
||||
}))
|
||||
) {
|
||||
log(
|
||||
`feishu[${account.accountId}]: skipped thread starter from sender ${rootMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
|
||||
@@ -1208,7 +1213,10 @@ export async function handleFeishuMessage(params: {
|
||||
.map((id) => id?.trim())
|
||||
.filter((id): id is string => id !== undefined && id.length > 0),
|
||||
);
|
||||
const allowlistedMessages = filterFetchedGroupContextMessages(threadMessages, {
|
||||
const allowlistedMessages = await filterFetchedGroupContextMessages(threadMessages, {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
isGroup,
|
||||
allowFrom: effectiveGroupSenderAllowFrom,
|
||||
mode: contextVisibilityMode,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveOpenDmAllowlistAccess } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
resolveDriveCommentEventTurn,
|
||||
type FeishuDriveCommentNoticeEvent,
|
||||
} from "./monitor.comment.js";
|
||||
import { resolveFeishuAllowlistMatch } from "./policy.js";
|
||||
import { resolveFeishuDmIngressAccess } from "./policy.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||
|
||||
@@ -88,30 +87,19 @@ export async function handleFeishuCommentEvent(
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const storeAllowFrom =
|
||||
dmPolicy !== "allowlist" && dmPolicy !== "open"
|
||||
? await pairing.readAllowFromStore().catch(() => [])
|
||||
: [];
|
||||
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const senderAllowed = resolveFeishuAllowlistMatch({
|
||||
allowFrom: effectiveDmAllowFrom,
|
||||
senderId: turn.senderId,
|
||||
senderIds: [turn.senderUserId],
|
||||
}).allowed;
|
||||
const dmAccessAllowed =
|
||||
dmPolicy === "open"
|
||||
? resolveOpenDmAllowlistAccess({
|
||||
effectiveAllowFrom: effectiveDmAllowFrom,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId: turn.senderId,
|
||||
senderIds: [turn.senderUserId],
|
||||
}).allowed,
|
||||
}).decision === "allow"
|
||||
: senderAllowed;
|
||||
if (!dmAccessAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const dmIngress = await resolveFeishuDmIngressAccess({
|
||||
cfg: params.cfg,
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
allowFrom: configAllowFrom,
|
||||
readAllowFromStore: pairing.readAllowFromStore,
|
||||
senderOpenId: turn.senderId,
|
||||
senderUserId: turn.senderUserId,
|
||||
conversationId: turn.senderId,
|
||||
mayPair: true,
|
||||
});
|
||||
if (dmIngress.ingress.admission !== "dispatch") {
|
||||
if (dmIngress.ingress.admission === "pairing-required") {
|
||||
const client = createFeishuClient(account);
|
||||
await pairing.issueChallenge({
|
||||
senderId: turn.senderId,
|
||||
|
||||
@@ -3,9 +3,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
hasExplicitFeishuGroupConfig,
|
||||
isFeishuGroupAllowed,
|
||||
resolveFeishuAllowlistMatch,
|
||||
resolveFeishuGroupConfig,
|
||||
resolveFeishuGroupSenderActivationIngressAccess,
|
||||
resolveFeishuReplyPolicy,
|
||||
} from "./policy.js";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
@@ -165,170 +164,60 @@ describe("hasExplicitFeishuGroupConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFeishuAllowlistMatch", () => {
|
||||
it("allows wildcard", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["*"],
|
||||
senderId: "ou-attacker",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
|
||||
});
|
||||
describe("resolveFeishuGroupSenderActivationIngressAccess", () => {
|
||||
async function senderDecision(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
senderOpenId: string;
|
||||
senderUserId?: string;
|
||||
}) {
|
||||
return (
|
||||
await resolveFeishuGroupSenderActivationIngressAccess({
|
||||
cfg: createCfg({}),
|
||||
accountId: "default",
|
||||
chatId: "oc_group",
|
||||
allowFrom: params.allowFrom,
|
||||
senderOpenId: params.senderOpenId,
|
||||
senderUserId: params.senderUserId,
|
||||
requireMention: false,
|
||||
mentionedBot: true,
|
||||
})
|
||||
).senderAccess.decision;
|
||||
}
|
||||
|
||||
it("allows provider-prefixed wildcard entries", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
it("allows provider-prefixed wildcard entries", async () => {
|
||||
await expect(
|
||||
senderDecision({
|
||||
allowFrom: ["feishu:*", "lark:*"],
|
||||
senderId: "ou_anyone",
|
||||
senderOpenId: "ou_anyone",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
|
||||
).resolves.toBe("allow");
|
||||
});
|
||||
|
||||
it("treats typed wildcard aliases as bare wildcards", () => {
|
||||
for (const wildcard of [
|
||||
"chat:*",
|
||||
"group:*",
|
||||
"channel:*",
|
||||
"user:*",
|
||||
"dm:*",
|
||||
"open_id:*",
|
||||
"feishu:user:*",
|
||||
]) {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: [wildcard],
|
||||
senderId: "ou_anyone",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
|
||||
}
|
||||
});
|
||||
|
||||
it("matches normalized ID entries", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["feishu:user:ou_ALLOWED"],
|
||||
senderId: "ou_ALLOWED",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user:ou_ALLOWED", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("accepts repeated provider prefixes for legacy allowlist entries", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
it("matches normalized immutable user ID entries", async () => {
|
||||
await expect(
|
||||
senderDecision({
|
||||
allowFrom: ["feishu:feishu:user:ou_ALLOWED"],
|
||||
senderId: "ou_ALLOWED",
|
||||
senderOpenId: "ou_ALLOWED",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user:ou_ALLOWED", matchSource: "id" });
|
||||
).resolves.toBe("allow");
|
||||
});
|
||||
|
||||
it("does not fold opaque IDs to lowercase", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["user:OU_ALLOWED"],
|
||||
senderId: "ou_ALLOWED",
|
||||
}),
|
||||
).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("keeps user and chat allowlist namespaces distinct", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
it("keeps user and chat allowlist namespaces distinct", async () => {
|
||||
await expect(
|
||||
senderDecision({
|
||||
allowFrom: ["user:oc_group_123"],
|
||||
senderId: "oc_group_123",
|
||||
senderOpenId: "oc_group_123",
|
||||
}),
|
||||
).toEqual({ allowed: false });
|
||||
).resolves.toBe("block");
|
||||
});
|
||||
|
||||
it("supports user_id as an additional immutable sender candidate", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
it("supports user_id as an additional immutable sender candidate", async () => {
|
||||
await expect(
|
||||
senderDecision({
|
||||
allowFrom: ["on_user_123"],
|
||||
senderId: "ou_other",
|
||||
senderIds: ["on_user_123"],
|
||||
senderOpenId: "ou_other",
|
||||
senderUserId: "on_user_123",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user:on_user_123", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("auto-detects bare open_id entries as user allowlist matches", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["ou_BARE"],
|
||||
senderId: "ou_BARE",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user:ou_BARE", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("auto-detects bare chat_id entries as chat allowlist matches", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["oc_group_123"],
|
||||
senderId: "oc_group_123",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "chat:oc_group_123", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("does not authorize based on display-name collision", () => {
|
||||
const victimOpenId = "ou_4f4ec5aa111122223333444455556666";
|
||||
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: [victimOpenId],
|
||||
senderId: "ou_attacker_real_open_id",
|
||||
senderIds: ["on_attacker_user_id"],
|
||||
senderName: victimOpenId,
|
||||
}),
|
||||
).toEqual({ allowed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFeishuGroupAllowed", () => {
|
||||
it("matches group IDs with chat: prefix", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["chat:oc_group_123"],
|
||||
senderId: "oc_group_123",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows group when groupPolicy is 'open'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats 'allowall' as equivalent to 'open'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowall",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects group when groupPolicy is 'disabled'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["oc_group_999"],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects group when groupPolicy is 'allowlist' and allowFrom is empty", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(false);
|
||||
).resolves.toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,38 +2,42 @@ import {
|
||||
normalizeAccountId,
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import {
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
type ChannelIngressIdentitySubjectInput,
|
||||
type ResolveChannelMessageIngressParams,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { AllowlistMatch, ChannelGroupContext } from "../runtime-api.js";
|
||||
import type { ChannelGroupContext } from "../runtime-api.js";
|
||||
import { detectIdType } from "./targets.js";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
|
||||
type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id">;
|
||||
type FeishuDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
|
||||
type FeishuGroupPolicy = "open" | "allowlist" | "disabled" | "allowall";
|
||||
type NormalizedFeishuGroupPolicy = Exclude<FeishuGroupPolicy, "allowall">;
|
||||
|
||||
const FEISHU_PROVIDER_PREFIX_RE = /^(feishu|lark):/i;
|
||||
|
||||
function stripRepeatedFeishuProviderPrefixes(raw: string): string {
|
||||
let normalized = raw.trim();
|
||||
while (FEISHU_PROVIDER_PREFIX_RE.test(normalized)) {
|
||||
normalized = normalized.replace(FEISHU_PROVIDER_PREFIX_RE, "").trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function canonicalizeFeishuAllowlistKey(params: { kind: "chat" | "user"; value: string }): string {
|
||||
const value = params.value.trim();
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
// A typed wildcard (`chat:*`, `user:*`, `open_id:*`, `dm:*`, `group:*`,
|
||||
// `channel:*`) collapses to the bare wildcard so it keeps matching across
|
||||
// both kinds, preserving the prior `normalizeFeishuTarget`-based behavior.
|
||||
if (value === "*") {
|
||||
return "*";
|
||||
}
|
||||
return `${params.kind}:${value}`;
|
||||
}
|
||||
const FEISHU_TYPED_PREFIX_RE = /^(chat|group|channel|user|dm|open_id):/i;
|
||||
const FEISHU_ID_KIND = "plugin:feishu-id" as const;
|
||||
const feishuIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "feishu-id",
|
||||
kind: FEISHU_ID_KIND,
|
||||
normalize: normalizeFeishuAllowEntry,
|
||||
sensitivity: "pii",
|
||||
aliases: [
|
||||
{
|
||||
key: "feishu-alt-id",
|
||||
kind: FEISHU_ID_KIND,
|
||||
normalizeEntry: () => null,
|
||||
normalizeSubject: normalizeFeishuAllowEntry,
|
||||
sensitivity: "pii",
|
||||
},
|
||||
],
|
||||
isWildcardEntry: (entry) => normalizeFeishuAllowEntry(entry) === "*",
|
||||
resolveEntryId: ({ entryIndex }) => `feishu-entry-${entryIndex + 1}`,
|
||||
});
|
||||
|
||||
function normalizeFeishuAllowEntry(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
@@ -44,7 +48,10 @@ function normalizeFeishuAllowEntry(raw: string): string {
|
||||
return "*";
|
||||
}
|
||||
|
||||
const withoutProviderPrefix = stripRepeatedFeishuProviderPrefixes(trimmed);
|
||||
let withoutProviderPrefix = trimmed;
|
||||
while (FEISHU_PROVIDER_PREFIX_RE.test(withoutProviderPrefix)) {
|
||||
withoutProviderPrefix = withoutProviderPrefix.replace(FEISHU_PROVIDER_PREFIX_RE, "").trim();
|
||||
}
|
||||
if (withoutProviderPrefix === "*") {
|
||||
return "*";
|
||||
}
|
||||
@@ -52,77 +59,170 @@ function normalizeFeishuAllowEntry(raw: string): string {
|
||||
if (!lowered) {
|
||||
return "";
|
||||
}
|
||||
// Lowercase for prefix detection only; preserve the original ID casing in the
|
||||
// canonicalized key. Sender candidates pass through this same path so allowlist
|
||||
// entries and runtime IDs stay normalized symmetrically.
|
||||
if (
|
||||
lowered.startsWith("chat:") ||
|
||||
lowered.startsWith("group:") ||
|
||||
lowered.startsWith("channel:")
|
||||
) {
|
||||
return canonicalizeFeishuAllowlistKey({
|
||||
kind: "chat",
|
||||
value: withoutProviderPrefix.slice(withoutProviderPrefix.indexOf(":") + 1),
|
||||
});
|
||||
}
|
||||
if (lowered.startsWith("user:") || lowered.startsWith("dm:")) {
|
||||
return canonicalizeFeishuAllowlistKey({
|
||||
kind: "user",
|
||||
value: withoutProviderPrefix.slice(withoutProviderPrefix.indexOf(":") + 1),
|
||||
});
|
||||
}
|
||||
if (lowered.startsWith("open_id:")) {
|
||||
return canonicalizeFeishuAllowlistKey({
|
||||
kind: "user",
|
||||
value: withoutProviderPrefix.slice(withoutProviderPrefix.indexOf(":") + 1),
|
||||
});
|
||||
const prefixed = lowered.match(FEISHU_TYPED_PREFIX_RE);
|
||||
if (prefixed?.[1]) {
|
||||
const kind = ["chat", "group", "channel"].includes(prefixed[1]) ? "chat" : "user";
|
||||
const value = withoutProviderPrefix.slice(prefixed[0].length).trim();
|
||||
return value === "*" ? "*" : value ? `${kind}:${value}` : "";
|
||||
}
|
||||
|
||||
const detectedType = detectIdType(withoutProviderPrefix);
|
||||
if (detectedType === "chat_id") {
|
||||
return canonicalizeFeishuAllowlistKey({
|
||||
kind: "chat",
|
||||
value: withoutProviderPrefix,
|
||||
});
|
||||
return `chat:${withoutProviderPrefix}`;
|
||||
}
|
||||
if (detectedType === "open_id" || detectedType === "user_id") {
|
||||
return canonicalizeFeishuAllowlistKey({
|
||||
kind: "user",
|
||||
value: withoutProviderPrefix,
|
||||
});
|
||||
return `user:${withoutProviderPrefix}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveFeishuAllowlistMatch(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderIds?: Array<string | null | undefined>;
|
||||
senderName?: string | null;
|
||||
}): FeishuAllowlistMatch {
|
||||
const allowFrom = params.allowFrom
|
||||
.map((entry) => normalizeFeishuAllowEntry(String(entry)))
|
||||
.filter(Boolean);
|
||||
if (allowFrom.length === 0) {
|
||||
return { allowed: false };
|
||||
}
|
||||
if (allowFrom.includes("*")) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
function normalizeFeishuDmPolicy(policy: string | null | undefined): FeishuDmPolicy {
|
||||
return policy === "open" ||
|
||||
policy === "pairing" ||
|
||||
policy === "allowlist" ||
|
||||
policy === "disabled"
|
||||
? policy
|
||||
: "pairing";
|
||||
}
|
||||
|
||||
// Feishu allowlists are ID-based; mutable display names must never grant access.
|
||||
const senderCandidates = [params.senderId, ...(params.senderIds ?? [])]
|
||||
.map((entry) => normalizeFeishuAllowEntry(entry ?? ""))
|
||||
.filter(Boolean);
|
||||
function normalizeFeishuGroupPolicy(policy: FeishuGroupPolicy): NormalizedFeishuGroupPolicy {
|
||||
return policy === "allowall" ? "open" : policy;
|
||||
}
|
||||
|
||||
for (const senderId of senderCandidates) {
|
||||
if (allowFrom.includes(senderId)) {
|
||||
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
||||
}
|
||||
}
|
||||
function createFeishuIngressSubject(params: {
|
||||
primaryId?: string | null;
|
||||
alternateIds?: Array<string | null | undefined>;
|
||||
}): ChannelIngressIdentitySubjectInput {
|
||||
const ids = [params.primaryId, ...(params.alternateIds ?? [])]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return {
|
||||
stableId: ids[0],
|
||||
aliases: {
|
||||
"feishu-alt-id": ids[1],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: false };
|
||||
function createFeishuIngressResolver(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
readAllowFromStore?: ResolveChannelMessageIngressParams["readStoreAllowFrom"];
|
||||
}) {
|
||||
return createChannelIngressResolver({
|
||||
channelId: "feishu",
|
||||
accountId: normalizeAccountId(params.accountId) ?? "default",
|
||||
identity: feishuIngressIdentity,
|
||||
cfg: params.cfg,
|
||||
...(params.readAllowFromStore ? { readStoreAllowFrom: params.readAllowFromStore } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveFeishuDmIngressAccess(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
dmPolicy?: string | null;
|
||||
allowFrom?: Array<string | number> | null;
|
||||
readAllowFromStore?: () => Promise<Array<string | number>>;
|
||||
senderOpenId: string;
|
||||
senderUserId?: string | null;
|
||||
conversationId: string;
|
||||
mayPair: boolean;
|
||||
command?: { hasControlCommand: boolean };
|
||||
}) {
|
||||
return await createFeishuIngressResolver({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
readAllowFromStore: params.readAllowFromStore,
|
||||
}).message({
|
||||
subject: createFeishuIngressSubject({
|
||||
primaryId: params.senderOpenId,
|
||||
alternateIds: [params.senderUserId],
|
||||
}),
|
||||
conversation: {
|
||||
kind: "direct",
|
||||
id: params.conversationId,
|
||||
},
|
||||
event: {
|
||||
mayPair: params.mayPair,
|
||||
},
|
||||
dmPolicy: normalizeFeishuDmPolicy(params.dmPolicy),
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: params.allowFrom ?? [],
|
||||
...(params.command ? { command: params.command } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveFeishuGroupConversationIngressAccess(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
chatId: string;
|
||||
groupPolicy: FeishuGroupPolicy;
|
||||
groupAllowFrom?: Array<string | number> | null;
|
||||
groupExplicitlyConfigured?: boolean;
|
||||
}) {
|
||||
const groupPolicy = normalizeFeishuGroupPolicy(params.groupPolicy);
|
||||
const groupAllowFrom =
|
||||
groupPolicy === "allowlist" && params.groupExplicitlyConfigured
|
||||
? [...(params.groupAllowFrom ?? []), params.chatId]
|
||||
: (params.groupAllowFrom ?? []);
|
||||
return await createFeishuIngressResolver({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).message({
|
||||
subject: createFeishuIngressSubject({
|
||||
primaryId: params.chatId,
|
||||
}),
|
||||
conversation: {
|
||||
kind: "group",
|
||||
id: params.chatId,
|
||||
},
|
||||
dmPolicy: "disabled",
|
||||
groupPolicy,
|
||||
groupAllowFrom,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveFeishuGroupSenderActivationIngressAccess(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
chatId: string;
|
||||
allowFrom?: Array<string | number> | null;
|
||||
senderOpenId: string;
|
||||
senderUserId?: string | null;
|
||||
requireMention: boolean;
|
||||
mentionedBot: boolean;
|
||||
command?: { hasControlCommand: boolean };
|
||||
}) {
|
||||
const groupAllowFrom = params.allowFrom ?? [];
|
||||
return await createFeishuIngressResolver({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).message({
|
||||
subject: createFeishuIngressSubject({
|
||||
primaryId: params.senderOpenId,
|
||||
alternateIds: [params.senderUserId],
|
||||
}),
|
||||
conversation: {
|
||||
kind: "group",
|
||||
id: params.chatId,
|
||||
},
|
||||
dmPolicy: "disabled",
|
||||
groupPolicy: groupAllowFrom.length > 0 ? "allowlist" : "open",
|
||||
groupAllowFrom,
|
||||
mentionFacts: {
|
||||
canDetectMention: true,
|
||||
wasMentioned: params.mentionedBot,
|
||||
},
|
||||
policy: {
|
||||
activation: {
|
||||
requireMention: params.requireMention,
|
||||
allowTextCommands: false,
|
||||
},
|
||||
},
|
||||
...(params.command ? { command: params.command } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveFeishuGroupConfig(params: { cfg?: FeishuConfig; groupId?: string | null }) {
|
||||
@@ -181,21 +281,6 @@ export function resolveFeishuGroupToolPolicy(params: ChannelGroupContext) {
|
||||
return groupConfig?.tools;
|
||||
}
|
||||
|
||||
export function isFeishuGroupAllowed(params: {
|
||||
groupPolicy: "open" | "allowlist" | "disabled" | "allowall";
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderIds?: Array<string | null | undefined>;
|
||||
senderName?: string | null;
|
||||
}): boolean {
|
||||
return evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy: params.groupPolicy === "allowall" ? "open" : params.groupPolicy,
|
||||
groupAllowFrom: params.allowFrom.map((entry) => String(entry)),
|
||||
senderId: params.senderId,
|
||||
isSenderAllowed: () => resolveFeishuAllowlistMatch(params).allowed,
|
||||
}).allowed;
|
||||
}
|
||||
|
||||
export function resolveFeishuReplyPolicy(params: {
|
||||
isDirectMessage: boolean;
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
Reference in New Issue
Block a user