fix(qqbot): ignore bot self-echo events

This commit is contained in:
Peter Steinberger
2026-04-26 04:40:26 +01:00
parent e40094a9ef
commit 9e4a0e7f3c
5 changed files with 227 additions and 9 deletions

View File

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

View File

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

View File

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

View File

@@ -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<ProcessedAttachments>
>(),
);
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<string, unknown>) => 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> = {}): 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> = {}): 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);
});
});

View File

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