refactor: centralize channel ingress access

This commit is contained in:
Peter Steinberger
2026-05-10 05:06:03 +01:00
parent 1725eebe62
commit a0fb7fb045
250 changed files with 11410 additions and 8161 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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;