fix(feishu): preserve disabled group policy for explicit groups

This commit is contained in:
Peter Steinberger
2026-04-27 21:55:27 +01:00
parent b3bc60ae25
commit 18ef83c0da
6 changed files with 196 additions and 50 deletions

View File

@@ -59,11 +59,11 @@ openclaw pairing approve feishu <CODE>
**Group policy** (`channels.feishu.groupPolicy`):
| Value | Behavior |
| ------------- | ------------------------------------------ |
| `"open"` | Respond to all messages in groups |
| `"allowlist"` | Only respond to groups in `groupAllowFrom` |
| `"disabled"` | Disable all group messages |
| Value | Behavior |
| ------------- | -------------------------------------------------------------------------------------------- |
| `"open"` | Respond to all messages in groups |
| `"allowlist"` | Only respond to groups in `groupAllowFrom` or explicitly configured under `groups.<chat_id>` |
| `"disabled"` | Disable all group messages |
Default: `allowlist`
@@ -117,6 +117,23 @@ Default: `allowlist`
}
```
You can also admit a group by adding an explicit `groups.<chat_id>` entry. Wildcard defaults under `groups.*` configure matching groups, but they do not admit groups by themselves.
```json5
{
channels: {
feishu: {
groupPolicy: "allowlist",
groups: {
oc_xxx: {
requireMention: false,
},
},
},
},
}
```
### Restrict senders within a group
```json5
@@ -386,34 +403,34 @@ See [Get group/user IDs](#get-groupuser-ids) for lookup tips.
Full configuration: [Gateway configuration](/gateway/configuration)
| Setting | Description | Default |
| ------------------------------------------------- | ------------------------------------------ | ---------------- |
| `channels.feishu.enabled` | Enable/disable the channel | `true` |
| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` |
| `channels.feishu.connectionMode` | Event transport (`websocket` or `webhook`) | `websocket` |
| `channels.feishu.defaultAccount` | Default account for outbound routing | `default` |
| `channels.feishu.verificationToken` | Required for webhook mode | — |
| `channels.feishu.encryptKey` | Required for webhook mode | — |
| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` |
| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` |
| `channels.feishu.webhookPort` | Webhook bind port | `3000` |
| `channels.feishu.accounts.<id>.appId` | App ID | — |
| `channels.feishu.accounts.<id>.appSecret` | App Secret | — |
| `channels.feishu.accounts.<id>.domain` | Per-account domain override | `feishu` |
| `channels.feishu.accounts.<id>.tts` | Per-account TTS override | `messages.tts` |
| `channels.feishu.dmPolicy` | DM policy | `allowlist` |
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | [BotOwnerId] |
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
| `channels.feishu.groupAllowFrom` | Group allowlist | — |
| `channels.feishu.requireMention` | Require @mention in groups | `true` |
| `channels.feishu.groups.<chat_id>.requireMention` | Per-group @mention override | inherited |
| `channels.feishu.groups.<chat_id>.enabled` | Enable/disable a specific group | `true` |
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
| `channels.feishu.streaming` | Streaming card output | `true` |
| `channels.feishu.blockStreaming` | Block-level streaming | `true` |
| `channels.feishu.typingIndicator` | Send typing reactions | `true` |
| `channels.feishu.resolveSenderNames` | Resolve sender display names | `true` |
| Setting | Description | Default |
| ------------------------------------------------- | -------------------------------------------------------------------------------- | ---------------- |
| `channels.feishu.enabled` | Enable/disable the channel | `true` |
| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` |
| `channels.feishu.connectionMode` | Event transport (`websocket` or `webhook`) | `websocket` |
| `channels.feishu.defaultAccount` | Default account for outbound routing | `default` |
| `channels.feishu.verificationToken` | Required for webhook mode | — |
| `channels.feishu.encryptKey` | Required for webhook mode | — |
| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` |
| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` |
| `channels.feishu.webhookPort` | Webhook bind port | `3000` |
| `channels.feishu.accounts.<id>.appId` | App ID | — |
| `channels.feishu.accounts.<id>.appSecret` | App Secret | — |
| `channels.feishu.accounts.<id>.domain` | Per-account domain override | `feishu` |
| `channels.feishu.accounts.<id>.tts` | Per-account TTS override | `messages.tts` |
| `channels.feishu.dmPolicy` | DM policy | `allowlist` |
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | [BotOwnerId] |
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
| `channels.feishu.groupAllowFrom` | Group allowlist | — |
| `channels.feishu.requireMention` | Require @mention in groups | `true` |
| `channels.feishu.groups.<chat_id>.requireMention` | Per-group @mention override; explicit IDs also admit the group in allowlist mode | inherited |
| `channels.feishu.groups.<chat_id>.enabled` | Enable/disable a specific group | `true` |
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
| `channels.feishu.streaming` | Streaming card output | `true` |
| `channels.feishu.blockStreaming` | Block-level streaming | `true` |
| `channels.feishu.typingIndicator` | Send typing reactions | `true` |
| `channels.feishu.resolveSenderNames` | Resolve sender display names | `true` |
---

View File

@@ -1425,7 +1425,7 @@ describe("handleFeishuMessage command authorization", () => {
const cfg: ClawdbotConfig = {
channels: {
feishu: {
// groupPolicy intentionally omitted -> schema default is "allowlist"
groupPolicy: "allowlist",
// groupAllowFrom intentionally omitted -> empty []
groups: {
"oc-explicit-group": {
@@ -1456,6 +1456,72 @@ describe("handleFeishuMessage command authorization", () => {
expect(mockDispatchReplyFromConfig).toHaveBeenCalled();
});
it("does not let explicit group config override disabled group policy", async () => {
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "disabled",
groups: {
"oc-disabled-policy-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: { open_id: "ou-sender" },
},
message: {
message_id: "msg-disabled-policy-group",
chat_id: "oc-disabled-policy-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello bot" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("does not treat wildcard group defaults as allowlist admission", async () => {
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groupPolicy: "allowlist",
groups: {
"*": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: { open_id: "ou-sender" },
},
message: {
message_id: "msg-wildcard-group-default",
chat_id: "oc-wildcard-only",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello bot" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("drops message when groupConfig.enabled is false", async () => {
const cfg: ClawdbotConfig = {
channels: {

View File

@@ -44,10 +44,11 @@ import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./d
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
import {
hasExplicitFeishuGroupConfig,
isFeishuGroupAllowed,
resolveFeishuAllowlistMatch,
resolveFeishuGroupConfig,
resolveFeishuReplyPolicy,
resolveFeishuAllowlistMatch,
isFeishuGroupAllowed,
} from "./policy.js";
import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
@@ -554,23 +555,25 @@ export async function handleFeishuMessage(params: {
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
// A group that is explicitly configured under `channels.feishu.groups.<chat_id>`
// is treated as admitted regardless of `groupAllowFrom`. The reporter case in
// #67687 only sets `groups.<chat_id>.requireMention=false` and leaves
// `groupAllowFrom` empty; with the schema-default `groupPolicy="allowlist"`,
// an empty allowlist would otherwise reject the group before any per-group
// `requireMention` override is evaluated.
const groupExplicitlyConfigured = groupConfig !== undefined;
// A group explicitly configured under `channels.feishu.groups.<chat_id>` is
// treated as admitted in allowlist mode even when `groupAllowFrom` is empty.
// Wildcard defaults still configure matching groups, but they are not an
// admission signal by themselves.
const groupExplicitlyConfigured = hasExplicitFeishuGroupConfig({
cfg: feishuCfg,
groupId: ctx.chatId,
});
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
const groupAllowed =
groupExplicitlyConfigured ||
isFeishuGroupAllowed({
groupPolicy,
allowFrom: groupAllowFrom,
senderId: ctx.chatId, // Check group ID, not sender ID
senderName: undefined,
});
groupPolicy !== "disabled" &&
(groupExplicitlyConfigured ||
isFeishuGroupAllowed({
groupPolicy,
allowFrom: groupAllowFrom,
senderId: ctx.chatId, // Check group ID, not sender ID
senderName: undefined,
}));
if (!groupAllowed) {
log(

View File

@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { describe, expect, it } from "vitest";
import { FeishuConfigSchema } from "./config-schema.js";
import {
hasExplicitFeishuGroupConfig,
isFeishuGroupAllowed,
resolveFeishuAllowlistMatch,
resolveFeishuGroupConfig,
@@ -141,6 +142,29 @@ describe("resolveFeishuGroupConfig", () => {
});
});
describe("hasExplicitFeishuGroupConfig", () => {
it("matches direct and case-insensitive group ids", () => {
const cfg = createFeishuConfig({
groups: {
OC_UPPER: { requireMention: true },
},
});
expect(hasExplicitFeishuGroupConfig({ cfg, groupId: "OC_UPPER" })).toBe(true);
expect(hasExplicitFeishuGroupConfig({ cfg, groupId: "oc_upper" })).toBe(true);
});
it("does not treat wildcard group defaults as explicit admission", () => {
const cfg = createFeishuConfig({
groups: {
"*": { requireMention: false },
},
});
expect(hasExplicitFeishuGroupConfig({ cfg, groupId: "oc_any" })).toBe(false);
});
});
describe("resolveFeishuAllowlistMatch", () => {
it("allows wildcard", () => {
expect(

View File

@@ -148,6 +148,25 @@ export function resolveFeishuGroupConfig(params: { cfg?: FeishuConfig; groupId?:
return wildcard;
}
export function hasExplicitFeishuGroupConfig(params: {
cfg?: FeishuConfig;
groupId?: string | null;
}): boolean {
const groups = params.cfg?.groups ?? {};
const groupId = params.groupId?.trim();
if (!groupId) {
return false;
}
if (Object.prototype.hasOwnProperty.call(groups, groupId) && groupId !== "*") {
return true;
}
const lowered = normalizeOptionalLowercaseString(groupId) ?? "";
return Object.keys(groups).some(
(key) => key !== "*" && normalizeOptionalLowercaseString(key) === lowered,
);
}
export function resolveFeishuGroupToolPolicy(params: ChannelGroupContext) {
const cfg = params.cfg.channels?.feishu;
if (!cfg) {

View File

@@ -30,6 +30,23 @@ describe("Dockerfile", () => {
expect(dockerfile).not.toContain("OPENCLAW_VARIANT");
});
it("installs CA certificates in the slim runtime stage", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
const collapsed = collapseDockerContinuations(dockerfile);
const runtimeIndex = collapsed.indexOf(
"FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-runtime",
);
const caInstallIndex = collapsed.indexOf(
"ca-certificates procps hostname curl git lsof openssl",
);
expect(runtimeIndex).toBeGreaterThan(-1);
expect(caInstallIndex).toBeGreaterThan(runtimeIndex);
expect(caInstallIndex).toBeLessThan(collapsed.indexOf("RUN chown node:node /app"));
expect(collapsed).toMatch(/apt-get install -y --no-install-recommends\s+ca-certificates/);
expect(collapsed).toContain("update-ca-certificates");
});
it("installs optional browser dependencies after pnpm install", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");