diff --git a/CHANGELOG.md b/CHANGELOG.md index 23133f3ad22..d06661794e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - GitHub Copilot: refresh the model catalog from `${baseUrl}/models` so per-account entitlement and accurate context windows surface at runtime; static manifest catalog (now including `gpt-5.5`) remains the fallback when discovery is disabled or the API is unreachable. - Active Memory: support concrete `plugins.entries.active-memory.config.toolsAllow` recall tool names for custom memory plugins while keeping the built-in memory-core default on `memory_search`/`memory_get` and preserving `memory_recall` automatically for `plugins.slots.memory: "memory-lancedb"`. - Telegram: share the grammY API throttler across polling and ad hoc send clients for the same bot token, so visible draft previews and CLI sends use one quota gate. Thanks @anagnorisis2peripeteia. +- Feishu: resolve group policy/tool context from the trusted chat target for group turns while keeping the speaker in `From`, so @mention replies do not drop the configured group id. Fixes #79457. Thanks @greyxiong. - Telegram/Feishu: honor configured per-agent and global `reasoningDefault` values when deciding whether channel reasoning previews should stream or stay hidden, addressing the preview-default part of #73182. Thanks @anagnorisis2peripeteia. - QQBot: mark recognized framework slash commands as text-command turns before reply dispatch so `/models`, `/status`, and `/new` responses stay visible in QQ Bot C2C conversations. Fixes #79310. Thanks @rollingshmily. - Docker: run the runtime image under `tini` so long-lived containers reap orphaned child processes and forward signals correctly. (#77885) Thanks @VintageAyu. diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index de52e7a3147..525c5dc67db 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1,6 +1,7 @@ import type * as ConversationRuntime from "openclaw/plugin-sdk/conversation-runtime"; import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; +import { resolveGroupSessionKey } from "openclaw/plugin-sdk/session-store-runtime"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js"; import type { FeishuMessageEvent } from "./bot.js"; @@ -1219,6 +1220,61 @@ describe("handleFeishuMessage command authorization", () => { expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); }); + it("keeps Feishu group policy bound to the chat while preserving speaker identity", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groupPolicy: "open", + groupSenderAllowFrom: ["ou-allowed"], + groups: { + "oc-group": { + requireMention: false, + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-allowed", + }, + }, + message: { + message_id: "msg-group-context-79457", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + const finalized = mockFinalizeInboundContext.mock.calls.at(-1)?.[0]; + expect(finalized).toEqual( + expect.objectContaining({ + ChatType: "group", + From: "feishu:ou-allowed", + To: "chat:oc-group", + OriginatingChannel: "feishu", + OriginatingTo: "chat:oc-group", + SenderId: "ou-allowed", + }), + ); + expect(resolveGroupSessionKey(finalized as never)).toEqual( + expect.objectContaining({ + channel: "feishu", + id: "oc-group", + key: "feishu:group:oc-group", + }), + ); + expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); + }); + it("blocks group sender when global groupSenderAllowFrom excludes sender", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); diff --git a/src/config/sessions/group.ts b/src/config/sessions/group.ts index 4e845f5ba65..d8d52c0fd86 100644 --- a/src/config/sessions/group.ts +++ b/src/config/sessions/group.ts @@ -31,6 +31,34 @@ function normalizeGroupLabel(raw?: string) { return normalizeHyphenSlug(raw); } +function resolveOriginatingGroupTargetId(params: { + ctx: MsgContext; + provider: string; +}): string | null { + const target = normalizeOptionalString(params.ctx.OriginatingTo ?? params.ctx.To) ?? ""; + if (!target) { + return null; + } + const parts = target.split(":").filter(Boolean); + if (parts.length < 2) { + return null; + } + + const head = normalizeLowercaseStringOrEmpty(parts[0]); + const second = normalizeOptionalLowercaseString(parts[1]); + const secondIsKind = second === "group" || second === "channel"; + if (secondIsKind && (head === params.provider || getGroupSurfaces().has(head))) { + return parts.slice(2).join(":") || null; + } + if (head === params.provider || head === "chat" || head === "room" || head === "group") { + return parts.slice(1).join(":") || null; + } + if (head === "channel") { + return parts.slice(1).join(":") || null; + } + return null; +} + function shortenGroupId(value?: string) { const trimmed = normalizeOptionalString(value) ?? ""; if (!trimmed) { @@ -112,11 +140,15 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu : from.includes(":channel:") || normalizedChatType === "channel" ? "channel" : "group"; - const id = headIsSurface - ? secondIsKind - ? parts.slice(2).join(":") - : parts.slice(1).join(":") - : from; + const originatingGroupTargetId = + !secondIsKind && normalizedChatType ? resolveOriginatingGroupTargetId({ ctx, provider }) : null; + const id = originatingGroupTargetId + ? originatingGroupTargetId + : headIsSurface + ? secondIsKind + ? parts.slice(2).join(":") + : parts.slice(1).join(":") + : from; const finalId = normalizeLowercaseStringOrEmpty(id); if (!finalId) { return null;