@openclaw/qqbot 2026.5.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.ts +56 -0
- package/channel-plugin-api.ts +1 -0
- package/dist/.boundary-tsc.stamp +1 -0
- package/dist/.boundary-tsc.tsbuildinfo +1 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +814 -0
- package/package.json +66 -0
- package/runtime-api.ts +9 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/skills/qqbot-channel/SKILL.md +262 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +37 -0
- package/skills/qqbot-remind/SKILL.md +153 -0
- package/src/bridge/approval/capability.ts +237 -0
- package/src/bridge/approval/handler-runtime.ts +204 -0
- package/src/bridge/bootstrap.ts +135 -0
- package/src/bridge/channel-entry.ts +18 -0
- package/src/bridge/commands/framework-context-adapter.ts +60 -0
- package/src/bridge/commands/framework-registration.ts +47 -0
- package/src/bridge/commands/from-parser.test.ts +86 -0
- package/src/bridge/commands/from-parser.ts +60 -0
- package/src/bridge/commands/result-dispatcher.ts +76 -0
- package/src/bridge/config-shared.ts +132 -0
- package/src/bridge/config.ts +111 -0
- package/src/bridge/gateway.ts +174 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.test.ts +146 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +131 -0
- package/src/bridge/setup/finalize.ts +144 -0
- package/src/bridge/setup/surface.ts +34 -0
- package/src/bridge/tools/channel.ts +58 -0
- package/src/bridge/tools/index.ts +15 -0
- package/src/bridge/tools/remind.test.ts +124 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +288 -0
- package/src/command-auth.test.ts +62 -0
- package/src/config-schema.ts +84 -0
- package/src/config.test.ts +364 -0
- package/src/engine/access/access-control.test.ts +198 -0
- package/src/engine/access/access-control.ts +226 -0
- package/src/engine/access/index.ts +16 -0
- package/src/engine/access/resolve-policy.test.ts +59 -0
- package/src/engine/access/resolve-policy.ts +57 -0
- package/src/engine/access/sender-match.test.ts +60 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +53 -0
- package/src/engine/adapter/audio.port.ts +27 -0
- package/src/engine/adapter/commands.port.ts +22 -0
- package/src/engine/adapter/history.port.ts +52 -0
- package/src/engine/adapter/index.ts +139 -0
- package/src/engine/adapter/mention-gate.port.ts +50 -0
- package/src/engine/adapter/types.ts +38 -0
- package/src/engine/api/api-client.ts +212 -0
- package/src/engine/api/media-chunked.test.ts +336 -0
- package/src/engine/api/media-chunked.ts +622 -0
- package/src/engine/api/media.ts +218 -0
- package/src/engine/api/messages.ts +293 -0
- package/src/engine/api/retry.ts +217 -0
- package/src/engine/api/routes.ts +95 -0
- package/src/engine/api/token.ts +271 -0
- package/src/engine/approval/index.test.ts +22 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +319 -0
- package/src/engine/commands/builtin/register-all.ts +17 -0
- package/src/engine/commands/builtin/register-approve.ts +201 -0
- package/src/engine/commands/builtin/register-basic.ts +95 -0
- package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
- package/src/engine/commands/builtin/register-logs.ts +20 -0
- package/src/engine/commands/builtin/register-streaming.ts +137 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +48 -0
- package/src/engine/commands/slash-command-handler.ts +146 -0
- package/src/engine/commands/slash-commands-impl.test.ts +8 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +199 -0
- package/src/engine/config/credential-backup.test.ts +88 -0
- package/src/engine/config/credential-backup.ts +107 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.test.ts +234 -0
- package/src/engine/config/group.ts +299 -0
- package/src/engine/config/resolve.test.ts +152 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/engine-import-boundary.test.ts +73 -0
- package/src/engine/gateway/codec.ts +47 -0
- package/src/engine/gateway/constants.ts +117 -0
- package/src/engine/gateway/event-dispatcher.ts +177 -0
- package/src/engine/gateway/gateway-connection.ts +371 -0
- package/src/engine/gateway/gateway.ts +291 -0
- package/src/engine/gateway/inbound-attachments.test.ts +126 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +195 -0
- package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +218 -0
- package/src/engine/gateway/inbound-pipeline.ts +235 -0
- package/src/engine/gateway/interaction-handler.ts +220 -0
- package/src/engine/gateway/message-queue.test.ts +282 -0
- package/src/engine/gateway/message-queue.ts +499 -0
- package/src/engine/gateway/outbound-dispatch.test.ts +231 -0
- package/src/engine/gateway/outbound-dispatch.ts +575 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +132 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.test.ts +77 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.test.ts +152 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +292 -0
- package/src/engine/gateway/stages/index.ts +18 -0
- package/src/engine/gateway/stages/quote-stage.ts +113 -0
- package/src/engine/gateway/stages/refidx-stage.ts +62 -0
- package/src/engine/gateway/stages/stub-contexts.ts +116 -0
- package/src/engine/gateway/types.ts +264 -0
- package/src/engine/gateway/typing-keepalive.ts +79 -0
- package/src/engine/group/activation.test.ts +114 -0
- package/src/engine/group/activation.ts +147 -0
- package/src/engine/group/history.test.ts +314 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.test.ts +141 -0
- package/src/engine/group/mention.ts +197 -0
- package/src/engine/group/message-gating.test.ts +188 -0
- package/src/engine/group/message-gating.ts +216 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +215 -0
- package/src/engine/messaging/media-type-detect.ts +37 -0
- package/src/engine/messaging/outbound-audio-port.ts +38 -0
- package/src/engine/messaging/outbound-deliver.ts +810 -0
- package/src/engine/messaging/outbound-media-send.ts +702 -0
- package/src/engine/messaging/outbound-reply.ts +27 -0
- package/src/engine/messaging/outbound-result-helpers.ts +54 -0
- package/src/engine/messaging/outbound-types.ts +45 -0
- package/src/engine/messaging/outbound.ts +485 -0
- package/src/engine/messaging/reply-dispatcher.ts +597 -0
- package/src/engine/messaging/reply-limiter.ts +164 -0
- package/src/engine/messaging/sender.ts +729 -0
- package/src/engine/messaging/streaming-c2c.ts +1192 -0
- package/src/engine/messaging/streaming-media-send.ts +544 -0
- package/src/engine/messaging/target-parser.ts +104 -0
- package/src/engine/ref/format-message-ref.ts +142 -0
- package/src/engine/ref/format-ref-entry.test.ts +60 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +224 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +254 -0
- package/src/engine/session/session-store.ts +284 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.test.ts +280 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.test.ts +186 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.test.ts +250 -0
- package/src/engine/utils/audio.ts +585 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +109 -0
- package/src/engine/utils/file-utils.test.ts +72 -0
- package/src/engine/utils/file-utils.ts +225 -0
- package/src/engine/utils/format.test.ts +68 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.test.ts +158 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.test.ts +28 -0
- package/src/engine/utils/log.ts +61 -0
- package/src/engine/utils/media-tags.test.ts +32 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.test.ts +68 -0
- package/src/engine/utils/payload.ts +145 -0
- package/src/engine/utils/platform-storage-laziness.test.ts +65 -0
- package/src/engine/utils/platform.test.ts +148 -0
- package/src/engine/utils/platform.ts +343 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.test.ts +104 -0
- package/src/engine/utils/stt.ts +100 -0
- package/src/engine/utils/text-parsing.test.ts +29 -0
- package/src/engine/utils/text-parsing.ts +155 -0
- package/src/engine/utils/upload-cache.ts +96 -0
- package/src/engine/utils/voice-text.ts +15 -0
- package/src/exec-approvals.ts +218 -0
- package/src/manifest-schema.test.ts +56 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/types.ts +210 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine adapter layer — all external dependency interfaces unified here.
|
|
3
|
+
*
|
|
4
|
+
* This directory is the **single source of truth** for every interface
|
|
5
|
+
* the engine uses to talk to the outside world.
|
|
6
|
+
*
|
|
7
|
+
* ## Two-layer DI architecture
|
|
8
|
+
*
|
|
9
|
+
* ### Layer 1: EngineAdapters (构造参数注入 — preferred)
|
|
10
|
+
*
|
|
11
|
+
* Used for capabilities consumed within the pipeline call stack.
|
|
12
|
+
* Injected once via {@link CoreGatewayContext.adapters}, threaded
|
|
13
|
+
* through {@link InboundPipelineDeps.adapters}, consumed by stages.
|
|
14
|
+
*
|
|
15
|
+
* - {@link HistoryPort} — group history record/build/clear
|
|
16
|
+
* - {@link MentionGatePort} — mention + command gate evaluation
|
|
17
|
+
* - {@link AudioConvertPort} — inbound SILK→WAV conversion
|
|
18
|
+
* - {@link OutboundAudioPort} — outbound WAV→SILK conversion
|
|
19
|
+
* - {@link CommandsPort} — slash-command version/approve dependencies
|
|
20
|
+
*
|
|
21
|
+
* ### Layer 2: PlatformAdapter (global singleton — leaf utilities)
|
|
22
|
+
*
|
|
23
|
+
* Used by leaf utility functions (`file-utils`, `image-size`,
|
|
24
|
+
* `platform`, `config/resolve`) that sit outside the pipeline and
|
|
25
|
+
* cannot receive a `deps` parameter. Registered once at startup.
|
|
26
|
+
*
|
|
27
|
+
* - {@link PlatformAdapter} — SSRF, secrets, media fetch, temp dir
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { FetchMediaOptions, FetchMediaResult, SecretInputRef } from "./types.js";
|
|
31
|
+
|
|
32
|
+
// ============ EngineAdapters (aggregated port injection) ============
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Aggregated adapter ports injected via `CoreGatewayContext.adapters`.
|
|
36
|
+
*
|
|
37
|
+
* All fields are required — the bridge layer must provide every adapter.
|
|
38
|
+
* The engine no longer falls back to built-in implementations.
|
|
39
|
+
*/
|
|
40
|
+
export interface EngineAdapters {
|
|
41
|
+
/** Group history record/build/clear — backed by SDK `reply-history`. */
|
|
42
|
+
history: import("./history.port.js").HistoryPort;
|
|
43
|
+
/** Mention + command gate evaluation — backed by SDK `channel-mention-gating`. */
|
|
44
|
+
mentionGate: import("./mention-gate.port.js").MentionGatePort;
|
|
45
|
+
/** Inbound audio conversion (SILK→WAV, voice detection). */
|
|
46
|
+
audioConvert: import("./audio.port.js").AudioConvertPort;
|
|
47
|
+
/** Outbound audio conversion (WAV→SILK, audio detection). */
|
|
48
|
+
outboundAudio: import("./audio.port.js").OutboundAudioPort;
|
|
49
|
+
/** Slash-command dependencies (version, approve runtime). */
|
|
50
|
+
commands: import("./commands.port.js").CommandsPort;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============ PlatformAdapter (global singleton — leaf utilities) ============
|
|
54
|
+
|
|
55
|
+
/** Platform adapter that leaf utilities use for framework-specific operations. */
|
|
56
|
+
export interface PlatformAdapter {
|
|
57
|
+
/** Validate that a remote URL is safe to fetch (SSRF protection). */
|
|
58
|
+
validateRemoteUrl(url: string, options?: { allowPrivate?: boolean }): Promise<void>;
|
|
59
|
+
|
|
60
|
+
/** Resolve a secret value (SecretInput or plain string) to a plain string. */
|
|
61
|
+
resolveSecret(value: string | SecretInputRef | undefined): Promise<string | undefined>;
|
|
62
|
+
|
|
63
|
+
/** Download a remote file to a local directory. Returns the local file path. */
|
|
64
|
+
downloadFile(url: string, destDir: string, filename?: string): Promise<string>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch remote media with SSRF protection.
|
|
68
|
+
* Replaces direct usage of `fetchRemoteMedia` from `plugin-sdk/media-runtime`.
|
|
69
|
+
*/
|
|
70
|
+
fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult>;
|
|
71
|
+
|
|
72
|
+
/** Return the preferred temporary directory for the platform. */
|
|
73
|
+
getTempDir(): string;
|
|
74
|
+
|
|
75
|
+
/** Check whether a secret input value has been configured (non-empty). */
|
|
76
|
+
hasConfiguredSecret(value: unknown): boolean;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize a raw SecretInput value into a plain string.
|
|
80
|
+
* For unresolved references (e.g. `$secret:xxx`), returns the raw reference string.
|
|
81
|
+
*/
|
|
82
|
+
normalizeSecretInputString(value: unknown): string | undefined;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a SecretInput value into the final plain-text secret.
|
|
86
|
+
* For secret references, resolves them to actual values via the platform's secret store.
|
|
87
|
+
*/
|
|
88
|
+
resolveSecretInputString(params: { value: unknown; path: string }): string | undefined;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Submit an approval decision to the framework's approval gateway.
|
|
92
|
+
* Optional — only available when the framework supports approvals.
|
|
93
|
+
* Returns true if the decision was submitted successfully.
|
|
94
|
+
*/
|
|
95
|
+
resolveApproval?(approvalId: string, decision: string): Promise<boolean>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let _adapter: PlatformAdapter | null = null;
|
|
99
|
+
let _adapterFactory: (() => PlatformAdapter) | null = null;
|
|
100
|
+
|
|
101
|
+
/** Register the platform adapter. Called once during startup. */
|
|
102
|
+
export function registerPlatformAdapter(adapter: PlatformAdapter): void {
|
|
103
|
+
_adapter = adapter;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Register a factory that creates the PlatformAdapter on first access.
|
|
108
|
+
*
|
|
109
|
+
* This decouples adapter availability from side-effect import ordering.
|
|
110
|
+
* The factory is invoked at most once — on the first `getPlatformAdapter()`
|
|
111
|
+
* call when no adapter has been explicitly registered yet.
|
|
112
|
+
*/
|
|
113
|
+
export function registerPlatformAdapterFactory(factory: () => PlatformAdapter): void {
|
|
114
|
+
_adapterFactory = factory;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get the registered platform adapter.
|
|
119
|
+
*
|
|
120
|
+
* If no adapter has been explicitly registered yet but a factory was provided
|
|
121
|
+
* via `registerPlatformAdapterFactory()`, the factory is invoked to create
|
|
122
|
+
* and register the adapter automatically.
|
|
123
|
+
*/
|
|
124
|
+
export function getPlatformAdapter(): PlatformAdapter {
|
|
125
|
+
if (!_adapter && _adapterFactory) {
|
|
126
|
+
_adapter = _adapterFactory();
|
|
127
|
+
}
|
|
128
|
+
if (!_adapter) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
"PlatformAdapter not registered. Call registerPlatformAdapter() during bootstrap.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return _adapter;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Check whether a platform adapter has been registered (or can be created from a factory). */
|
|
137
|
+
export function hasPlatformAdapter(): boolean {
|
|
138
|
+
return _adapter !== null || _adapterFactory !== null;
|
|
139
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mention gate port — abstracts the SDK's `resolveInboundMentionDecision`
|
|
3
|
+
* + `resolveControlCommandGate` into a single interface.
|
|
4
|
+
*
|
|
5
|
+
* The engine's `resolveGroupMessageGate` (Layer 1: ignoreOtherMentions)
|
|
6
|
+
* is QQ-specific and stays in `group/message-gating.ts`. Layer 2+3
|
|
7
|
+
* (command gating + mention gating + command bypass) delegate to this port.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Implicit mention kind aligned with SDK's `InboundImplicitMentionKind`. */
|
|
11
|
+
type ImplicitMentionKind = "reply_to_bot" | "quoted_bot" | "bot_thread_participant" | "native";
|
|
12
|
+
|
|
13
|
+
/** Facts about the current message's mention state. */
|
|
14
|
+
export interface MentionFacts {
|
|
15
|
+
canDetectMention: boolean;
|
|
16
|
+
wasMentioned: boolean;
|
|
17
|
+
hasAnyMention?: boolean;
|
|
18
|
+
implicitMentionKinds?: readonly ImplicitMentionKind[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Policy configuration for the mention gate. */
|
|
22
|
+
export interface MentionPolicy {
|
|
23
|
+
isGroup: boolean;
|
|
24
|
+
requireMention: boolean;
|
|
25
|
+
allowTextCommands: boolean;
|
|
26
|
+
hasControlCommand: boolean;
|
|
27
|
+
commandAuthorized: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Result of the mention gate evaluation. */
|
|
31
|
+
export interface MentionGateDecision {
|
|
32
|
+
effectiveWasMentioned: boolean;
|
|
33
|
+
shouldSkip: boolean;
|
|
34
|
+
shouldBypassMention: boolean;
|
|
35
|
+
implicitMention: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MentionGatePort {
|
|
39
|
+
/**
|
|
40
|
+
* Evaluate whether the message should be skipped based on mention
|
|
41
|
+
* policy, command bypass, and implicit mention rules.
|
|
42
|
+
*
|
|
43
|
+
* Equivalent to SDK's `resolveInboundMentionDecision` with the
|
|
44
|
+
* command-bypass logic folded in.
|
|
45
|
+
*/
|
|
46
|
+
resolveInboundMentionDecision(params: {
|
|
47
|
+
facts: MentionFacts;
|
|
48
|
+
policy: MentionPolicy;
|
|
49
|
+
}): MentionGateDecision;
|
|
50
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types used by the PlatformAdapter interface.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Reference to a secret stored in the platform's secret management system. */
|
|
6
|
+
export interface SecretInputRef {
|
|
7
|
+
source: "env" | "file" | "config";
|
|
8
|
+
id: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Options for fetching remote media through the platform adapter. */
|
|
12
|
+
export interface FetchMediaOptions {
|
|
13
|
+
url: string;
|
|
14
|
+
/** Hint for the local filename when saving. */
|
|
15
|
+
filePathHint?: string;
|
|
16
|
+
/** Maximum bytes to download. */
|
|
17
|
+
maxBytes?: number;
|
|
18
|
+
/** Maximum redirects to follow. */
|
|
19
|
+
maxRedirects?: number;
|
|
20
|
+
/** SSRF policy configuration. */
|
|
21
|
+
ssrfPolicy?: SsrfPolicyConfig;
|
|
22
|
+
/** Extra fetch() RequestInit options. */
|
|
23
|
+
requestInit?: RequestInit;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Result of a remote media fetch operation. */
|
|
27
|
+
export interface FetchMediaResult {
|
|
28
|
+
buffer: Buffer;
|
|
29
|
+
fileName?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** SSRF policy configuration — platform-agnostic subset. */
|
|
33
|
+
export interface SsrfPolicyConfig {
|
|
34
|
+
/** Hostnames that are always allowed (supports `*.example.com` wildcards). */
|
|
35
|
+
hostnameAllowlist?: string[];
|
|
36
|
+
/** Whether to allow RFC 2544 benchmark ranges (198.18.0.0/15). */
|
|
37
|
+
allowRfc2544BenchmarkRange?: boolean;
|
|
38
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core HTTP client for the QQ Open Platform REST API.
|
|
3
|
+
*
|
|
4
|
+
* Key improvements over the old `src/api.ts#apiRequest`:
|
|
5
|
+
* - `ApiClient` is an **instance** — config (baseUrl, timeout, logger, UA)
|
|
6
|
+
* is injected via the constructor, eliminating module-level globals.
|
|
7
|
+
* - Throws structured `ApiError` with httpStatus, bizCode, and path fields.
|
|
8
|
+
* - Detects HTML error pages from CDN/gateway and returns user-friendly messages.
|
|
9
|
+
* - `redactBodyKeys` replaces the hardcoded `file_data` redaction.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js";
|
|
13
|
+
import { formatErrorMessage } from "../utils/format.js";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_BASE_URL = "https://api.sgroup.qq.com";
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
17
|
+
const FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
|
18
|
+
|
|
19
|
+
interface RequestOptions {
|
|
20
|
+
/** Request timeout override in milliseconds. */
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
/** Body keys to redact in debug logs (e.g. `['file_data']`). */
|
|
23
|
+
redactBodyKeys?: string[];
|
|
24
|
+
/**
|
|
25
|
+
* Mark the request as a file-upload call.
|
|
26
|
+
*
|
|
27
|
+
* Triggers the longer `fileUploadTimeoutMs` (default 120s) instead of the
|
|
28
|
+
* standard `defaultTimeoutMs` (default 30s). Prefer this flag over
|
|
29
|
+
* inspecting the request path; it keeps the timeout policy independent of
|
|
30
|
+
* route naming conventions.
|
|
31
|
+
*/
|
|
32
|
+
uploadRequest?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Stateful HTTP client for the QQ Open Platform.
|
|
37
|
+
*
|
|
38
|
+
* Usage:
|
|
39
|
+
* ```ts
|
|
40
|
+
* const client = new ApiClient({ logger, userAgent: 'QQBotPlugin/1.0' });
|
|
41
|
+
* const data = await client.request<{ url: string }>(token, 'GET', '/gateway');
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export class ApiClient {
|
|
45
|
+
private readonly baseUrl: string;
|
|
46
|
+
private readonly defaultTimeoutMs: number;
|
|
47
|
+
private readonly fileUploadTimeoutMs: number;
|
|
48
|
+
private readonly logger?: EngineLogger;
|
|
49
|
+
private readonly resolveUserAgent: () => string;
|
|
50
|
+
|
|
51
|
+
constructor(config: ApiClientConfig = {}) {
|
|
52
|
+
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
53
|
+
this.defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
54
|
+
this.fileUploadTimeoutMs = config.fileUploadTimeoutMs ?? FILE_UPLOAD_TIMEOUT_MS;
|
|
55
|
+
this.logger = config.logger;
|
|
56
|
+
const ua = config.userAgent ?? "QQBotPlugin/unknown";
|
|
57
|
+
this.resolveUserAgent = typeof ua === "function" ? ua : () => ua;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Send an authenticated JSON request to the QQ Open Platform.
|
|
62
|
+
*
|
|
63
|
+
* @param accessToken - Bearer token (`QQBot {token}`).
|
|
64
|
+
* @param method - HTTP method.
|
|
65
|
+
* @param path - API path (appended to baseUrl).
|
|
66
|
+
* @param body - Optional JSON body.
|
|
67
|
+
* @param options - Optional request overrides.
|
|
68
|
+
* @returns Parsed JSON response.
|
|
69
|
+
* @throws {ApiError} On HTTP or parse errors.
|
|
70
|
+
*/
|
|
71
|
+
async request<T = unknown>(
|
|
72
|
+
accessToken: string,
|
|
73
|
+
method: string,
|
|
74
|
+
path: string,
|
|
75
|
+
body?: unknown,
|
|
76
|
+
options?: RequestOptions,
|
|
77
|
+
): Promise<T> {
|
|
78
|
+
const url = `${this.baseUrl}${path}`;
|
|
79
|
+
|
|
80
|
+
const headers: Record<string, string> = {
|
|
81
|
+
Authorization: `QQBot ${accessToken}`,
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
"User-Agent": this.resolveUserAgent(),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const isFileUpload =
|
|
87
|
+
options?.uploadRequest === true ||
|
|
88
|
+
// Back-compat: legacy callers that predate the explicit `uploadRequest`
|
|
89
|
+
// flag still get the long timeout when hitting file endpoints. New
|
|
90
|
+
// code should always pass `uploadRequest: true` explicitly.
|
|
91
|
+
path.includes("/files") ||
|
|
92
|
+
path.includes("/upload_prepare") ||
|
|
93
|
+
path.includes("/upload_part_finish");
|
|
94
|
+
const timeout =
|
|
95
|
+
options?.timeoutMs ?? (isFileUpload ? this.fileUploadTimeoutMs : this.defaultTimeoutMs);
|
|
96
|
+
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
99
|
+
|
|
100
|
+
const fetchInit: RequestInit = {
|
|
101
|
+
method,
|
|
102
|
+
headers,
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (body) {
|
|
107
|
+
fetchInit.body = JSON.stringify(body);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Debug logging with optional body redaction.
|
|
111
|
+
this.logger?.debug?.(`[qqbot:api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
|
|
112
|
+
if (body && this.logger?.debug) {
|
|
113
|
+
const logBody = { ...(body as Record<string, unknown>) };
|
|
114
|
+
for (const key of options?.redactBodyKeys ?? ["file_data"]) {
|
|
115
|
+
if (typeof logBody[key] === "string") {
|
|
116
|
+
logBody[key] = `<redacted ${logBody[key].length} chars>`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
this.logger.debug(`[qqbot:api] >>> Body: ${JSON.stringify(logBody)}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let res: Response;
|
|
123
|
+
try {
|
|
124
|
+
res = await fetch(url, fetchInit);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
clearTimeout(timeoutId);
|
|
127
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
128
|
+
this.logger?.error?.(`[qqbot:api] <<< Timeout after ${timeout}ms`);
|
|
129
|
+
throw new ApiError(`Request timeout [${path}]: exceeded ${timeout}ms`, 0, path);
|
|
130
|
+
}
|
|
131
|
+
this.logger?.error?.(`[qqbot:api] <<< Network error: ${formatErrorMessage(err)}`);
|
|
132
|
+
throw new ApiError(`Network error [${path}]: ${formatErrorMessage(err)}`, 0, path);
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timeoutId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Log response status and trace ID.
|
|
138
|
+
const traceId = res.headers.get("x-tps-trace-id") ?? "";
|
|
139
|
+
this.logger?.info?.(
|
|
140
|
+
`[qqbot:api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
let rawBody: string;
|
|
144
|
+
try {
|
|
145
|
+
rawBody = await res.text();
|
|
146
|
+
} catch (err) {
|
|
147
|
+
throw new ApiError(
|
|
148
|
+
`Failed to read response [${path}]: ${formatErrorMessage(err)}`,
|
|
149
|
+
res.status,
|
|
150
|
+
path,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
this.logger?.debug?.(`[qqbot:api] <<< Body: ${rawBody}`);
|
|
154
|
+
|
|
155
|
+
// Detect non-JSON responses (HTML gateway errors, CDN rate-limit pages).
|
|
156
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
157
|
+
const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
|
|
158
|
+
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
if (isHtmlResponse) {
|
|
161
|
+
const statusHint =
|
|
162
|
+
res.status === 502 || res.status === 503 || res.status === 504
|
|
163
|
+
? "调用发生异常,请稍候重试"
|
|
164
|
+
: res.status === 429
|
|
165
|
+
? "请求过于频繁,已被限流"
|
|
166
|
+
: `开放平台返回 HTTP ${res.status}`;
|
|
167
|
+
throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// JSON error response.
|
|
171
|
+
try {
|
|
172
|
+
const error = JSON.parse(rawBody) as {
|
|
173
|
+
message?: string;
|
|
174
|
+
code?: number;
|
|
175
|
+
err_code?: number;
|
|
176
|
+
};
|
|
177
|
+
const bizCode = error.code ?? error.err_code;
|
|
178
|
+
throw new ApiError(
|
|
179
|
+
`API Error [${path}]: ${error.message ?? rawBody}`,
|
|
180
|
+
res.status,
|
|
181
|
+
path,
|
|
182
|
+
bizCode,
|
|
183
|
+
error.message,
|
|
184
|
+
);
|
|
185
|
+
} catch (parseErr) {
|
|
186
|
+
if (parseErr instanceof ApiError) {
|
|
187
|
+
throw parseErr;
|
|
188
|
+
}
|
|
189
|
+
throw new ApiError(
|
|
190
|
+
`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`,
|
|
191
|
+
res.status,
|
|
192
|
+
path,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Successful response but not JSON (extreme edge case).
|
|
198
|
+
if (isHtmlResponse) {
|
|
199
|
+
throw new ApiError(
|
|
200
|
+
`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`,
|
|
201
|
+
res.status,
|
|
202
|
+
path,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
return JSON.parse(rawBody) as T;
|
|
208
|
+
} catch {
|
|
209
|
+
throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|