refactor(channels): centralize mention and group activation decisions (#109480)

* refactor(channels): adopt shared mention-gate decision; consolidate group activation

* fix(matrix): carry effective mention decision

* test(session): pin qqbot accessor migration
This commit is contained in:
Peter Steinberger
2026-07-16 18:15:58 -07:00
committed by GitHub
parent 54f1800d2a
commit 0a19e1ed58
12 changed files with 147 additions and 209 deletions
@@ -884,6 +884,33 @@ describe("matrix monitor handler pairing account scope", () => {
expect(recordInboundSession).toHaveBeenCalled();
});
it.each([
{ body: "hello", isControlCommand: false, expectedDispatches: 0 },
{ body: "/new", isControlCommand: true, expectedDispatches: 1 },
])(
"keeps require-mention decision for unmentioned room text $body",
async ({ body, isControlCommand, expectedDispatches }) => {
const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({
cfg: { commands: { useAccessGroups: false } },
isDirectMessage: false,
mentionRegexes: [],
shouldHandleTextCommands: () => true,
hasControlCommand: (text?: string) => isControlCommand && text === body,
getMemberDisplayName: async () => "sender",
});
await handler(
"!room:example.org",
createMatrixTextMessageEvent({
eventId: `$unmentioned-${isControlCommand ? "command" : "text"}`,
body,
}),
);
expect(finalizeInboundContext).toHaveBeenCalledTimes(expectedDispatches);
},
);
it("processes room messages mentioned via displayName in formatted_body", async () => {
const recordInboundSession = vi.fn(async () => {});
const { handler } = createMatrixHandlerTestHarness({
+22 -10
View File
@@ -1,6 +1,7 @@
// Matrix plugin module implements handler behavior.
import {
buildChannelInboundEventContext,
resolveInboundMentionDecision,
toInboundMediaFacts,
} from "openclaw/plugin-sdk/channel-inbound";
import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
@@ -1156,16 +1157,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
? roomConfig?.requireMention
: true
: false;
const shouldBypassMention =
allowTextCommands &&
isRoom &&
shouldRequireMention &&
!wasMentioned &&
!hasExplicitMention &&
commandAuthorized &&
hasControlCommandInMessage;
const mentionDecision = resolveInboundMentionDecision({
facts: {
// Matrix native mention metadata lets us reliably decide absence even
// when no custom mention regex is configured.
canDetectMention: true,
wasMentioned,
hasAnyMention: hasExplicitMention,
},
policy: {
isGroup: isRoom,
requireMention: shouldRequireMention,
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
},
});
const { effectiveWasMentioned, shouldBypassMention } = mentionDecision;
const canDetectMention = agentMentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
if (mentionDecision.shouldSkip) {
const pendingHistoryBody = preflightAudioTranscript
? formatMatrixAudioTranscript(preflightAudioTranscript)
: pendingHistoryText || pendingHistoryPollText;
@@ -1368,6 +1378,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
isRoom,
shouldRequireMention,
wasMentioned,
effectiveWasMentioned,
shouldBypassMention,
canDetectMention,
commandAuthorized,
@@ -1438,6 +1449,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
isRoom,
shouldRequireMention,
wasMentioned,
effectiveWasMentioned,
shouldBypassMention,
canDetectMention,
commandAuthorized,
@@ -1680,7 +1692,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
isMentionableGroup: isRoom,
requireMention: shouldRequireMention,
canDetectMention,
effectiveWasMentioned: wasMentioned || shouldBypassMention,
effectiveWasMentioned,
shouldBypassMention,
}),
);
@@ -7,7 +7,6 @@ import {
} from "../commands/command-visibility.js";
import { initCommands } from "../commands/slash-commands-impl.js";
import { resolveGroupCommandLevelFromAccountConfig } from "../config/group.js";
import { createNodeSessionStoreReader } from "../group/activation.js";
import type { HistoryEntry } from "../group/history.js";
import { claimMessageReply } from "../messaging/outbound-reply.js";
import { setOutboundAudioPort } from "../messaging/outbound.js";
@@ -96,16 +95,11 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
allowTextCommands: ctx.group?.allowTextCommands,
isControlCommand: ctx.group?.isControlCommand,
resolveIntroHint: ctx.group?.resolveIntroHint,
sessionStoreReader: ctx.group?.sessionStoreReader,
};
const groupChatEnabled = groupOpts.enabled;
const groupHistories: Map<string, HistoryEntry[]> | undefined = groupChatEnabled
? new Map()
: undefined;
const sessionStoreReader = groupChatEnabled
? (groupOpts.sessionStoreReader ?? createNodeSessionStoreReader())
: undefined;
// Live config provider: per-inbound lookup so binding edits applied
// through the CLI take effect without a gateway restart (#69546).
const activeCfgProvider = createActiveCfgProvider({ fallback: ctx.cfg });
@@ -135,7 +129,6 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
runtime,
startTyping: (ev) => startTypingForEvent(ev, account, log),
groupHistories,
sessionStoreReader,
allowTextCommands: groupOpts.allowTextCommands,
isControlCommand: groupOpts.isControlCommand,
resolveGroupIntroHint: groupOpts.resolveIntroHint,
@@ -2,7 +2,7 @@
import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime";
import type { EngineAdapters } from "../adapter/index.js";
import type { QQBotGroupCommandLevel } from "../config/group.js";
import type { GroupActivationMode, SessionStoreReader } from "../group/activation.js";
import type { GroupActivationMode } from "../group/activation.js";
import type { HistoryEntry } from "../group/history.js";
import type { GroupMessageGateResult } from "../group/message-gating.js";
import type { QueuedMessage } from "./message-queue.js";
@@ -77,7 +77,6 @@ export interface InboundPipelineDeps {
keepAlive: TypingKeepAlive | null;
}>;
groupHistories?: Map<string, HistoryEntry[]>;
sessionStoreReader?: SessionStoreReader;
allowTextCommands?: boolean;
isControlCommand?: (content: string) => boolean;
resolveGroupIntroHint?: (params: {
@@ -1,5 +1,11 @@
// Qqbot tests cover group gate command-level enforcement.
import { describe, expect, it, vi } from "vitest";
vi.mock("openclaw/plugin-sdk/session-store-runtime", () => ({
getSessionEntry: vi.fn(() => undefined),
resolveStorePath: vi.fn(() => "/state/agents/default/openclaw-agent.sqlite"),
}));
import type { QQBotInboundAccess } from "../../adapter/index.js";
import type { InboundPipelineDeps } from "../inbound-context.js";
import type { QueuedMessage } from "../message-queue.js";
@@ -1,4 +1,5 @@
// Qqbot plugin module implements group gate stage behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { HistoryPort } from "../../adapter/history.port.js";
import type { QQBotInboundAccess } from "../../adapter/index.js";
import { classifyCoreCommandForGroup } from "../../commands/command-visibility.js";
@@ -38,7 +39,7 @@ interface GroupGateStageInput {
export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageResult {
const { event, deps, accountId, agentId, sessionKey, userContent, processedAttachments } = input;
const groupOpenid = event.groupOpenid!;
const cfg = (deps.cfg ?? {}) as Record<string, unknown>;
const cfg = (deps.cfg ?? {}) as OpenClawConfig;
const settings = resolveGroupSettings({ cfg, groupOpenid, accountId, agentId });
const { historyLimit, requireMention, ignoreOtherMentions } = settings.config;
@@ -65,7 +66,6 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes
agentId: agentId ?? "default",
sessionKey,
configRequireMention: requireMention,
sessionStoreReader: deps.sessionStoreReader,
});
const content = (event.content ?? "").trim();
@@ -199,11 +199,6 @@ interface GatewayGroupOptions {
accountId: string;
groupId: string;
}) => string | undefined;
/**
* Session-store reader for the `/activation` command override. When
* omitted, the engine loads a default node-based reader lazily.
*/
sessionStoreReader?: import("../group/activation.js").SessionStoreReader;
}
/** Full gateway startup context. */
@@ -1,115 +1,71 @@
// Qqbot tests cover activation plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveGroupActivation, type SessionStoreReader } from "./activation.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sessionStoreMocks = vi.hoisted(() => ({
getSessionEntry: vi.fn(),
resolveStorePath: vi.fn(() => "/state/agents/main/openclaw-agent.sqlite"),
}));
vi.mock("openclaw/plugin-sdk/session-store-runtime", () => sessionStoreMocks);
import { resolveGroupActivation } from "./activation.js";
describe("engine/group/activation", () => {
describe("resolveGroupActivation — no reader", () => {
it("maps configRequireMention=true → mention", () => {
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "s",
configRequireMention: true,
}),
).toBe("mention");
});
beforeEach(() => {
sessionStoreMocks.getSessionEntry.mockReset();
sessionStoreMocks.resolveStorePath.mockClear();
});
it("maps configRequireMention=false → always", () => {
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "s",
configRequireMention: false,
}),
).toBe("always");
it.each([
{ configRequireMention: true, expected: "mention" },
{ configRequireMention: false, expected: "always" },
] as const)("falls back to $expected when no override exists", (testCase) => {
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "missing",
configRequireMention: testCase.configRequireMention,
}),
).toBe(testCase.expected);
});
it.each([
{ raw: "mention", configRequireMention: false, expected: "mention" },
{ raw: "always", configRequireMention: true, expected: "always" },
{ raw: " Always ", configRequireMention: true, expected: "always" },
{ raw: "weird-mode", configRequireMention: true, expected: "mention" },
] as const)("resolves session activation $raw as $expected", (testCase) => {
sessionStoreMocks.getSessionEntry.mockReturnValue({ groupActivation: testCase.raw });
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: testCase.configRequireMention,
}),
).toBe(testCase.expected);
expect(sessionStoreMocks.resolveStorePath).toHaveBeenCalledWith(undefined, { agentId: "main" });
expect(sessionStoreMocks.getSessionEntry).toHaveBeenCalledWith({
storePath: "/state/agents/main/openclaw-agent.sqlite",
agentId: "main",
sessionKey: "k1",
});
});
describe("resolveGroupActivation — with reader", () => {
const makeReader = (
store: Record<string, { groupActivation?: string }> | null,
): SessionStoreReader => ({
read: () => store,
it("falls back when the session accessor fails", () => {
sessionStoreMocks.getSessionEntry.mockImplementation(() => {
throw new Error("unavailable");
});
it("honours explicit session-store override (mention)", () => {
const reader = makeReader({ k1: { groupActivation: "mention" } });
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: false,
sessionStoreReader: reader,
}),
).toBe("mention");
});
it("honours explicit session-store override (always)", () => {
const reader = makeReader({ k1: { groupActivation: "always" } });
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: true,
sessionStoreReader: reader,
}),
).toBe("always");
});
it("ignores override when the key is absent", () => {
const reader = makeReader({});
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "MISSING",
configRequireMention: true,
sessionStoreReader: reader,
}),
).toBe("mention");
});
it("ignores reader errors (null) and falls back", () => {
const reader = makeReader(null);
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: false,
sessionStoreReader: reader,
}),
).toBe("always");
});
it("ignores invalid activation values", () => {
const reader = makeReader({ k1: { groupActivation: "weird-mode" } });
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: true,
sessionStoreReader: reader,
}),
).toBe("mention");
});
it("normalizes whitespace / case", () => {
const reader = makeReader({ k1: { groupActivation: " Always " } });
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: true,
sessionStoreReader: reader,
}),
).toBe("always");
});
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: false,
}),
).toBe("always");
});
});
+19 -76
View File
@@ -1,89 +1,32 @@
// Qqbot plugin module implements activation behavior.
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
normalizeGroupActivation,
type GroupActivationMode,
} from "openclaw/plugin-sdk/group-activation";
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
export type GroupActivationMode = "mention" | "always";
export interface SessionStoreReader {
read(params: {
cfg: Record<string, unknown>;
agentId: string;
}): Record<string, { groupActivation?: string }> | null;
}
export type { GroupActivationMode } from "openclaw/plugin-sdk/group-activation";
export function resolveGroupActivation(params: {
cfg: Record<string, unknown>;
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
configRequireMention: boolean;
sessionStoreReader?: SessionStoreReader;
}): GroupActivationMode {
const fallback: GroupActivationMode = params.configRequireMention ? "mention" : "always";
const store = params.sessionStoreReader?.read({
cfg: params.cfg,
agentId: params.agentId,
});
if (!store) {
try {
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId });
const activation = normalizeGroupActivation(
getSessionEntry({
storePath,
agentId: params.agentId,
sessionKey: params.sessionKey,
})?.groupActivation,
);
return activation ?? fallback;
} catch {
return fallback;
}
const entry = store[params.sessionKey];
if (!entry?.groupActivation) {
return fallback;
}
const normalized = entry.groupActivation.trim().toLowerCase();
if (normalized === "mention" || normalized === "always") {
return normalized;
}
return fallback;
}
function resolveSessionStorePath(
cfg: Record<string, unknown>,
agentId: string | undefined,
): string {
const resolvedAgentId = agentId || "default";
const session =
typeof cfg.session === "object" && cfg.session !== null
? (cfg.session as { store?: unknown })
: undefined;
const rawStore = typeof session?.store === "string" ? session.store : undefined;
if (rawStore) {
let expanded = rawStore;
if (expanded.includes("{agentId}")) {
expanded = expanded.replaceAll("{agentId}", resolvedAgentId);
}
if (expanded.startsWith("~")) {
const home = process.env.HOME || process.env.USERPROFILE || "";
expanded = expanded.replace(/^~/, home);
}
return path.resolve(expanded);
}
const stateDir =
process.env.OPENCLAW_STATE_DIR?.trim() ||
process.env.CLAWDBOT_STATE_DIR?.trim() ||
path.join(process.env.HOME || process.env.USERPROFILE || "", ".openclaw");
return path.join(stateDir, "agents", resolvedAgentId, "sessions", "sessions.json");
}
export function createNodeSessionStoreReader(): SessionStoreReader {
return {
read: ({ cfg, agentId }) => {
try {
const storePath = resolveSessionStorePath(cfg, agentId);
if (!fs.existsSync(storePath)) {
return null;
}
const raw = fs.readFileSync(storePath, "utf-8");
return JSON.parse(raw) as Record<string, { groupActivation?: string }>;
} catch {
return null;
}
},
};
}
+8 -3
View File
@@ -11,6 +11,7 @@ import {
resolveThreadBindingSpawnPolicy,
} from "openclaw/plugin-sdk/conversation-runtime";
import { formatErrorMessage, formatUncaughtError } from "openclaw/plugin-sdk/error-runtime";
import { normalizeGroupActivation } from "openclaw/plugin-sdk/group-activation";
import {
isNativeCommandsExplicitlyDisabled,
resolveNativeCommandsEnabled,
@@ -311,11 +312,15 @@ export function createTelegramBotCore(
if (!getSessionEntry) {
return undefined;
}
const entry = getSessionEntry({ storePath, sessionKey });
if (entry?.groupActivation === "always") {
const storedActivation = getSessionEntry({ storePath, sessionKey })?.groupActivation;
const activation =
storedActivation === "mention" || storedActivation === "always"
? normalizeGroupActivation(storedActivation)
: undefined;
if (activation === "always") {
return false;
}
if (entry?.groupActivation === "mention") {
if (activation === "mention") {
return true;
}
} catch (err) {
@@ -161,6 +161,7 @@ export const migratedBundledPluginSessionAccessorFiles = new Set([
"extensions/mattermost/src/mattermost/model-picker.ts",
"extensions/matrix/src/matrix/monitor/handler.ts",
"extensions/matrix/src/session-route.ts",
"extensions/qqbot/src/engine/group/activation.ts",
"extensions/slack/src/monitor/slash.ts",
"extensions/telegram/src/bot-core.ts",
"extensions/telegram/src/bot-handlers.runtime.ts",
@@ -111,6 +111,7 @@ describe("session accessor boundary guard", () => {
"extensions/mattermost/src/mattermost/model-picker.ts",
"extensions/matrix/src/matrix/monitor/handler.ts",
"extensions/matrix/src/session-route.ts",
"extensions/qqbot/src/engine/group/activation.ts",
"extensions/slack/src/monitor/slash.ts",
"extensions/telegram/src/bot-core.ts",
"extensions/telegram/src/bot-handlers.runtime.ts",