diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 628236ce69f..3b93283e8cd 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -59,11 +59,11 @@ openclaw pairing approve feishu **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.` | +| `"disabled"` | Disable all group messages | Default: `allowlist` @@ -117,6 +117,23 @@ Default: `allowlist` } ``` +You can also admit a group by adding an explicit `groups.` 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..appId` | App ID | — | -| `channels.feishu.accounts..appSecret` | App Secret | — | -| `channels.feishu.accounts..domain` | Per-account domain override | `feishu` | -| `channels.feishu.accounts..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..requireMention` | Per-group @mention override | inherited | -| `channels.feishu.groups..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..appId` | App ID | — | +| `channels.feishu.accounts..appSecret` | App Secret | — | +| `channels.feishu.accounts..domain` | Per-account domain override | `feishu` | +| `channels.feishu.accounts..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..requireMention` | Per-group @mention override; explicit IDs also admit the group in allowlist mode | inherited | +| `channels.feishu.groups..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` | --- diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 24d50303cd5..755d523902d 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -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: { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index aefbf41c780..11c1ab08014 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -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.` - // is treated as admitted regardless of `groupAllowFrom`. The reporter case in - // #67687 only sets `groups..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.` 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( diff --git a/extensions/feishu/src/policy.test.ts b/extensions/feishu/src/policy.test.ts index 622dedc0a41..c17563518e1 100644 --- a/extensions/feishu/src/policy.test.ts +++ b/extensions/feishu/src/policy.test.ts @@ -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( diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts index 80237249986..afc932dffea 100644 --- a/extensions/feishu/src/policy.ts +++ b/extensions/feishu/src/policy.ts @@ -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) { diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index ad60aefbcad..950d4195164 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -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");