diff --git a/extensions/qqbot/src/engine/api/api-client.test.ts b/extensions/qqbot/src/engine/api/api-client.test.ts index c37d20f284e4..03bb83d6ded3 100644 --- a/extensions/qqbot/src/engine/api/api-client.test.ts +++ b/extensions/qqbot/src/engine/api/api-client.test.ts @@ -97,6 +97,102 @@ describe("ApiClient", () => { }); }); + it("adds network and whitelist guidance to DNS failures without suggesting credentials", async () => { + fetchWithSsrFGuardMock.mockRejectedValueOnce( + new Error("getaddrinfo ENOTFOUND api.sgroup.qq.com"), + ); + + const client = new ApiClient({ baseUrl: "https://qqbot.test" }); + let error: unknown; + try { + await client.request("token-1", "GET", "/v2/users/@me"); + } catch (caught) { + error = caught; + } + + const message = error instanceof Error ? error.message : String(error); + expect(message).toContain("Network error [/v2/users/@me]"); + expect(message).toContain("network connectivity and DNS"); + expect(message).toContain("server IP whitelist"); + expect(message).not.toContain("appId"); + expect(message).not.toContain("clientSecret"); + }); + + it("adds credential guidance to structured HTTP 401 errors", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response('{"code":11241,"message":"invalid credentials"}', { + status: 401, + headers: { "content-type": "application/json" }, + }), + release, + }); + + const client = new ApiClient({ baseUrl: "https://qqbot.test" }); + let error: unknown; + try { + await client.request("token-1", "POST", "/v2/messages", { content: "hi" }); + } catch (caught) { + error = caught; + } + + const message = error instanceof Error ? error.message : String(error); + expect(message).toContain("API Error [/v2/messages]: invalid credentials"); + expect(message).toContain("QQBot account appId and clientSecret"); + expect(message).toContain("https://q.qq.com/"); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("adds credential guidance when QQ reports an expired token as HTTP 500", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response('{"code":11244,"message":"token not exist or expire"}', { + status: 500, + headers: { "content-type": "application/json" }, + }), + release, + }); + + const client = new ApiClient({ baseUrl: "https://qqbot.test" }); + let error: unknown; + try { + await client.request("token-1", "GET", "/gateway"); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ httpStatus: 500, bizCode: 11244 }); + expect((error as Error).message).toContain("QQBot account appId and clientSecret"); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("keeps non-auth structured API guidance generic", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response('{"code":40034025,"message":"invalid event id"}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + release, + }); + + const client = new ApiClient({ baseUrl: "https://qqbot.test" }); + let error: unknown; + try { + await client.request("token-1", "POST", "/v2/messages", { content: "hi" }); + } catch (caught) { + error = caught; + } + + const message = error instanceof Error ? error.message : String(error); + expect(message).toContain("API Error [/v2/messages]: invalid event id"); + expect(message).toContain("QQBot API troubleshooting"); + expect(message).not.toContain("appId"); + expect(message).not.toContain("clientSecret"); + expect(release).toHaveBeenCalledTimes(1); + }); + it("bounds successful response bodies without using response.text()", async () => { const release = vi.fn(async () => {}); const streamed = createStreamingResponse({ diff --git a/extensions/qqbot/src/engine/api/api-client.ts b/extensions/qqbot/src/engine/api/api-client.ts index 2b363db36113..aec72cf41c26 100644 --- a/extensions/qqbot/src/engine/api/api-client.ts +++ b/extensions/qqbot/src/engine/api/api-client.ts @@ -16,6 +16,7 @@ import { } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { qqbotApiGuidance, qqbotNetworkGuidance } from "../config/setup-guidance.js"; import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js"; const DEFAULT_BASE_URL = "https://api.sgroup.qq.com"; @@ -149,7 +150,11 @@ export class ApiClient { throw new ApiError(`Request timeout [${path}]: exceeded ${timeout}ms`, 0, path); } this.logger?.error?.(`[qqbot:api] <<< Network error: ${formatErrorMessage(err)}`); - throw new ApiError(`Network error [${path}]: ${formatErrorMessage(err)}`, 0, path); + throw new ApiError( + `Network error [${path}]: ${formatErrorMessage(err)}. ${qqbotNetworkGuidance()}`, + 0, + path, + ); } const res = guarded.response; @@ -206,7 +211,7 @@ export class ApiClient { }; const bizCode = error.code ?? error.err_code; throw new ApiError( - `API Error [${path}]: ${error.message ?? rawBody}`, + `API Error [${path}]: ${error.message ?? rawBody}. ${qqbotApiGuidance(res.status, bizCode)}`, res.status, path, bizCode, diff --git a/extensions/qqbot/src/engine/api/auth-errors.ts b/extensions/qqbot/src/engine/api/auth-errors.ts new file mode 100644 index 000000000000..79e3e192b198 --- /dev/null +++ b/extensions/qqbot/src/engine/api/auth-errors.ts @@ -0,0 +1,6 @@ +const QQBOT_TOKEN_EXPIRED_OR_MISSING_CODE = 11244; + +/** Match QQ's HTTP and business-code signals for an invalid access token. */ +export function isQQBotTokenAuthenticationFailure(httpStatus: number, bizCode?: number): boolean { + return httpStatus === 401 || bizCode === QQBOT_TOKEN_EXPIRED_OR_MISSING_CODE; +} diff --git a/extensions/qqbot/src/engine/api/token.test.ts b/extensions/qqbot/src/engine/api/token.test.ts index a8d270fde6a6..b9bc093f324c 100644 --- a/extensions/qqbot/src/engine/api/token.test.ts +++ b/extensions/qqbot/src/engine/api/token.test.ts @@ -89,6 +89,28 @@ describe("QQBot token manager", () => { expect(release).toHaveBeenCalledTimes(1); }); + it("adds account-neutral credential guidance when the token endpoint omits access_token", async () => { + const release = mockGuardedTokenResponse('{"code":4001,"message":"invalid app secret"}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + + let error: unknown; + try { + await new TokenManager().getAccessToken("app-id", "secret"); + } catch (caught) { + error = caught; + } + + const message = error instanceof Error ? error.message : String(error); + expect(message).toContain("Failed to get QQBot access_token"); + expect(message).toContain("QQBot account appId and clientSecret"); + expect(message).toContain("https://q.qq.com/"); + expect(message).toContain('{"code":4001,"message":"invalid app secret"}'); + expect(message).not.toContain("QQBOT_APP_ID"); + expect(release).toHaveBeenCalledTimes(1); + }); + it("bounds access token responses without using response.text()", async () => { const logger = { debug: vi.fn(), info: vi.fn(), error: vi.fn() }; const tracked = cancelTrackedResponse(`${"qqbot token unavailable ".repeat(1024)}tail`, { @@ -230,7 +252,10 @@ describe("QQBot token manager", () => { } const timeoutError = firstOutcome.reason as Error; expect(timeoutError).toBe(secondOutcome.reason); - expect(timeoutError.message).toBe("Network error getting access_token: request timed out"); + expect(timeoutError.message).toContain("Network error getting access_token: request timed out"); + expect(timeoutError.message).toContain("Check network connectivity and DNS"); + expect(timeoutError.message).toContain("server IP whitelist"); + expect(timeoutError.message).not.toContain("appId"); expect(timeoutError.cause).toMatchObject({ name: "TimeoutError", message: "request timed out", diff --git a/extensions/qqbot/src/engine/api/token.ts b/extensions/qqbot/src/engine/api/token.ts index 79c7e25dd406..bcc9ee6653b3 100644 --- a/extensions/qqbot/src/engine/api/token.ts +++ b/extensions/qqbot/src/engine/api/token.ts @@ -16,6 +16,7 @@ import { import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; +import { qqbotNetworkGuidance, qqbotTokenFailureMessage } from "../config/setup-guidance.js"; import type { EngineLogger } from "../types.js"; const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"; @@ -267,9 +268,10 @@ export class TokenManager { release = guarded.release; } catch (err) { this.logger?.error?.(`[qqbot:token:${appId}] Network error: ${formatErrorMessage(err)}`); - throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, { - cause: err, - }); + throw new Error( + `Network error getting access_token: ${formatErrorMessage(err)}. ${qqbotNetworkGuidance()}`, + { cause: err }, + ); } try { @@ -297,7 +299,7 @@ export class TokenManager { } if (!data.access_token) { - throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`); + throw new Error(qqbotTokenFailureMessage(JSON.stringify(data))); } const nowMs = asDateTimestampMs(Date.now()); diff --git a/extensions/qqbot/src/engine/config/setup-guidance.test.ts b/extensions/qqbot/src/engine/config/setup-guidance.test.ts new file mode 100644 index 000000000000..36ff0d58e139 --- /dev/null +++ b/extensions/qqbot/src/engine/config/setup-guidance.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + qqbotApiGuidance, + qqbotNetworkGuidance, + qqbotNotConfiguredMessage, +} from "./setup-guidance.js"; + +describe("QQBot setup guidance", () => { + it("offers default-account config and environment variables", () => { + const message = qqbotNotConfiguredMessage("default"); + + expect(message).toContain("channels.qqbot.appId"); + expect(message).toContain("QQBOT_APP_ID and QQBOT_CLIENT_SECRET"); + expect(message).toContain("https://docs.openclaw.ai/channels/qqbot"); + }); + + it("directs named accounts to account-scoped config without default-only environment variables", () => { + const message = qqbotNotConfiguredMessage("operations"); + + expect(message).toContain("channels.qqbot.accounts.operations.appId"); + expect(message).toContain("clientSecret (or clientSecretFile)"); + expect(message).not.toContain("QQBOT_APP_ID"); + expect(message).not.toContain("QQBOT_CLIENT_SECRET"); + }); + + it("keeps authentication guidance account-neutral", () => { + const message = qqbotApiGuidance(401); + + expect(message).toContain("QQBot account appId"); + expect(message).toContain("https://q.qq.com/"); + expect(message).not.toContain("QQBOT_APP_ID"); + expect(message).not.toContain("QQBOT_CLIENT_SECRET"); + }); + + it("keeps network guidance cause-specific", () => { + const message = qqbotNetworkGuidance(); + + expect(message).toContain("network connectivity and DNS"); + expect(message).toContain("server IP whitelist"); + expect(message).not.toContain("appId"); + expect(message).not.toContain("clientSecret"); + }); + + it("uses credential guidance for HTTP and QQ business-code auth failures", () => { + expect(qqbotApiGuidance(401)).toContain("appId and clientSecret"); + expect(qqbotApiGuidance(500, 11244)).toContain("appId and clientSecret"); + expect(qqbotApiGuidance(403)).not.toContain("appId"); + expect(qqbotApiGuidance(500, 40034025)).not.toContain("appId"); + expect(qqbotApiGuidance(429)).not.toContain("appId"); + }); +}); diff --git a/extensions/qqbot/src/engine/config/setup-guidance.ts b/extensions/qqbot/src/engine/config/setup-guidance.ts new file mode 100644 index 000000000000..8288e2e84eb5 --- /dev/null +++ b/extensions/qqbot/src/engine/config/setup-guidance.ts @@ -0,0 +1,31 @@ +import { isQQBotTokenAuthenticationFailure } from "../api/auth-errors.js"; +import { DEFAULT_ACCOUNT_ID } from "./resolve.js"; + +const QQBOT_DOCS_URL = "https://docs.openclaw.ai/channels/qqbot"; +const QQBOT_OPEN_PLATFORM_URL = "https://q.qq.com/"; + +function qqbotAuthGuidance(): string { + return `Check the QQBot account appId and clientSecret (or clientSecretFile) in OpenClaw and verify the credentials in QQ Open Platform at ${QQBOT_OPEN_PLATFORM_URL}. See ${QQBOT_DOCS_URL}`; +} + +export function qqbotNetworkGuidance(): string { + return `Check network connectivity and DNS, and verify the server IP whitelist in QQ Open Platform at ${QQBOT_OPEN_PLATFORM_URL}. See ${QQBOT_DOCS_URL}`; +} + +export function qqbotApiGuidance(httpStatus: number, bizCode?: number): string { + return isQQBotTokenAuthenticationFailure(httpStatus, bizCode) + ? qqbotAuthGuidance() + : `See ${QQBOT_DOCS_URL} for QQBot API troubleshooting`; +} + +export function qqbotNotConfiguredMessage(accountId: string): string { + const guidance = + accountId === DEFAULT_ACCOUNT_ID + ? `Set channels.qqbot.appId and clientSecret (or clientSecretFile), or set QQBOT_APP_ID and QQBOT_CLIENT_SECRET. See ${QQBOT_DOCS_URL}` + : `Set channels.qqbot.accounts.${accountId}.appId and clientSecret (or clientSecretFile). See ${QQBOT_DOCS_URL}`; + return `QQBot not configured (missing appId or clientSecret). ${guidance}`; +} + +export function qqbotTokenFailureMessage(detail: string): string { + return `Failed to get QQBot access_token. ${qqbotAuthGuidance()}. Open platform response: ${detail}`; +} diff --git a/extensions/qqbot/src/engine/gateway/gateway.config.test.ts b/extensions/qqbot/src/engine/gateway/gateway.config.test.ts new file mode 100644 index 000000000000..6178910e1614 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/gateway.config.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../types.js"; +import { startGateway, type CoreGatewayContext } from "./gateway.js"; +import type { InboundPipelineDeps } from "./inbound-context.js"; +import type { QueuedMessage } from "./message-queue.js"; +import type { GatewayAccount } from "./types.js"; + +const mocks = vi.hoisted(() => ({ + clearTokenCache: vi.fn(), + handleMessage: undefined as ((event: QueuedMessage) => Promise) | undefined, + sendInputNotify: vi.fn(), +})); + +vi.mock("../commands/slash-commands-impl.js", () => ({ + initCommands: vi.fn(), +})); + +vi.mock("../messaging/outbound-reply.js", () => ({ + claimMessageReply: vi.fn(() => ({ allowed: true })), +})); + +vi.mock("../messaging/outbound.js", () => ({ + setOutboundAudioPort: vi.fn(), +})); + +vi.mock("../messaging/sender.js", () => ({ + accountToCreds: vi.fn((account: GatewayAccount) => ({ + appId: account.appId, + clientSecret: account.clientSecret, + })), + buildDeliveryTarget: vi.fn(), + clearTokenCache: mocks.clearTokenCache, + createRawInputNotifyFn: vi.fn(() => vi.fn()), + getAccessToken: vi.fn(async () => "token"), + initApiConfig: vi.fn(), + onMessageSent: vi.fn(), + sendInputNotify: mocks.sendInputNotify, + sendText: vi.fn(), +})); + +vi.mock("../utils/diagnostics.js", () => ({ + runDiagnostics: vi.fn(async () => ({ warnings: [] })), +})); + +vi.mock("./gateway-connection.js", () => ({ + GatewayConnection: class { + constructor(options: { handleMessage: (event: QueuedMessage) => Promise }) { + mocks.handleMessage = options.handleMessage; + } + + async start() {} + }, +})); + +vi.mock("./inbound-pipeline.js", () => ({ + buildInboundContext: vi.fn( + async (event: QueuedMessage, deps: Pick) => ({ + blocked: true, + blockReason: "test", + typing: await deps.startTyping(event), + }), + ), + clearGroupPendingHistory: vi.fn(), +})); + +vi.mock("./interaction-handler.js", () => ({ + createInteractionHandler: vi.fn(() => vi.fn()), +})); + +vi.mock("./outbound-dispatch.js", () => ({ + dispatchOutbound: vi.fn(), +})); + +function makeContext(accountId = "default", withCredentials = true): CoreGatewayContext { + return { + account: { + accountId, + appId: withCredentials ? "app-id" : "", + clientSecret: withCredentials ? "secret" : "", + markdownSupport: false, + config: {}, + }, + cfg: {}, + getCurrentConfig: () => ({}), + log: { info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + runtime: { + channel: { + activity: { record: vi.fn() }, + }, + }, + adapters: { + commands: {}, + outboundAudio: {}, + }, + } as unknown as CoreGatewayContext; +} + +describe("QQBot gateway configuration guidance", () => { + it("shows default-account recovery paths from the real gateway entry point", async () => { + await expect(startGateway(makeContext("default", false))).rejects.toThrow( + /channels\.qqbot\.appId.*QQBOT_APP_ID and QQBOT_CLIENT_SECRET/, + ); + }); + + it("shows account-scoped recovery without default-only env vars", async () => { + let error: unknown; + try { + await startGateway(makeContext("operations", false)); + } catch (caught) { + error = caught; + } + + const message = error instanceof Error ? error.message : String(error); + expect(message).toContain("channels.qqbot.accounts.operations.appId"); + expect(message).not.toContain("QQBOT_APP_ID"); + expect(message).not.toContain("QQBOT_CLIENT_SECRET"); + }); +}); + +async function sendC2CTyping(): Promise { + await startGateway(makeContext()); + const handleMessage = mocks.handleMessage; + if (!handleMessage) { + throw new Error("Gateway did not register a message handler"); + } + await handleMessage({ + type: "c2c", + senderId: "openid-1", + content: "hello", + messageId: "msg-1", + timestamp: "2026-08-07T00:00:00Z", + }); +} + +describe("QQBot gateway typing token retry", () => { + beforeEach(() => { + mocks.clearTokenCache.mockReset(); + mocks.handleMessage = undefined; + mocks.sendInputNotify.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("refreshes a keyword-free HTTP 500/business-code 11244 failure", async () => { + mocks.sendInputNotify + .mockRejectedValueOnce(new ApiError("credential rejected", 500, "/typing", 11244)) + .mockResolvedValueOnce({ refIdx: "ref-1" }); + + await sendC2CTyping(); + + expect(mocks.clearTokenCache).toHaveBeenCalledOnce(); + expect(mocks.clearTokenCache).toHaveBeenCalledWith("app-id"); + expect(mocks.sendInputNotify).toHaveBeenCalledTimes(2); + }); + + it("preserves the string fallback for non-ApiError failures", async () => { + mocks.sendInputNotify + .mockRejectedValueOnce(new Error("401 token rejected")) + .mockResolvedValueOnce({ refIdx: "ref-1" }); + + await sendC2CTyping(); + + expect(mocks.clearTokenCache).toHaveBeenCalledOnce(); + expect(mocks.sendInputNotify).toHaveBeenCalledTimes(2); + }); +}); diff --git a/extensions/qqbot/src/engine/gateway/gateway.ts b/extensions/qqbot/src/engine/gateway/gateway.ts index 88c794e11dfe..9f22f356a876 100644 --- a/extensions/qqbot/src/engine/gateway/gateway.ts +++ b/extensions/qqbot/src/engine/gateway/gateway.ts @@ -1,12 +1,14 @@ // Qqbot plugin module implements gateway behavior. import path from "node:path"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { isQQBotTokenAuthenticationFailure } from "../api/auth-errors.js"; import { classifyCoreCommandForGroup, PRIVATE_CHAT_ONLY_TEXT, } from "../commands/command-visibility.js"; import { initCommands } from "../commands/slash-commands-impl.js"; import { resolveGroupCommandLevelFromAccountConfig } from "../config/group.js"; +import { qqbotNotConfiguredMessage } from "../config/setup-guidance.js"; import type { HistoryEntry } from "../group/history.js"; import { claimMessageReply } from "../messaging/outbound-reply.js"; import { setOutboundAudioPort } from "../messaging/outbound.js"; @@ -22,6 +24,7 @@ import { sendText as senderSendText, } from "../messaging/sender.js"; import { setRefIndex } from "../ref/store.js"; +import { ApiError } from "../types.js"; import { runDiagnostics } from "../utils/diagnostics.js"; import { runWithRequestContext } from "../utils/request-context.js"; import { GatewayConnection } from "./gateway-connection.js"; @@ -46,7 +49,7 @@ export async function startGateway(ctx: CoreGatewayContext): Promise { initCommands(adapters.commands); if (!account.appId || !account.clientSecret) { - throw new Error("QQBot not configured (missing appId or clientSecret)"); + throw new Error(qqbotNotConfiguredMessage(account.accountId)); } const diag = await runDiagnostics(); @@ -315,8 +318,14 @@ async function startTypingForEvent( try { return await sendNotifyAndStartKeepAlive(); } catch (notifyErr) { + const isStructuredAuthFailure = + notifyErr instanceof ApiError && + isQQBotTokenAuthenticationFailure(notifyErr.httpStatus, notifyErr.bizCode); const errMsg = String(notifyErr); - if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) { + const isSyntheticAuthFailure = + !(notifyErr instanceof ApiError) && + (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")); + if (isStructuredAuthFailure || isSyntheticAuthFailure) { clearTokenCache(account.appId); return await sendNotifyAndStartKeepAlive(); } diff --git a/extensions/qqbot/src/engine/messaging/outbound-config.test.ts b/extensions/qqbot/src/engine/messaging/outbound-config.test.ts new file mode 100644 index 000000000000..062b63b67ba8 --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/outbound-config.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { GatewayAccount } from "../types.js"; +import { sendMedia, sendText } from "./outbound.js"; + +function makeAccount(accountId: string): GatewayAccount { + return { + accountId, + appId: "", + clientSecret: "", + markdownSupport: false, + config: {}, + }; +} + +describe("QQBot outbound configuration guidance", () => { + it("returns default-account recovery paths from sendText", async () => { + const result = await sendText({ + account: makeAccount("default"), + to: "user-openid", + text: "hello", + }); + + expect(result.error).toContain("channels.qqbot.appId"); + expect(result.error).toContain("QQBOT_APP_ID and QQBOT_CLIENT_SECRET"); + }); + + it("returns named-account recovery paths from sendMedia", async () => { + const result = await sendMedia({ + account: makeAccount("operations"), + accountId: "operations", + to: "user-openid", + text: "", + mediaUrl: "https://example.com/image.png", + }); + + expect(result.error).toContain("channels.qqbot.accounts.operations.appId"); + expect(result.error).not.toContain("QQBOT_APP_ID"); + expect(result.error).not.toContain("QQBOT_CLIENT_SECRET"); + }); + + it.each([ + ["default", "https://example.com/image.png", "channels.qqbot.appId", true], + [ + "operations", + "report https://example.com/report.pdf", + "channels.qqbot.accounts.operations.appId", + false, + ], + ] as const)( + "preflights tagged media for the %s account", + async (accountId, text, expectedPath, expectsDefaultEnv) => { + const result = await sendText({ + account: makeAccount(accountId), + to: "user-openid", + text, + }); + + expect(result.error).toContain(expectedPath); + if (expectsDefaultEnv) { + expect(result.error).toContain("QQBOT_APP_ID and QQBOT_CLIENT_SECRET"); + } else { + expect(result.error).not.toContain("QQBOT_APP_ID"); + expect(result.error).not.toContain("QQBOT_CLIENT_SECRET"); + } + }, + ); +}); diff --git a/extensions/qqbot/src/engine/messaging/outbound.ts b/extensions/qqbot/src/engine/messaging/outbound.ts index 5ac2a16a76f1..b5ed81b2a9cb 100644 --- a/extensions/qqbot/src/engine/messaging/outbound.ts +++ b/extensions/qqbot/src/engine/messaging/outbound.ts @@ -35,6 +35,7 @@ export { import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { qqbotNotConfiguredMessage } from "../config/setup-guidance.js"; import type { GatewayAccount } from "../types.js"; import type { EngineLogger } from "../types.js"; import { debugError, debugLog, debugWarn } from "../utils/log.js"; @@ -109,6 +110,18 @@ export async function sendText(ctx: OutboundContext): Promise { /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi; const mediaTagMatches = text.match(mediaTagRegex); + if (!replyToId && (!text || text.trim().length === 0)) { + debugError("[qqbot] sendText error: proactive message content cannot be empty"); + return { + channel: "qqbot", + error: "Proactive messages require non-empty content (--message cannot be empty)", + }; + } + + if (!account.appId || !account.clientSecret) { + return { channel: "qqbot", error: qqbotNotConfiguredMessage(account.accountId) }; + } + if (mediaTagMatches && mediaTagMatches.length > 0) { debugLog(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`); @@ -236,20 +249,9 @@ export async function sendText(ctx: OutboundContext): Promise { } if (!replyToId) { - if (!text || text.trim().length === 0) { - debugError("[qqbot] sendText error: proactive message content cannot be empty"); - return { - channel: "qqbot", - error: "Proactive messages require non-empty content (--message cannot be empty)", - }; - } debugLog(`[qqbot] sendText: sending proactive message to ${to}, length=${text.length}`); } - if (!account.appId || !account.clientSecret) { - return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; - } - try { const target = parseTarget(to); const creds = accountToCreds(account); @@ -281,7 +283,7 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("refreshes when QQ reports an expired token as HTTP 500 with business code 11244", async () => { + const getAccessToken = vi + .spyOn(TokenManager.prototype, "getAccessToken") + .mockResolvedValueOnce("expired-token") + .mockResolvedValueOnce("fresh-token"); + const clearCache = vi.spyOn(TokenManager.prototype, "clearCache"); + const send = vi + .fn<(token: string) => Promise>() + // Keep the message free of retry keywords so the structured code is the only signal. + .mockRejectedValueOnce(new ApiError("credential rejected", 500, "/gateway", 11244)) + .mockResolvedValueOnce("sent"); + const logger = { info: vi.fn(), error: vi.fn(), debug: vi.fn() }; + registerAccount("retry-app", { logger }); + + await expect( + withTokenRetry({ appId: "retry-app", clientSecret: "secret" }, send, logger), + ).resolves.toBe("sent"); + + expect(getAccessToken).toHaveBeenCalledTimes(2); + expect(clearCache).toHaveBeenCalledWith("retry-app"); + expect(send).toHaveBeenNthCalledWith(1, "expired-token"); + expect(send).toHaveBeenNthCalledWith(2, "fresh-token"); + }); +}); diff --git a/extensions/qqbot/src/engine/messaging/sender.ts b/extensions/qqbot/src/engine/messaging/sender.ts index f5159921aa1e..1877e081d414 100644 --- a/extensions/qqbot/src/engine/messaging/sender.ts +++ b/extensions/qqbot/src/engine/messaging/sender.ts @@ -28,6 +28,7 @@ import os from "node:os"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { ApiClient } from "../api/api-client.js"; +import { isQQBotTokenAuthenticationFailure } from "../api/auth-errors.js"; import { ChunkedMediaApi as ChunkedMediaApiClass } from "../api/media-chunked.js"; import { downloadDirectUploadUrl, MediaApi as MediaApiClass } from "../api/media.js"; import type { Credentials } from "../api/messages.js"; @@ -320,9 +321,9 @@ interface AccountCreds { // ============ Token retry ============ /** - * Execute an API call with automatic token-retry on 401 errors. + * Execute an API call with automatic retry when QQ rejects the access token. * - * Primary signal is structured: `ApiError.httpStatus === 401`. A string + * Primary signals are the structured HTTP status and QQ business code. A string * fallback remains for non-`ApiError` paths (e.g. synthetic errors from * custom adapters), but logs a warning so such cases can be surfaced. */ @@ -336,9 +337,10 @@ export async function withTokenRetry( const token = await getAccessToken(creds.appId, creds.clientSecret); return await sendFn(token); } catch (err) { - const isStructured401 = err instanceof ApiError && err.httpStatus === 401; - if (isStructured401) { - log?.debug?.(`Token expired (ApiError 401), refreshing...`); + const isStructuredAuthFailure = + err instanceof ApiError && isQQBotTokenAuthenticationFailure(err.httpStatus, err.bizCode); + if (isStructuredAuthFailure) { + log?.debug?.(`QQBot access token rejected, refreshing...`); clearTokenCache(creds.appId); const newToken = await getAccessToken(creds.appId, creds.clientSecret); return await sendFn(newToken);