fix(feishu): keep group context on chat target

This commit is contained in:
brokemac79
2026-05-08 19:10:00 +01:00
committed by Peter Steinberger
parent a1b89317a8
commit 864cd1444b
3 changed files with 94 additions and 5 deletions

View File

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

View File

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

View File

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