



























@@ -10,6 +10,7 @@
1010 */
11111212import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
13+import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
1314import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js";
1415import { formatErrorMessage } from "../utils/format.js";
1516@@ -18,6 +19,13 @@ const DEFAULT_TIMEOUT_MS = 30_000;
1819const FILE_UPLOAD_TIMEOUT_MS = 120_000;
1920const QQBOT_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
202122+function resolveQqbotApiSsrfPolicy(url: string): SsrFPolicy {
23+return {
24+hostnameAllowlist: [new URL(url).hostname],
25+allowRfc2544BenchmarkRange: true,
26+};
27+}
28+2129interface RequestOptions {
2230/** Request timeout override in milliseconds. */
2331timeoutMs?: number;
@@ -122,8 +130,16 @@ export class ApiClient {
122130}
123131124132let res: Response;
133+let release: (() => Promise<void>) | undefined;
125134try {
126-res = await fetch(url, fetchInit);
135+const guarded = await fetchWithSsrFGuard({
136+ url,
137+init: fetchInit,
138+auditContext: "qqbot-api",
139+policy: resolveQqbotApiSsrfPolicy(url),
140+});
141+res = guarded.response;
142+release = guarded.release;
127143} catch (err) {
128144clearTimeout(timeoutId);
129145if (err instanceof Error && err.name === "AbortError") {
@@ -136,84 +152,89 @@ export class ApiClient {
136152clearTimeout(timeoutId);
137153}
138154139-// Log response status and trace ID.
140-const traceId = res.headers.get("x-tps-trace-id") ?? "";
141-this.logger?.info?.(
142-`[qqbot:api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`,
143-);
144-145-const readBody = async (limitBytes?: number): Promise<string> => {
146-try {
147-return limitBytes === undefined
148- ? await res.text()
149- : await readResponseTextLimited(res, limitBytes);
150-} catch (err) {
151-throw new ApiError(
152-`Failed to read response [${path}]: ${formatErrorMessage(err)}`,
153-res.status,
154-path,
155-);
156-}
157-};
158-159-const rawBody = res.ok ? await readBody() : await readBody(QQBOT_API_ERROR_BODY_LIMIT_BYTES);
160-this.logger?.debug?.(`[qqbot:api] <<< Body: ${rawBody}`);
155+try {
156+// Log response status and trace ID.
157+const traceId = res.headers.get("x-tps-trace-id") ?? "";
158+this.logger?.info?.(
159+`[qqbot:api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`,
160+);
161161162-// Detect non-JSON responses (HTML gateway errors, CDN rate-limit pages).
163-const contentType = res.headers.get("content-type") ?? "";
164-const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
162+const readBody = async (limitBytes?: number): Promise<string> => {
163+try {
164+return limitBytes === undefined
165+ ? await res.text()
166+ : await readResponseTextLimited(res, limitBytes);
167+} catch (err) {
168+throw new ApiError(
169+`Failed to read response [${path}]: ${formatErrorMessage(err)}`,
170+res.status,
171+path,
172+);
173+}
174+};
175+176+const rawBody = res.ok ? await readBody() : await readBody(QQBOT_API_ERROR_BODY_LIMIT_BYTES);
177+this.logger?.debug?.(`[qqbot:api] <<< Body: ${rawBody}`);
178+179+// Detect non-JSON responses (HTML gateway errors, CDN rate-limit pages).
180+const contentType = res.headers.get("content-type") ?? "";
181+const isHtmlResponse =
182+contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
183+184+if (!res.ok) {
185+if (isHtmlResponse) {
186+const statusHint =
187+res.status === 502 || res.status === 503 || res.status === 504
188+ ? "调用发生异常,请稍候重试"
189+ : res.status === 429
190+ ? "请求过于频繁,已被限流"
191+ : `开放平台返回 HTTP ${res.status}`;
192+throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path);
193+}
165194166-if (!res.ok) {
167-if (isHtmlResponse) {
168-const statusHint =
169-res.status === 502 || res.status === 503 || res.status === 504
170- ? "调用发生异常,请稍候重试"
171- : res.status === 429
172- ? "请求过于频繁,已被限流"
173- : `开放平台返回 HTTP ${res.status}`;
174-throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path);
195+// JSON error response.
196+try {
197+const error = JSON.parse(rawBody) as {
198+message?: string;
199+code?: number;
200+err_code?: number;
201+};
202+const bizCode = error.code ?? error.err_code;
203+throw new ApiError(
204+`API Error [${path}]: ${error.message ?? rawBody}`,
205+res.status,
206+path,
207+bizCode,
208+error.message,
209+);
210+} catch (parseErr) {
211+if (parseErr instanceof ApiError) {
212+throw parseErr;
213+}
214+throw new ApiError(
215+`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`,
216+res.status,
217+path,
218+);
219+}
175220}
176221177-// JSON error response.
178-try {
179-const error = JSON.parse(rawBody) as {
180-message?: string;
181-code?: number;
182-err_code?: number;
183-};
184-const bizCode = error.code ?? error.err_code;
185-throw new ApiError(
186-`API Error [${path}]: ${error.message ?? rawBody}`,
187-res.status,
188-path,
189-bizCode,
190-error.message,
191-);
192-} catch (parseErr) {
193-if (parseErr instanceof ApiError) {
194-throw parseErr;
195-}
222+// Successful response but not JSON (extreme edge case).
223+if (isHtmlResponse) {
196224throw new ApiError(
197-`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`,
225+`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`,
198226res.status,
199227path,
200228);
201229}
202-}
203-204-// Successful response but not JSON (extreme edge case).
205-if (isHtmlResponse) {
206-throw new ApiError(
207-`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`,
208-res.status,
209-path,
210-);
211-}
212230213-try {
214-return JSON.parse(rawBody) as T;
215-} catch {
216-throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path);
231+try {
232+return JSON.parse(rawBody) as T;
233+} catch {
234+throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path);
235+}
236+} finally {
237+await release?.();
217238}
218239}
219240}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。