mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(qqbot): add cause-specific setup guidance (#119780)
* fix(qqbot): add cause-specific setup guidance * fix(qqbot): recognize expired-token business errors * fix(qqbot): retry structured token failures --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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<void>) | 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<void> }) {
|
||||
mocks.handleMessage = options.handleMessage;
|
||||
}
|
||||
|
||||
async start() {}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./inbound-pipeline.js", () => ({
|
||||
buildInboundContext: vi.fn(
|
||||
async (event: QueuedMessage, deps: Pick<InboundPipelineDeps, "startTyping">) => ({
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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", "<qqimg>https://example.com/image.png</qqimg>", "channels.qqbot.appId", true],
|
||||
[
|
||||
"operations",
|
||||
"report <qqfile>https://example.com/report.pdf</qqfile>",
|
||||
"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");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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<OutboundResult> {
|
||||
/<(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<OutboundResult> {
|
||||
}
|
||||
|
||||
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<OutboundResu
|
||||
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
|
||||
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
||||
return { channel: "qqbot", error: qqbotNotConfiguredMessage(account.accountId) };
|
||||
}
|
||||
if (!ctx.mediaUrl) {
|
||||
return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TokenManager } from "../api/token.js";
|
||||
import { ApiError } from "../types.js";
|
||||
import { registerAccount, withTokenRetry } from "./sender.js";
|
||||
|
||||
describe("QQBot token retry", () => {
|
||||
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<string>>()
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -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<T>(
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user