From 9e4a0e7f3cac7162a8ed3a6f2667fd4879134112 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 04:40:26 +0100 Subject: [PATCH] fix(qqbot): ignore bot self-echo events --- CHANGELOG.md | 1 + docs/channels/qqbot.md | 4 + extensions/qqbot/src/engine/access/types.ts | 1 + .../inbound-pipeline.self-echo.test.ts | 166 ++++++++++++++++++ .../src/engine/gateway/inbound-pipeline.ts | 64 ++++++- 5 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a027bbeb076..fe84d131c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,6 +149,7 @@ Docs: https://docs.openclaw.ai - CLI/gateway: keep diagnostic probes from creating first-time read-only device pairings, while still reusing cached device tokens for detailed read probes. Fixes #71766. Thanks @SunboZ. - CLI/plugins: keep `message` startup, `channels logs`, `agents delete`, and `agents set-identity` off broad plugin preloading; message delivery still loads plugins when the action actually runs. - Image understanding: resolve configured image models such as local LM Studio vision entries before reporting `Unknown model` when the discovery registry has not registered that provider. Fixes #66486. Thanks @zhanggpcsu. +- QQ Bot: ignore self-echoed bot messages using the outbound ref-index marker, preventing mirrored replies from re-entering the agent loop while still allowing users to quote bot replies. Fixes #71912. Thanks @wangyc6003. - Sessions: separate reset freshness from session-store `updatedAt`, so heartbeat, cron, exec, and gateway bookkeeping no longer prevent configured daily/idle resets from rolling long-running channel sessions. Fixes #68315, #63732, #63820, and #69083. Thanks @maxatv, @longhairedsi, @bradfreels, and @akessel56. - Sessions: clear queued system-event notices during `/new`, `/reset`, gateway `sessions.reset`, and daily/idle rollover so stale background updates cannot leak into the first prompt of the fresh session. Fixes #66864. Thanks @opeyio, @Magicray1217, and @cedillarack. - CLI/agents: keep `agents bind`, `agents unbind`, and `agents bindings` on setup-safe channel metadata paths so they do not preload bundled plugin runtimes or stage runtime dependencies. Fixes #71743. diff --git a/docs/channels/qqbot.md b/docs/channels/qqbot.md index f5cfbbf860a..e8b0ca42ed3 100644 --- a/docs/channels/qqbot.md +++ b/docs/channels/qqbot.md @@ -209,6 +209,10 @@ Approval prompts generated by the bot itself (for example, "allow this action?" - **Bot replies "gone to Mars":** credentials not configured or Gateway not started. - **No inbound messages:** verify `appId` and `clientSecret` are correct, and the bot is enabled on the QQ Open Platform. +- **Repeated self-replies:** OpenClaw records QQ outbound ref indexes as + bot-authored and ignores inbound events whose current `msgIdx` matches that + same bot account. This prevents platform echo loops while still allowing users + to quote or reply to previous bot messages. - **Setup with `--token-file` still shows unconfigured:** `--token-file` only sets the AppSecret. You still need `appId` in config or `QQBOT_APP_ID`. - **Proactive messages not arriving:** QQ may intercept bot-initiated messages if diff --git a/extensions/qqbot/src/engine/access/types.ts b/extensions/qqbot/src/engine/access/types.ts index 038ac5fb029..249eb207511 100644 --- a/extensions/qqbot/src/engine/access/types.ts +++ b/extensions/qqbot/src/engine/access/types.ts @@ -32,6 +32,7 @@ export const QQBOT_ACCESS_REASON = { GROUP_POLICY_DISABLED: "group_policy_disabled", GROUP_POLICY_EMPTY_ALLOWLIST: "group_policy_empty_allowlist", GROUP_POLICY_NOT_ALLOWLISTED: "group_policy_not_allowlisted", + BOT_SELF_ECHO: "bot_self_echo", } as const; export type QQBotAccessReasonCode = (typeof QQBOT_ACCESS_REASON)[keyof typeof QQBOT_ACCESS_REASON]; diff --git a/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts b/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts new file mode 100644 index 00000000000..26bda7c8d65 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QQBOT_ACCESS_REASON } from "../access/index.js"; +import type { RefIndexEntry } from "../ref/types.js"; +import type { InboundPipelineDeps } from "./inbound-context.js"; +import { buildInboundContext } from "./inbound-pipeline.js"; +import type { QueuedMessage } from "./message-queue.js"; +import type { GatewayAccount, GatewayPluginRuntime, ProcessedAttachments } from "./types.js"; + +const getRefIndexMock = vi.hoisted(() => vi.fn<(refIdx: string) => RefIndexEntry | null>()); +const setRefIndexMock = vi.hoisted(() => vi.fn<(refIdx: string, entry: RefIndexEntry) => void>()); +const formatRefEntryForAgentMock = vi.hoisted(() => vi.fn<(entry: RefIndexEntry) => string>()); +const processAttachmentsMock = vi.hoisted(() => + vi.fn< + ( + attachments: QueuedMessage["attachments"], + ctx: { accountId: string; cfg: unknown; log?: unknown }, + ) => Promise + >(), +); + +vi.mock("../ref/store.js", () => ({ + getRefIndex: getRefIndexMock, + setRefIndex: setRefIndexMock, + formatRefEntryForAgent: formatRefEntryForAgentMock, +})); + +vi.mock("./inbound-attachments.js", () => ({ + processAttachments: processAttachmentsMock, +})); + +const emptyProcessedAttachments: ProcessedAttachments = { + attachmentInfo: "", + imageUrls: [], + imageMediaTypes: [], + voiceAttachmentPaths: [], + voiceAttachmentUrls: [], + voiceAsrReferTexts: [], + voiceTranscripts: [], + voiceTranscriptSources: [], + attachmentLocalPaths: [], +}; + +const account: GatewayAccount = { + accountId: "qq-main", + appId: "app", + clientSecret: "secret", + markdownSupport: false, + config: {}, +}; + +function makeRuntime(): GatewayPluginRuntime { + return { + channel: { + activity: { record: vi.fn() }, + routing: { + resolveAgentRoute: vi.fn(() => ({ + sessionKey: "qqbot:c2c:user-openid", + accountId: "qq-main", + })), + }, + reply: { + dispatchReplyWithBufferedBlockDispatcher: vi.fn(), + finalizeInboundContext: vi.fn((fields: Record) => fields), + formatInboundEnvelope: vi.fn(() => "formatted inbound"), + resolveEffectiveMessagesConfig: vi.fn(() => ({})), + resolveEnvelopeFormatOptions: vi.fn(() => ({})), + }, + text: { + chunkMarkdownText: (text: string) => [text], + }, + }, + tts: { + textToSpeech: vi.fn(), + }, + }; +} + +function makeEvent(overrides: Partial = {}): QueuedMessage { + return { + type: "c2c", + senderId: "user-openid", + messageId: "msg-1", + content: "hello", + timestamp: "2026-04-25T00:00:00.000Z", + ...overrides, + }; +} + +function makeDeps(overrides: Partial = {}): InboundPipelineDeps { + return { + account, + cfg: {}, + log: { info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + runtime: makeRuntime(), + startTyping: vi.fn(async () => ({ keepAlive: null })), + ...overrides, + }; +} + +describe("buildInboundContext bot self-echo suppression", () => { + beforeEach(() => { + vi.clearAllMocks(); + getRefIndexMock.mockReturnValue(null); + formatRefEntryForAgentMock.mockReturnValue("bot reply"); + processAttachmentsMock.mockResolvedValue(emptyProcessedAttachments); + }); + + it("blocks inbound events whose current msgIdx matches this bot's outbound ref", async () => { + getRefIndexMock.mockReturnValue({ + content: "mirrored reply", + senderId: "qq-main", + timestamp: 1, + isBot: true, + }); + const deps = makeDeps(); + + const inbound = await buildInboundContext(makeEvent({ msgIdx: "REF_BOT" }), deps); + + expect(getRefIndexMock).toHaveBeenCalledWith("REF_BOT"); + expect(inbound.blocked).toBe(true); + expect(inbound.blockReasonCode).toBe(QQBOT_ACCESS_REASON.BOT_SELF_ECHO); + expect(inbound.body).toBe(""); + expect(deps.startTyping).not.toHaveBeenCalled(); + expect(processAttachmentsMock).not.toHaveBeenCalled(); + expect(setRefIndexMock).not.toHaveBeenCalled(); + }); + + it("does not block a user message that quotes a bot-authored ref", async () => { + getRefIndexMock.mockReturnValue({ + content: "previous bot reply", + senderId: "qq-main", + timestamp: 1, + isBot: true, + }); + const deps = makeDeps(); + + const inbound = await buildInboundContext(makeEvent({ refMsgIdx: "REF_BOT" }), deps); + + expect(getRefIndexMock).toHaveBeenCalledWith("REF_BOT"); + expect(formatRefEntryForAgentMock).toHaveBeenCalled(); + expect(inbound.blocked).toBe(false); + expect(inbound.replyTo).toMatchObject({ + id: "REF_BOT", + body: "bot reply", + isQuote: true, + }); + expect(deps.startTyping).toHaveBeenCalledTimes(1); + expect(processAttachmentsMock).toHaveBeenCalledTimes(1); + }); + + it("does not block matching refs from another QQ Bot account", async () => { + getRefIndexMock.mockReturnValue({ + content: "other bot reply", + senderId: "qq-other", + timestamp: 1, + isBot: true, + }); + const deps = makeDeps(); + + const inbound = await buildInboundContext(makeEvent({ msgIdx: "REF_BOT" }), deps); + + expect(inbound.blocked).toBe(false); + expect(deps.startTyping).toHaveBeenCalledTimes(1); + expect(processAttachmentsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts b/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts index ef82dc34bb2..4c10d2f0d2e 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts @@ -15,6 +15,7 @@ import { normalizeQQBotSenderId, resolveQQBotAccess, + QQBOT_ACCESS_REASON, type QQBotAccessResult, } from "../access/index.js"; import { @@ -56,6 +57,33 @@ export async function buildInboundContext( peer: { kind: isGroupChat ? "group" : "direct", id: peerId }, }); + const qualifiedTarget = isGroupChat + ? event.type === "guild" + ? `qqbot:channel:${event.channelId}` + : `qqbot:group:${event.groupOpenid}` + : event.type === "dm" + ? `qqbot:dm:${event.guildId}` + : `qqbot:c2c:${event.senderId}`; + const fromAddress = qualifiedTarget; + + const selfEchoAccess = resolveBotSelfEchoAccess(event, account.accountId); + if (selfEchoAccess) { + log?.info( + `Blocked qqbot inbound self-echo: reasonCode=${selfEchoAccess.reasonCode} ` + + `msgIdx=${event.msgIdx ?? ""} senderId=${normalizeQQBotSenderId(event.senderId)} ` + + `accountId=${account.accountId} isGroup=${isGroupChat}`, + ); + return buildBlockedInboundContext({ + event, + route, + isGroupChat, + peerId, + qualifiedTarget, + fromAddress, + access: selfEchoAccess, + }); + } + // ---- 1a. Early access control ---- // // Evaluate the account-level dmPolicy / groupPolicy + allowFrom / @@ -74,15 +102,6 @@ export async function buildInboundContext( groupPolicy: account.config?.groupPolicy, }); - const qualifiedTarget = isGroupChat - ? event.type === "guild" - ? `qqbot:channel:${event.channelId}` - : `qqbot:group:${event.groupOpenid}` - : event.type === "dm" - ? `qqbot:dm:${event.guildId}` - : `qqbot:c2c:${event.senderId}`; - const fromAddress = qualifiedTarget; - if (access.decision !== "allow") { log?.info( `Blocked qqbot inbound: decision=${access.decision} reasonCode=${access.reasonCode} ` + @@ -358,6 +377,33 @@ function buildBlockedInboundContext(params: { }; } +function resolveBotSelfEchoAccess( + event: QueuedMessage, + accountId: string, +): QQBotAccessResult | null { + const currentMsgIdx = event.msgIdx?.trim(); + if (!currentMsgIdx) { + return null; + } + + // Only the current message ref is a self-echo signal. `refMsgIdx` points at + // a quoted message, and real users must still be able to reply to bot output. + const refEntry = getRefIndex(currentMsgIdx); + if (refEntry?.isBot !== true || refEntry.senderId !== accountId) { + return null; + } + + return { + decision: "block", + reasonCode: QQBOT_ACCESS_REASON.BOT_SELF_ECHO, + reason: "bot self-echo", + effectiveAllowFrom: [], + effectiveGroupAllowFrom: [], + dmPolicy: "open", + groupPolicy: "open", + }; +} + // ============ Quote resolution (internal) ============ async function resolveQuote(