From fc12e608a69a7d9119c8e5ce2e11712252d1dd70 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 19 Jun 2026 18:14:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0QQBot=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=8F=97=E9=99=90=E9=87=8D=E5=AE=9A=E5=90=91=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/plugin-host-bridge.service.ts | 18 +++ .../runtime/plugin-worker.thread.ts | 1 + .../sdk/plugin-http-client.service.ts | 134 ++++++++++++++++++ .../plugin-host-bridge.spec.ts | 34 ++++- .../plugin-http-client.spec.ts | 70 +++++++++ 5 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 test/modules/qqbot/plugin-platform/plugin-http-client.spec.ts diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts index 18860b2..ead18a5 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts @@ -9,6 +9,7 @@ import type { QqbotPluginPackageDescriptor } from '@/modules/qqbot/plugin-platfo import { QqbotPluginHttpClientService, type QqbotPluginHttpClientRequest, + type QqbotPluginResolveRedirectRequest, } from '../sdk/plugin-http-client.service'; import type { QqbotPluginHostCallRequest, @@ -108,6 +109,8 @@ export class QqbotPluginHostBridgeService { return this.httpClient.requestBuffer(getHttpRequestOptions(args)); case 'requestJson': return this.httpClient.requestJson(getHttpRequestOptions(args)); + case 'resolveRedirect': + return this.httpClient.resolveRedirect(getRedirectRequestOptions(args)); case 'sendText': return this.sendService.sendText( args.input as Parameters[0], @@ -350,6 +353,21 @@ function getHttpRequestOptions( return request; } +/** + * Normalizes host-call redirect options from `{ input }`, `{ options }`, or raw option arguments. + * @param args - Worker-supplied host-call arguments. + * @returns Redirect resolver options safe to pass to QqbotPluginHttpClientService. + */ +function getRedirectRequestOptions( + args: Record, +): QqbotPluginResolveRedirectRequest { + const candidate = args.input || args.options || args; + if (!isRecord(candidate)) { + throw new Error('Plugin host redirect options must be an object'); + } + return candidate as QqbotPluginResolveRedirectRequest; +} + /** * Checks whether an unknown value can be treated as a record of host-call options. * @param value - Worker-supplied value that may contain named host-call arguments. diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts index 84489e6..79c8377 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts @@ -90,6 +90,7 @@ const HOST_ARGUMENT_MAPPERS: Record = { renameFile: (from, to) => ({ from, to }), requestBuffer: (options) => ({ options }), requestJson: (options) => ({ options }), + resolveRedirect: (input) => ({ input }), sendText: (input) => ({ input }), sleep: (ms) => ({ ms }), unbindEventPlugin: (selfId, pluginKey) => ({ pluginKey, selfId }), diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service.ts index 0f8d533..0a3c4a1 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service.ts @@ -14,8 +14,52 @@ export type QqbotPluginHttpClientRequest = { url: string | URL; }; +export type QqbotPluginResolveRedirectRequest = { + context?: string; + headers?: Record; + maxRedirects?: number; + timeoutMessage?: string; + timeoutMs?: number; + url: string | URL; +}; + +export type QqbotPluginRedirectResult = { + finalUrl: string; + redirects: string[]; +}; + @Injectable() export class QqbotPluginHttpClientService { + /** + * Resolves an HTTP(S) URL through bounded 3xx redirects for plugin host calls without platform-specific URL rules. + * @param input - Initial URL, optional headers, timeout, and redirect limit supplied by plugin runtime code. + * @returns Final URL and the ordered chain of redirect target URLs. + */ + async resolveRedirect( + input: QqbotPluginResolveRedirectRequest, + ): Promise { + let currentUrl = normalizePluginHttpUrl(input.url); + const redirects: string[] = []; + const maxRedirects = normalizeMaxRedirects(input.maxRedirects); + + while (true) { + const location = await this.requestRedirectLocation(currentUrl, input); + if (!location) { + return { + finalUrl: currentUrl.toString(), + redirects, + }; + } + + if (redirects.length >= maxRedirects) { + throw new Error('插件 HTTP 重定向超过上限'); + } + + currentUrl = normalizePluginHttpUrl(new URL(location, currentUrl)); + redirects.push(currentUrl.toString()); + } + } + /** * 执行 QQBot 插件平台流程。 * @param input - input 输入;使用 `invalidJsonMessage`、`context` 字段生成结果。 @@ -145,6 +189,62 @@ export class QqbotPluginHttpClientService { request.end(); }); } + + /** + * Requests one URL and returns its redirect Location after the response body is drained. + * @param url - Validated HTTP(S) URL to request. + * @param input - Headers and timeout options shared across the redirect chain. + * @returns Redirect Location header when the response is 3xx, otherwise `undefined`. + */ + private requestRedirectLocation( + url: URL, + input: QqbotPluginResolveRedirectRequest, + ): Promise { + const timeoutMs = input.timeoutMs || 8000; + const context = input.context || '插件 HTTP 重定向'; + + return new Promise((resolve, reject) => { + const client = getPluginHttpModule(url); + const request = client.request( + url, + { + headers: { + Accept: '*/*', + 'User-Agent': 'kt-template-online-api/qqbot-plugin', + ...(input.headers || {}), + }, + method: 'GET', + timeout: timeoutMs, + }, + (response) => { + const statusCode = response.statusCode || 0; + const location = response.headers.location; + + response.on('error', reject); + response.on('end', () => { + if ( + statusCode >= 300 && + statusCode < 400 && + typeof location === 'string' && + location.trim() + ) { + resolve(location); + return; + } + resolve(undefined); + }); + response.resume(); + }, + ); + request.on('timeout', () => { + request.destroy( + new Error(input.timeoutMessage || `${context}请求超时`), + ); + }); + request.on('error', reject); + request.end(); + }); + } } /** @@ -160,3 +260,37 @@ function createPluginHttpError(message: string, statusCode: number) { statusCode, }); } + +/** + * Builds a URL and enforces the plugin redirect resolver's HTTP(S)-only protocol boundary. + * @param value - Worker-supplied string or URL value. + * @returns URL instance safe to request with node:http or node:https. + */ +function normalizePluginHttpUrl(value: string | URL) { + const url = value instanceof URL ? value : new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('插件 HTTP 重定向仅支持 http/https'); + } + return url; +} + +/** + * Normalizes the redirect limit used to stop infinite or overly long redirect chains. + * @param value - Optional max redirect count supplied by plugin runtime code. + * @returns Non-negative redirect count limit. + */ +function normalizeMaxRedirects(value: number | undefined) { + const maxRedirects = value ?? 5; + return Number.isFinite(maxRedirects) && maxRedirects >= 0 + ? Math.floor(maxRedirects) + : 5; +} + +/** + * Selects the Node HTTP module for a validated redirect URL. + * @param url - HTTP(S) URL accepted by `normalizePluginHttpUrl`. + * @returns Node request module matching the URL protocol. + */ +function getPluginHttpModule(url: URL) { + return url.protocol === 'http:' ? http : https; +} diff --git a/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts b/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts index a2a95a9..6630d80 100644 --- a/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts @@ -13,7 +13,11 @@ describe('QQBot plugin host bridge', () => { let tempRoot: string; let bridge: QqbotPluginHostBridgeService; let configValues: Record; - let httpClient: { requestBuffer: jest.Mock; requestJson: jest.Mock }; + let httpClient: { + requestBuffer: jest.Mock; + requestJson: jest.Mock; + resolveRedirect: jest.Mock; + }; let accountService: { bindEventPlugin: jest.Mock; getBoundEventPluginKeys: jest.Mock; @@ -38,6 +42,7 @@ describe('QQBot plugin host bridge', () => { httpClient = { requestBuffer: jest.fn(), requestJson: jest.fn().mockResolvedValue({ ok: true }), + resolveRedirect: jest.fn(), }; accountService = { bindEventPlugin: jest.fn().mockResolvedValue(true), @@ -123,6 +128,33 @@ describe('QQBot plugin host bridge', () => { expect(httpClient.requestJson).toHaveBeenCalledWith(options); }); + it('delegates resolveRedirect host calls to the plugin HTTP client', async () => { + const options = { + maxRedirects: 3, + timeoutMs: 1000, + url: 'https://b23.tv/abc123', + }; + httpClient.resolveRedirect.mockResolvedValue({ + finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD', + redirects: ['https://www.bilibili.com/video/BV1xx411c7mD'], + }); + + await expect( + bridge.handleHostCall(createDescriptor(), { + args: { input: options }, + method: 'resolveRedirect', + pluginKey: 'sample', + }), + ).resolves.toEqual({ + ok: true, + value: { + finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD', + redirects: ['https://www.bilibili.com/video/BV1xx411c7mD'], + }, + }); + expect(httpClient.resolveRedirect).toHaveBeenCalledWith(options); + }); + it('delegates event binding and text sends to core QQBot services', async () => { const descriptor = createDescriptor(); const sendInput = { diff --git a/test/modules/qqbot/plugin-platform/plugin-http-client.spec.ts b/test/modules/qqbot/plugin-platform/plugin-http-client.spec.ts new file mode 100644 index 0000000..dbeb30b --- /dev/null +++ b/test/modules/qqbot/plugin-platform/plugin-http-client.spec.ts @@ -0,0 +1,70 @@ +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { QqbotPluginHttpClientService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service'; + +describe('QQBot plugin HTTP client redirect resolver', () => { + let server: http.Server; + let baseUrl: string; + + beforeEach(async () => { + server = http.createServer((request, response) => { + if (request.url === '/short') { + response.writeHead(302, { Location: '/video/BV1xx411c7mD' }); + response.end(); + return; + } + if (request.url === '/loop') { + response.writeHead(302, { Location: '/loop2' }); + response.end(); + return; + } + if (request.url === '/loop2') { + response.writeHead(302, { Location: '/loop' }); + response.end(); + return; + } + response.writeHead(200, { 'Content-Type': 'text/plain' }); + response.end('ok'); + }); + await new Promise((resolveListen) => { + server.listen(0, '127.0.0.1', resolveListen); + }); + const address = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterEach(async () => { + await new Promise((resolveClose) => server.close(() => resolveClose())); + }); + + it('returns the final URL and redirect chain for relative Location headers', async () => { + await expect( + new QqbotPluginHttpClientService().resolveRedirect({ + maxRedirects: 3, + timeoutMs: 1000, + url: `${baseUrl}/short`, + }), + ).resolves.toEqual({ + finalUrl: `${baseUrl}/video/BV1xx411c7mD`, + redirects: [`${baseUrl}/video/BV1xx411c7mD`], + }); + }); + + it('rejects redirect loops after the configured limit', async () => { + await expect( + new QqbotPluginHttpClientService().resolveRedirect({ + maxRedirects: 1, + timeoutMs: 1000, + url: `${baseUrl}/loop`, + }), + ).rejects.toThrow('插件 HTTP 重定向超过上限'); + }); + + it('rejects non-http protocols before requesting them', async () => { + await expect( + new QqbotPluginHttpClientService().resolveRedirect({ + url: 'file:///etc/passwd', + }), + ).rejects.toThrow('插件 HTTP 重定向仅支持 http/https'); + }); +});