mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:00:47 +00:00
fix(qqbot): ignore bot self-echo events
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user