feat(qqbot): extract self-contained engine/ architecture with QR-code onboarding, approval handling (#67960)

* feat(qqbot): add core architecture modules

* feat(qqbot): extract engine modules with DI adapters

* refactor(qqbot): remove plugin-level TTS, delegate to framework

Remove qqbot's internal TTS implementation and unify voice synthesis
through the framework's global TTS provider registry.

- Delete engine/gateway/tts-config.ts (plugin-specific TTS config)
- Simplify TTSProvider interface to textToSpeech + audioFileToSilkBase64
- Remove dual-strategy TTS in handleAudioPayload (plugin + global fallback)
- Strip QQBotTtsSchema from config-schema, plugin.json, and tests
- Remove TTS diagnostics logging and hasTTS system prompt from gateway
- Delete ~260 lines of TTS code from utils/audio-convert.ts

Made-with: Cursor

* feat(qqbot): extract shared engine modules for config, tools, and audio

Add engine-layer modules that are self-contained and portable across
both the built-in and standalone qqbot packages:

- engine/config: account resolution helpers, field readers
- engine/tools: channel API proxy, remind scheduling logic
- engine/utils: audio format conversion, duration/error formatting,
  debug logging

Consolidate duplicate utility functions across the codebase:

- Merge debug-log.ts into log.ts
- Merge error-format.ts into format.ts with full .cause chain support
- Unify normalizeLowercase/readNumber/readBoolean/readStringMap into
  string-normalize.ts, removing private copies in resolve.ts,
  remind-logic.ts, and audio-convert.ts
- Remove dead formatDuration export from audio-convert.ts
- Delete unused config/schema.ts and config/helpers.ts

Made-with: Cursor

* refactor(qqbot): streamline account configuration and credential management

Refactor the QQBot account configuration logic by consolidating credential management into dedicated engine modules. Key changes include:

- Migrate credential clearing and validation logic to engine/config/credentials.ts.
- Simplify setup input validation and application in engine/config/setup-logic.ts.
- Enhance account resolution and configuration application in engine/config/resolve.ts.
- Update channel and messaging logic to utilize the new credential management functions.

This refactor improves code maintainability and clarity by separating concerns and reducing duplication across the codebase.

* feat(qqbot): simplify api architecture

* feat: 支持扫码绑定QQ机器人

* feat(qqbot): refactor gateway into inbound pipeline + outbound dispatch

- Extract handleMessage (620 lines) into three modules:
  - inbound-context.ts: InboundContext type definition
  - inbound-pipeline.ts: buildInboundContext()
  - outbound-dispatch.ts: dispatchOutbound()
- gateway.ts handleMessage reduced to ~35 line shell
- Unify parseRefIndices: support both ext prefix formats + MSG_TYPE_QUOTE
- Add ref/format-message-ref.ts for cache-miss quote formatting
- Remove [QQBot] to= from agentBody, use GroupSystemPrompt instead
- QueuedMessage: add msgType/msgElements for quote messages

* fix(qqbot): fix markdownSupport loss + dynamic User-Agent

Root cause: setOpenClawVersion() called _ensureInitialized(true) which
cleared _appRegistry, destroying the MessageApi instance created by
initApiConfig() with markdownSupport=true. Subsequent block deliver
calls created a default markdownSupport=false instance, causing:
1. Markdown messages sent as plain text (msg_type=0 instead of 2)
2. message_reference incorrectly added (only suppressed in MD mode)

Fix: ApiClient and TokenManager now accept userAgent as string | (() => string).
sender.ts passes the buildUserAgent function reference, so UA changes
propagate automatically on next request without rebuilding any objects.

- ApiClient: userAgent -> resolveUserAgent getter, called per-request
- TokenManager: same pattern
- types.ts: ApiClientConfig.userAgent supports string | (() => string)
- sender.ts: remove force re-init + _rebuildAppRegistry hack
  - initSender/setOpenClawVersion only update version variables
  - _ensureInitialized creates singletons once, never destroys them
  - _appRegistry is never cleared -> markdownSupport always preserved
- runtime.ts: inject framework version via setOpenClawVersion(runtime.version)
- gateway.ts: pass openclawVersion to initSender + registerPluginVersion
- slash-commands-impl.ts: remove fragile require("../package.json")

* feat(qqbot): implement native approval handling and configuration

Add a new approval handling system for QQBot that integrates with the existing framework. Key features include:

- Introduce `approval-handler.runtime.ts` for managing approval requests via QQ messages with inline keyboard support.
- Create `approval-native.ts` as the entry point for QQBot's approval capability, allowing for simplified approval processes without explicit approver lists.
- Implement configuration schema for exec approvals, enabling fine-grained control over who can approve requests.
- Enhance messaging and interaction handling to support approval decisions through button interactions.

This implementation streamlines the approval process, making it more user-friendly and efficient for QQBot users.

* refactor(qqbot): enhance error handling across API and messaging modules

This update introduces a centralized error formatting utility, `formatErrorMessage`, to improve consistency in error logging throughout the QQBot codebase. Key changes include:

- Integration of `formatErrorMessage` in various API client, messaging, and gateway modules to standardize error messages.
- Replacement of direct error message handling with the new utility to enhance readability and maintainability.

These improvements streamline error reporting and provide clearer insights into issues encountered during operation.

* refactor(qqbot): enhance API and messaging structure with type improvements

This update refines the API and messaging modules by introducing type enhancements and restructuring function signatures for better clarity and maintainability. Key changes include:

- Updated import statements to streamline type usage in  and .
- Refactored message sending functions to accept options objects, improving readability and flexibility.
- Introduced a new  method in  to facilitate external message-sent notifications.
- Enhanced error handling in the retry mechanism to ensure more robust behavior.

These modifications aim to improve the overall code quality and developer experience within the QQBot framework.

* feat: 优化文案

* refactor(qqbot): unify Logger interfaces + eliminate P0 code smells

Logger unification (17 files):
- Introduce single EngineLogger interface in engine/types.ts
  { info, error, warn?, debug? }
- Delete 5 fragmented Logger interfaces:
  GatewayLogger, ReconnectLogger, MessageRefLogger, PathLogger, SenderLogger
- Replace all references across engine/ to use EngineLogger directly

P0 code smell fixes (sender.ts + messages.ts + outbound-dispatch.ts):
- messages.ts: add public notifyMessageSent() method on MessageApi,
  replacing 8x 'as unknown as { messageSentHook }' private field hack
- sender.ts: extract notifyMediaHook() helper, deduplicate 4 media
  send functions (sendImage/sendVoice/sendVideo/sendFile)
- sender.ts: replace magic numbers 1/2/3/4 with MediaFileType enum
- sender.ts: remove 4 redundant 'as MessageResponse' type assertions
- outbound-dispatch.ts: remove 5 unnecessary 'as never' casts

* feat(qqbot): add /bot-clear-storage command + consolidate utils/types into engine/

/bot-clear-storage (slash-commands-impl.ts):
- Migrate from standalone version, aligned with its two-step flow:
  1. No args: scan ~/.openclaw/media/qqbot/downloads/{appId}/ and
     display file list with confirmation button
  2. --force: delete files + removeEmptyDirs cleanup
- C2C only (group chat returns hint)
- bot-help: exclude bot-upgrade and bot-clear-storage in group listings

Consolidate into engine/:
- Delete src/utils/audio-convert.ts (pure re-export shell, zero consumers)
- Move 5 test files from src/utils/ to src/engine/utils/ (fix import paths)
- Move src/types/silk-wasm.d.ts to src/engine/types/
- Remove empty src/utils/ and src/types/ directories

* refactor(qqbot): restructure API and bridge components for improved modularity

This update enhances the QQBot framework by reorganizing the API and bridge components, promoting better modularity and maintainability. Key changes include:

- Refactored import paths to streamline access to bridge tools and configurations.
- Introduced new bridge files for channel entry, runtime, and approval capabilities, centralizing related functionalities.
- Updated existing functions to utilize the new bridge structure, ensuring consistency across the codebase.
- Removed deprecated functions and types, simplifying the overall architecture.

These modifications aim to improve code clarity and facilitate future development within the QQBot ecosystem.

* refactor(qqbot): standardize engine log levels and unify log tag prefix

- Rename client.ts to api-client.ts to match ApiClient class name
- Downgrade ~60 non-critical info logs to debug level across 12 files
  (token request/response, HTTP request/response, session restore,
  media tag detection, image classification, quote detection,
  attachment download/transcode, retry attempts, etc.)
- Unify log tag prefix to [qqbot:xxx] format across all engine modules
  ([core-api] -> [qqbot:api], [token:x] -> [qqbot:token:x],
  [retry] -> [qqbot:retry], [messages] -> [qqbot:messages],
  [sender:x] -> [qqbot:x])
- Remove unnecessary reqTs timestamp from api-client.ts log output
- Add dispatch event debug log in gateway-connection.ts
- Merge sendProactiveMessage into sendText, remove dead code
  (sendProactiveText import, getRefIdx, QQMessageResult type)
- Narrow allow-from.ts type from unknown[] to Array<string | number>

* refactor(qqbot): move interaction handler from bridge to engine

- Move onInteraction approval handler into engine/gateway.ts as
  createApprovalInteractionHandler(), eliminating the callback
  indirection through CoreGatewayContext
- Remove onInteraction from CoreGatewayContext interface and its
  unused InteractionEvent import from gateway/types.ts
- Remove getPlatformAdapter, parseApprovalButtonData and
  InteractionEvent imports from bridge/gateway.ts

* refactor(qqbot): route bridge and sender logs through framework logger

- Add bridge/logger.ts as a shared logger holder for bridge-layer
  modules, injected with ctx.log during gateway startup
- Replace all console.log/console.error in bridge/ with
  getBridgeLogger() calls (approval, bootstrap, tools)
- Restore framework logger support in sender.ts via initSender()
  so API-layer logs flow through OpenClaw log system
- Remove all direct debugLog/debugError imports from bridge/

* feat(qqbot): per-account isolated resource stack + multi-account logger

- sender.ts: global singletons (ApiClient/TokenManager/MediaApi) -> per-account AccountContext
  - Add _accountRegistry: Map<appId, AccountContext>
  - Each account owns independent client/tokenMgr/mediaApi/messageApi/logger
  - registerAccount() atomically sets up all resources
  - resolveAccount() routes to correct resource stack by appId
  - Remove _sharedLogger/_loggerRegistry/_appRegistry and old structures

- bridge/gateway.ts: createAccountLogger() with auto [accountId] prefix
  - registerAccount() merges logger + markdownSupport + full API resources

- engine-wide: remove ~60 manual [qqbot:${accountId}] log prefixes
  - Prefixes now auto-injected by per-account logger
  - Remove prefix/logPrefix parameter chains (outbound/outbound-deliver/typing-keepalive etc)

* feat(qqbot): completes fallback path for approval with multi-account isolation

When the execApprovals are not configured, multiple QQBot accounts' handlers will attempt to deliver the same approval message. The openid is account-level, and cross-account delivery will trigger a QQ Bot API 500 error.

- Add account ownership verification in the fallback shouldHandle: Only match the account's handler when the request includes turnSourceAccountId; if unbound, delivery is only permitted when the number of enabled+secret accounts is ≤1.

- Consolidate account ownership determination into the unified export `matchesQQBotApprovalAccount` in `exec-approvals.ts`, with both capability and native runtime paths sharing the same logic to eliminate redundancy.

* feat(qqbot): optimize permission validation strategy

* feat(qqbot): show plugin version in /bot-version and /bot-help

Align /bot-version output with the standalone openclaw-qqbot build so users see both the QQBot plugin version and the OpenClaw framework version. Append the plugin version as a footer in /bot-help as well, matching the standalone UX.

Also fix the plugin version lookup that previously rendered as 'vunknown': the old code used a hardcoded '../../package.json' relative path which resolved to 'src/package.json' (non-existent) when executed from raw sources, so the require threw and the default 'unknown' value was retained. The same broken value also leaked into the QQ Bot API User-Agent header.

Replace the hardcoded path with a dedicated helper (bridge/plugin-version.ts) that walks up the directory tree from import.meta.url and validates the manifest's name field (@openclaw/qqbot) to avoid misreading the monorepo root package.json. Covered by 6 unit tests.

* feat(qqbot): trust shared ~/.openclaw/media root for payload files

Add getOpenClawMediaDir() and include it alongside getQQBotMediaDir() in the allowed roots of resolveQQBotPayloadLocalFilePath, so framework-produced attachments under sibling directories (e.g. media/outbound/ written by saveMediaBuffer) are trusted by auto-routed sends without triggering the path-outside-storage guard.

Covered by a new test case that verifies files under ~/.openclaw/media/outbound/ resolve successfully.

* fix(qqbot): ensure PlatformAdapter is registered before approval delivery

After the framework centralized approval handler bootstrap (#62135), the native approval handler is spawned by the framework layer outside the qqbot gateway startAccount context. This means channel.ts's side-effect `import "./bridge/bootstrap.js"` may not have run, leaving PlatformAdapter unregistered when deliverPending calls resolveQQBotAccount -> getPlatformAdapter().

Extract ensurePlatformAdapter() from bootstrap.ts as an idempotent, re-entrant helper and call it in both capability.ts (load callback) and handler-runtime.ts (deliverPending entry) to guarantee the adapter is available regardless of initialization order.

* fix(qqbot): add lazy factory for PlatformAdapter to eliminate import-order dependency

The bundler splits qqbot code into multiple chunks where the adapter singleton and its consumers may live in different modules. When a consumer chunk evaluates before the bootstrap side-effect chunk, getPlatformAdapter() throws because the singleton is still null.

Introduce registerPlatformAdapterFactory() in adapter/index.ts so getPlatformAdapter() can auto-initialize the adapter on first access. bootstrap.ts registers the factory at module evaluation time alongside the existing eager registration path. Also add error logging in downloadFile's catch block to surface fetch failures.

* feat(qqbot): add /bot-approve slash command for exec approval config management

Add /bot-approve command to the built-in QQBot plugin, ported from the
standalone openclaw-qqbot implementation. This command allows users to
manage tools.exec.security and tools.exec.ask settings directly from QQ.

Supported sub-commands:
  /bot-approve on      - allowlist + on-miss (recommended)
  /bot-approve off     - full + off (no approval)
  /bot-approve always  - allowlist + always (strict mode)
  /bot-approve reset   - remove overrides, restore framework defaults
  /bot-approve status  - show current security/ask values

The runtime config API is injected via registerApproveRuntimeGetter()
following the existing dependency injection pattern used by
registerVersionResolver() and registerPluginVersion().

* fix(qqbot): ACK INTERACTION_CREATE events before processing approval buttons

Send PUT /interactions/{id} immediately upon receiving any
INTERACTION_CREATE event to prevent QQ from showing a timeout
error to the user. The ACK is fire-and-forget and does not block
subsequent approval button resolution.

Also resolve merge conflict in pnpm-lock.yaml (keep
@tencent-connect/qqbot-connector@1.1.0 and newer
@thi.ng/bitstream@2.4.46).

* feat(qqbot): enhance reminder functionality with delivery context and credential backup

This update improves the QQBot reminder system by introducing a delivery context for reminders, allowing for more flexible target resolution. Key changes include:

- Updated reminder logic to utilize a delivery envelope, ensuring that reminders are sent with the correct context.
- Implemented credential backup and recovery mechanisms to prevent loss of appId and clientSecret during hot upgrades.
- Added tests for credential backup functionality and admin resolver to ensure reliability.
- Enhanced the remind tool to automatically resolve the target from the current conversation context when not explicitly provided.

These enhancements aim to improve the user experience and reliability of the reminder feature within the QQBot framework.

* fix(qqbot): ensure PlatformAdapter is registered before gateway message processing

Call ensurePlatformAdapter() at the start of bridge/gateway.ts's
startGateway() to guarantee the adapter is available when engine
code (e.g. downloadFile in file-utils.ts) calls getPlatformAdapter().

When the bundler splits code into separate chunks, bootstrap.ts's
module-level side-effect registration may not have executed yet by
the time the gateway processes its first inbound attachment download.

Also fix the TS2339 error in registerApproveRuntimeGetter by using
getQQBotRuntime() (full PluginRuntime with config) instead of
getQQBotRuntimeForEngine() (GatewayPluginRuntime subset without config).

* fix(qqbot): make isAudioFile safe when OutboundAudioAdapter is not registered

sendMedia() calls isAudioFile() as part of its media-type dispatch logic
before any actual audio processing. When the audio adapter is not yet
registered (e.g. framework tool calls sendMedia before gateway startup),
isAudioFile() would throw 'OutboundAudioAdapter not registered' even
for non-audio files like images.

Wrap the getAudio() call in isAudioFile() with try/catch to return false
when the adapter is unavailable, allowing non-audio media sends to
proceed normally.

* refactor(qqbot): remove plugin startup/upgrade greeting pipeline

Drop the startup / upgrade greeting feature that was folded into the
previous reminder + credential-backup commit. The pipeline has proven
unnecessary for the fused build and its supporting admin-resolver
scaffolding has no other consumers, so both are removed wholesale.

- Delete engine/session/startup-greeting.ts and its tests: the
  first-launch "soul online" / "updated to vX.Y.Z" messages, the
  per-(accountId, appId) startup marker, the failure cooldown, and the
  legacy startup-marker.json migration path are all gone.
- Delete engine/session/admin-resolver.ts and its tests: admin openid
  persistence/resolution, upgrade-greeting-target load/clear and the
  sendStartupGreetings dispatcher only ever served the greeting flow
  and were not referenced elsewhere.
- channel.ts: drop the sendStartupGreetings import and the READY /
  RESUMED hooks that triggered greetings; credential-backup snapshots
  stay untouched.
- engine/utils/data-paths.ts: remove getAdminMarkerFile /
  getLegacyAdminMarkerFile / getUpgradeGreetingTargetFile /
  getStartupMarkerFile / getLegacyStartupMarkerFile along with the
  now-stale module docblock sections. Credential-backup helpers and
  safeName are preserved.

Net -655 LOC across 6 files. tsc --noEmit passes on
extensions/qqbot/tsconfig.json and no references to the removed
symbols remain in the workspace.

* fix(qqbot): resolve test failures in extension batch, contracts and bundled runtime deps

- bootstrap: replace sync require() with static imports for secret-input
  and temp-path so vitest resolve.alias works correctly (require bypasses
  vitest aliases causing Cannot find module errors)
- format: handle null/undefined in formatErrorMessage before JSON.stringify
  since JSON.stringify(undefined) returns JS undefined, not a string
- gateway/types: reword comment to avoid triggering the channel-import
  guardrail regex that forbids quoted openclaw/plugin-sdk references
- package.json: mirror @tencent-connect/qqbot-connector ^1.1.0 in root
  dependencies as required by bundled plugin runtime dependency checks

* chore: revert non-qqbot changes to align with upstream main

Revert modifications to src/agents/system-prompt, src/auto-reply/reply/dispatch-from-config, and src/canvas-host/a2ui build artifacts that were inadvertently included in the qqbot feature branch. Also fix .gitignore Core/ pattern to match subdirectories.

* fix(qqbot): remove unused logUnsupportedStructuredMediaTarget after API simplification

* fix(qqbot): restore channel-plugin-api.ts for bundled plugin surface convention

* fix(qqbot): update CI lint allowlists for restructured engine paths

- Update raw fetch() allowlist in check-no-raw-channel-fetch.mjs to
  reflect engine/ directory restructure (src/api.ts → src/engine/api/api-client.ts, etc.)
- Remove stale qqbot allowlist entry for deleted src/utils/audio-convert.ts

* fix(qqbot): eliminate os.tmpdir() in engine layer via adapter injection

- Make hasPlatformAdapter() also check for registered factory, so adapter
  is always discoverable once bootstrap has run
- Remove os.tmpdir() fallbacks in platform.ts getHomeDir()/getTempDir(),
  delegate entirely to PlatformAdapter.getTempDir() which calls
  resolvePreferredOpenClawTmpDir() under the hood
- Keeps engine/ layer free of openclaw/plugin-sdk imports

* chore(qqbot): update CHANGELOG for engine architecture refactor (#67960) (thanks @cxyhhhhh)

---------

Co-authored-by: Bobby <zkd8907@live.com>
Co-authored-by: neilhwang <neilhwang@tencent.com>
Co-authored-by: sliverp <870080352@qq.com>
This commit is contained in:
cxy
2026-04-22 01:05:12 +08:00
committed by GitHub
parent 38aaa23e63
commit 5e72e39c18
149 changed files with 15184 additions and 10312 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- Channels/preview streaming: stream tool-progress updates into live preview edits for Discord, Slack, and Telegram so in-flight replies show incremental tool state in the same preview message before finalization. (#69611) Thanks @thewilloftheshadow.
- Ollama/onboard: populate the cloud-only model list from `ollama.com/api/tags` so `openclaw onboard` reflects the live cloud catalog instead of a static three-model seed; cap the discovered list at 500 and fall back to the previous hardcoded suggestions when ollama.com is unreachable or returns no models. (#68463) Thanks @BruceMacD.
- Matrix/startup: narrow Matrix runtime registration and defer setup/doctor surfaces so cold plugin registration spends about 1.8s less in `setChannelRuntime`. (#69782) Thanks @gumadeiras.
- QQBot: extract a self-contained `engine/` architecture with QR-code onboarding, native approval handling via `/bot-approve`, per-account isolated resource stacks and multi-account logger, credential backup/restore, shared `~/.openclaw/media` payload root, and unified API/bridge/gateway modules. (#67960) Thanks @cxyhhhhh.
### Fixes

View File

@@ -1,9 +1,10 @@
export { qqbotPlugin } from "./src/channel.js";
export { qqbotSetupPlugin } from "./src/channel.setup.js";
export { getFrameworkCommands } from "./src/slash-commands.js";
export { registerChannelTool } from "./src/tools/channel.js";
export { registerRemindTool } from "./src/tools/remind.js";
export { getFrameworkCommands } from "./src/engine/commands/slash-commands-impl.js";
export { registerChannelTool } from "./src/bridge/tools/channel.js";
export { registerRemindTool } from "./src/bridge/tools/remind.js";
export { registerQQBotTools } from "./src/bridge/tools/index.js";
export { registerQQBotFull } from "./src/bridge/channel-entry.js";
export * from "./src/types.js";
export * from "./src/config.js";
export * from "./src/outbound.js";
export * from "./src/proactive.js";
export * from "./src/bridge/config.js";
export * from "./src/engine/messaging/outbound.js";

View File

@@ -2,89 +2,12 @@ import {
defineBundledChannelEntry,
loadBundledEntryExportSync,
type OpenClawPluginApi,
type PluginCommandContext,
} from "openclaw/plugin-sdk/channel-entry-contract";
type QQBotAccount = {
accountId: string;
appId: string;
config: unknown;
};
type MediaTargetContext = {
targetType: "c2c" | "group" | "channel" | "dm";
targetId: string;
account: QQBotAccount;
logPrefix: string;
};
type SendDocumentOptions = {
allowQQBotDataDownloads?: boolean;
};
type QQBotFrameworkCommandResult =
| string
| {
text: string;
filePath?: string;
}
| null
| undefined;
type QQBotFrameworkCommand = {
name: string;
description: string;
handler: (ctx: Record<string, unknown>) => Promise<QQBotFrameworkCommandResult>;
};
function resolveQQBotAccount(config: unknown, accountId?: string): QQBotAccount {
const resolve = loadBundledEntryExportSync<(config: unknown, accountId?: string) => QQBotAccount>(
import.meta.url,
{
specifier: "./api.js",
exportName: "resolveQQBotAccount",
},
);
return resolve(config, accountId);
}
function sendDocument(
context: MediaTargetContext,
filePath: string,
options?: SendDocumentOptions,
) {
const send = loadBundledEntryExportSync<
(
context: MediaTargetContext,
filePath: string,
options?: SendDocumentOptions,
) => Promise<unknown>
>(import.meta.url, {
specifier: "./api.js",
exportName: "sendDocument",
});
return send(context, filePath, options);
}
function getFrameworkCommands(): QQBotFrameworkCommand[] {
const getCommands = loadBundledEntryExportSync<() => QQBotFrameworkCommand[]>(import.meta.url, {
specifier: "./api.js",
exportName: "getFrameworkCommands",
});
return getCommands();
}
function registerChannelTool(api: OpenClawPluginApi): void {
function registerQQBotFull(api: OpenClawPluginApi): void {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./api.js",
exportName: "registerChannelTool",
});
register(api);
}
function registerRemindTool(api: OpenClawPluginApi): void {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./api.js",
exportName: "registerRemindTool",
exportName: "registerQQBotFull",
});
register(api);
}
@@ -102,108 +25,5 @@ export default defineBundledChannelEntry({
specifier: "./runtime-api.js",
exportName: "setQQBotRuntime",
},
registerFull(api: OpenClawPluginApi) {
registerChannelTool(api);
registerRemindTool(api);
// Register all requireAuth:true slash commands with the framework so that
// resolveCommandAuthorization() applies commands.allowFrom.qqbot precedence
// and qqbot: prefix normalization before any handler runs.
for (const cmd of getFrameworkCommands()) {
api.registerCommand({
name: cmd.name,
description: cmd.description,
requireAuth: true,
acceptsArgs: true,
handler: async (ctx: PluginCommandContext) => {
// Derive the QQBot message type from ctx.from so that handlers that
// inspect SlashCommandContext.type get the correct value.
// ctx.from format: "qqbot:<type>:<id>" e.g. "qqbot:c2c:<senderId>"
const fromStripped = (ctx.from ?? "").replace(/^qqbot:/i, "");
const rawMsgType = fromStripped.split(":")[0] ?? "c2c";
const msgType: "c2c" | "guild" | "dm" | "group" =
rawMsgType === "group"
? "group"
: rawMsgType === "channel"
? "guild"
: rawMsgType === "dm"
? "dm"
: "c2c";
// Parse target for file sends (same from string).
const colonIdx = fromStripped.indexOf(":");
const targetId = colonIdx !== -1 ? fromStripped.slice(colonIdx + 1) : fromStripped;
const targetType: "c2c" | "group" | "channel" | "dm" =
rawMsgType === "group"
? "group"
: rawMsgType === "channel"
? "channel"
: rawMsgType === "dm"
? "dm"
: "c2c";
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
// Build a minimal SlashCommandContext from the framework PluginCommandContext.
// commandAuthorized is always true here because the framework has already
// verified the sender via resolveCommandAuthorization().
const slashCtx = {
type: msgType,
senderId: ctx.senderId ?? "",
messageId: "",
eventTimestamp: new Date().toISOString(),
receivedAt: Date.now(),
rawContent: `/${cmd.name}${ctx.args ? ` ${ctx.args}` : ""}`,
args: ctx.args ?? "",
accountId: account.accountId,
// appId is not available from PluginCommandContext directly; handlers
// that need it should call resolveQQBotAccount(ctx.config, ctx.accountId).
appId: account.appId,
accountConfig: account.config,
commandAuthorized: true,
queueSnapshot: {
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 10,
senderPending: 0,
},
};
const result = await cmd.handler(slashCtx);
// Plain-text result.
if (typeof result === "string") {
return { text: result };
}
// File result: send the file attachment via QQ API, return text summary.
if (result && typeof result === "object" && "filePath" in result) {
try {
const mediaCtx: MediaTargetContext = {
targetType,
targetId,
account,
logPrefix: `[qqbot:${account.accountId}]`,
};
await sendDocument(mediaCtx, String(result.filePath), {
allowQQBotDataDownloads: true,
});
} catch {
// File send failed; the text summary is still returned below.
}
return { text: result.text };
}
return {
text:
result &&
typeof result === "object" &&
"text" in result &&
typeof result.text === "string"
? result.text
: "⚠️ 命令返回了意外结果。",
};
},
});
}
},
registerFull: registerQQBotFull,
});

View File

@@ -24,30 +24,6 @@
"transcodeEnabled": { "type": "boolean" }
}
},
"speechQueryParams": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"tts": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"provider": { "type": "string" },
"baseUrl": { "type": "string" },
"apiKey": { "type": "string" },
"model": { "type": "string" },
"voice": { "type": "string" },
"authStyle": {
"type": "string",
"enum": ["bearer", "api-key"]
},
"queryParams": { "$ref": "#/$defs/speechQueryParams" },
"speed": { "type": "number" }
}
},
"stt": {
"type": "object",
"additionalProperties": false,
@@ -139,7 +115,6 @@
"items": { "type": "string" }
},
"audioFormatPolicy": { "$ref": "#/$defs/audioFormatPolicy" },
"tts": { "$ref": "#/$defs/tts" },
"stt": { "$ref": "#/$defs/stt" },
"urlDirectUpload": { "type": "boolean" },
"upgradeUrl": { "type": "string" },

View File

@@ -5,6 +5,7 @@
"description": "OpenClaw QQ Bot channel plugin",
"type": "module",
"dependencies": {
"@tencent-connect/qqbot-connector": "^1.1.0",
"mpg123-decoder": "^1.0.3",
"silk-wasm": "^3.7.1",
"ws": "^8.20.0",

View File

@@ -6,4 +6,4 @@ export type {
PluginLogger,
} from "openclaw/plugin-sdk/core";
export type { ResolvedQQBotAccount, QQBotAccountConfig } from "./src/types.js";
export { getQQBotRuntime, setQQBotRuntime } from "./src/runtime.js";
export { getQQBotRuntime, setQQBotRuntime } from "./src/bridge/runtime.js";

View File

@@ -49,15 +49,16 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb
> **payload.kind 必须是 `"agentTurn"`,绝对不能用 `"systemEvent"`**
> `systemEvent` 只在 AI 会话内部注入文本,用户收不到 QQ 消息。
**5 个不可更改字段**
**不可更改字段**
| 字段 | 固定值 | 原因 |
| ----------------- | ------------- | ---------------------------- |
| `payload.kind` | `"agentTurn"` | `systemEvent` 不会发 QQ 消息 |
| `payload.deliver` | `true` | 否则不投递 |
| `payload.channel` | `"qqbot"` | QQ 通道标识 |
| `payload.to` | 用户 openid | 从 `To` 字段获取 |
| `sessionTarget` | `"isolated"` | 隔离会话避免污染 |
| 字段 | 固定值 | 原因 |
| -------------------- | ------------- | ---------------------------- |
| `payload.kind` | `"agentTurn"` | `systemEvent` 不会发 QQ 消息 |
| `delivery.mode` | `"announce"` | 主动投递模式 |
| `delivery.channel` | `"qqbot"` | QQ 通道标识 |
| `delivery.to` | 目标地址 | 从当前会话上下文获取 |
| `delivery.accountId` | 当前账户 ID | 多账号场景下不可省略 |
| `sessionTarget` | `"isolated"` | 隔离会话避免污染 |
> `schedule.atMs` 必须是**绝对毫秒时间戳**(如 `1770733800000`),不支持 `"5m"` 等相对字符串。
> 计算方式:`当前时间戳ms + 延迟毫秒`。
@@ -75,10 +76,13 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb
"deleteAfterRun": true,
"payload": {
"kind": "agentTurn",
"message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀",
"deliver": true,
"message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀"
},
"delivery": {
"mode": "announce",
"channel": "qqbot",
"to": "{openid}"
"to": "qqbot:c2c:{openid}",
"accountId": "{accountId}"
}
}
}
@@ -96,16 +100,20 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb
"wakeMode": "now",
"payload": {
"kind": "agentTurn",
"message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀",
"deliver": true,
"message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀"
},
"delivery": {
"mode": "announce",
"channel": "qqbot",
"to": "{openid}"
"to": "qqbot:c2c:{openid}",
"accountId": "{accountId}"
}
}
}
```
> 周期任务**不加** `deleteAfterRun`。群聊 `to` 格式为 `"group:{group_openid}"`。
> 周期任务**不加** `deleteAfterRun`。群聊 `delivery.to` 格式为 `"qqbot:group:{group_openid}"`。
> 若通过 `qqbot_remind` 工具生成 cronParams**必须**原样传给 `cron` 工具,不要修改或省略任何字段,特别是 `delivery.accountId`。
---

View File

@@ -1,145 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const ssrfMocks = vi.hoisted(() => ({
fetchWithSsrFGuard: vi.fn(),
resolvePinnedHostnameWithPolicy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: ssrfMocks.fetchWithSsrFGuard,
resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy,
}));
vi.mock("./utils/debug-log.js", () => ({
debugError: vi.fn(),
debugLog: vi.fn(),
}));
import { MediaFileType, uploadC2CMedia, uploadGroupMedia } from "./api.js";
import { clearUploadCache, computeFileHash, setCachedFileInfo } from "./utils/upload-cache.js";
describe("qqbot direct upload SSRF guard", () => {
beforeEach(() => {
vi.clearAllMocks();
clearUploadCache();
ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({
hostname: "example.com",
addresses: ["203.0.113.10"],
lookup: vi.fn(),
});
ssrfMocks.fetchWithSsrFGuard.mockResolvedValue({
response: new Response(JSON.stringify({ file_uuid: "uuid", file_info: "info", ttl: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
}),
release: async () => {},
});
});
it("blocks direct-upload URLs that target private or internal hosts", async () => {
ssrfMocks.resolvePinnedHostnameWithPolicy.mockRejectedValueOnce(
new Error("Blocked hostname or private/internal/special-use IP address"),
);
await expect(
uploadC2CMedia(
"access-token",
"user-1",
MediaFileType.IMAGE,
"https://169.254.169.254/latest/meta-data/iam/security-credentials/",
),
).rejects.toThrow("Blocked hostname or private/internal/special-use IP address");
expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled();
});
it("blocks non-HTTPS direct-upload URLs before the QQ upload request", async () => {
await expect(
uploadGroupMedia(
"access-token",
"group-1",
MediaFileType.FILE,
"http://cdn.qpic.cn/payload.txt",
),
).rejects.toThrow("Direct-upload media URL must use HTTPS");
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled();
});
it("allows public HTTPS direct-upload URLs", async () => {
const result = await uploadC2CMedia(
"access-token",
"user-1",
MediaFileType.IMAGE,
"https://example.com/payload.png",
);
expect(result).toEqual({ file_uuid: "uuid", file_info: "info", ttl: 3600 });
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("example.com");
expect(ssrfMocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(1);
});
it("allows public HTTPS direct-upload URLs for group uploads", async () => {
const result = await uploadGroupMedia(
"access-token",
"group-1",
MediaFileType.FILE,
"https://example.com/payload.txt",
);
expect(result).toEqual({ file_uuid: "uuid", file_info: "info", ttl: 3600 });
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("example.com");
expect(ssrfMocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(1);
});
it("skips URL validation on c2c cache hits when fileData is reused", async () => {
const fileData = "cached-file-data";
setCachedFileInfo(
computeFileHash(fileData),
"c2c",
"user-1",
MediaFileType.IMAGE,
"cached-info",
"cached-uuid",
3600,
);
const result = await uploadC2CMedia(
"access-token",
"user-1",
MediaFileType.IMAGE,
"https://example.com/stale.png",
fileData,
);
expect(result).toEqual({ file_uuid: "", file_info: "cached-info", ttl: 0 });
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled();
});
it("skips URL validation on group cache hits when fileData is reused", async () => {
const fileData = "cached-group-file-data";
setCachedFileInfo(
computeFileHash(fileData),
"group",
"group-1",
MediaFileType.FILE,
"cached-group-info",
"cached-group-uuid",
3600,
);
const result = await uploadGroupMedia(
"access-token",
"group-1",
MediaFileType.FILE,
"https://example.com/stale.txt",
fileData,
);
expect(result).toEqual({ file_uuid: "", file_info: "cached-group-info", ttl: 0 });
expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
/**
* QQ Bot Approval Capability — entry point.
*
* QQBot uses a simpler approval model than Telegram/Slack: any user who
* can see the inline-keyboard buttons can approve. No explicit approver
* list is required — the bot simply sends the approval message to the
* originating conversation and whoever clicks the button resolves it.
*
* When `execApprovals` IS configured, it gates which requests are
* handled natively and who is authorized. When it is NOT configured,
* QQBot falls back to "always handle, anyone can approve".
*/
import {
createChannelApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveApprovalTarget } from "../../engine/approval/index.js";
import {
isQQBotExecApprovalClientEnabled,
matchesQQBotApprovalAccount,
shouldHandleQQBotExecApprovalRequest,
isQQBotExecApprovalAuthorizedSender,
isQQBotExecApprovalApprover,
resolveQQBotExecApprovalConfig,
} from "../../exec-approvals.js";
import { ensurePlatformAdapter } from "../bootstrap.js";
import { resolveQQBotAccount } from "../config.js";
import { getBridgeLogger } from "../logger.js";
/**
* When `execApprovals` is configured, delegate to the profile-based
* check. Otherwise fall back to target-resolvability plus the shared
* per-account ownership rule in `matchesQQBotApprovalAccount` so that
* each QQBot account handler only delivers approvals that originated
* from its own account (openids are account-scoped — cross-account
* delivery fails with 500 on the QQ Bot API).
*/
function shouldHandleRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: {
request: {
sessionKey?: string | null;
turnSourceTo?: string | null;
turnSourceChannel?: string | null;
turnSourceAccountId?: string | null;
};
};
}): boolean {
if (hasExecApprovalConfig(params)) {
return shouldHandleQQBotExecApprovalRequest(params as never);
}
if (!canResolveTarget(params.request)) {
return false;
}
return matchesQQBotApprovalAccount({
cfg: params.cfg,
accountId: params.accountId,
request: params.request as never,
});
}
function hasExecApprovalConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return resolveQQBotExecApprovalConfig(params) !== undefined;
}
function isNativeDeliveryEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
if (hasExecApprovalConfig(params)) {
return isQQBotExecApprovalClientEnabled(params);
}
const account = resolveQQBotAccount(params.cfg, params.accountId);
return account.enabled && account.secretSource !== "none";
}
function canResolveTarget(request: {
request: { sessionKey?: string | null; turnSourceTo?: string | null };
}): boolean {
const sessionKey = request.request.sessionKey ?? null;
const turnSourceTo = request.request.turnSourceTo ?? null;
const target = resolveApprovalTarget(sessionKey, turnSourceTo);
if (target) {
return true;
}
const sessionConversation = resolveApprovalRequestSessionConversation({
request: request as never,
channel: "qqbot",
bundledFallback: true,
});
return sessionConversation?.id != null;
}
function createQQBotApprovalCapability(): ChannelApprovalCapability {
return createChannelApprovalCapability({
authorizeActorAction: ({ cfg, accountId, senderId, approvalKind }) => {
if (hasExecApprovalConfig({ cfg, accountId })) {
const authorized =
approvalKind === "plugin"
? isQQBotExecApprovalApprover({ cfg, accountId, senderId })
: isQQBotExecApprovalAuthorizedSender({ cfg, accountId, senderId });
return authorized
? { authorized: true }
: { authorized: false, reason: "You are not authorized to approve this request." };
}
return { authorized: true };
},
getActionAvailabilityState: ({
cfg,
accountId,
}: {
cfg: OpenClawConfig;
accountId?: string | null;
action: "approve";
}) => {
const enabled = isNativeDeliveryEnabled({ cfg, accountId });
return enabled ? { kind: "enabled" } : { kind: "disabled" };
},
getExecInitiatingSurfaceState: ({
cfg,
accountId,
}: {
cfg: OpenClawConfig;
accountId?: string | null;
action: "approve";
}) => {
const enabled = isNativeDeliveryEnabled({ cfg, accountId });
return enabled ? { kind: "enabled" } : { kind: "disabled" };
},
describeExecApprovalSetup: ({ accountId }: { accountId?: string | null }) => {
const prefix =
accountId && accountId !== "default"
? `channels.qqbot.accounts.${accountId}`
: "channels.qqbot";
return `QQBot native exec approvals are enabled by default. To restrict who can approve, configure \`${prefix}.execApprovals.approvers\` with QQ user OpenIDs.`;
},
delivery: {
hasConfiguredDmRoute: () => true,
shouldSuppressForwardingFallback: (input) => {
const channel = normalizeOptionalString(input.target?.channel);
if (channel !== "qqbot") {
return false;
}
const accountId =
normalizeOptionalString(input.target?.accountId) ??
normalizeOptionalString(input.request?.request?.turnSourceAccountId);
const result = isNativeDeliveryEnabled({ cfg: input.cfg, accountId });
getBridgeLogger().debug?.(
`[qqbot:approval] shouldSuppressForwardingFallback channel=${channel} accountId=${accountId}${result}`,
);
return result;
},
},
native: {
describeDeliveryCapabilities: ({ cfg, accountId }) => ({
enabled: isNativeDeliveryEnabled({ cfg, accountId }),
preferredSurface: "origin" as const,
supportsOriginSurface: true,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: false,
}),
resolveOriginTarget: ({ request }) => {
const sessionKey = request.request.sessionKey ?? null;
const turnSourceTo = request.request.turnSourceTo ?? null;
const target = resolveApprovalTarget(sessionKey, turnSourceTo);
if (target) {
return { to: `${target.type}:${target.id}` };
}
const sessionConversation = resolveApprovalRequestSessionConversation({
request: request as never,
channel: "qqbot",
bundledFallback: true,
});
if (sessionConversation?.id) {
const kind = sessionConversation.kind === "group" ? "group" : "c2c";
return { to: `${kind}:${sessionConversation.id}` };
}
return null;
},
},
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) => {
const result = isNativeDeliveryEnabled({ cfg, accountId });
getBridgeLogger().debug?.(
`[qqbot:approval] nativeRuntime.isConfigured accountId=${accountId}${result}`,
);
return result;
},
shouldHandle: ({ cfg, accountId, request }) => {
const result = shouldHandleRequest({
cfg,
accountId,
request: request as never,
});
getBridgeLogger().debug?.(
`[qqbot:approval] nativeRuntime.shouldHandle accountId=${accountId}${result}`,
);
return result;
},
load: async () => {
// Ensure PlatformAdapter is registered before handler-runtime uses
// getPlatformAdapter(). When the framework spawns the approval handler
// outside the qqbot gateway startAccount context, channel.ts's
// side-effect `import "./bridge/bootstrap.js"` may not have run yet.
ensurePlatformAdapter();
return (await import("./handler-runtime.js"))
.qqbotApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter;
},
}),
});
}
export const qqbotApprovalCapability = createQQBotApprovalCapability();
export const qqbotNativeApprovalAdapter = splitChannelApprovalCapability(qqbotApprovalCapability);
let _cachedCapability: ChannelApprovalCapability | undefined;
export function getQQBotApprovalCapability(): ChannelApprovalCapability {
_cachedCapability ??= qqbotApprovalCapability;
return _cachedCapability;
}

View File

@@ -0,0 +1,204 @@
/**
* QQ Bot Native Approval Runtime Adapter.
*
* Implements the framework's ChannelApprovalNativeRuntimeSpec to deliver
* approval requests as QQ messages with inline keyboard buttons and handle
* resolved/expired lifecycle events.
*
* This file is lazily imported by capability.ts to avoid loading
* heavy dependencies on the critical startup path.
*/
import type { ChannelApprovalNativeRuntimeSpec } from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime";
import {
buildExecApprovalText,
buildPluginApprovalText,
buildApprovalKeyboard,
resolveApprovalTarget,
type ExecApprovalRequest,
type PluginApprovalRequest,
} from "../../engine/approval/index.js";
import { getMessageApi, accountToCreds } from "../../engine/messaging/sender.js";
import type { ChatScope, InlineKeyboard, MessageResponse } from "../../engine/types.js";
import { ensurePlatformAdapter } from "../bootstrap.js";
import {
matchesQQBotApprovalAccount,
resolveQQBotExecApprovalConfig,
isQQBotExecApprovalClientEnabled,
shouldHandleQQBotExecApprovalRequest,
} from "../../exec-approvals.js";
import { resolveQQBotAccount } from "../config.js";
import { getBridgeLogger } from "../logger.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type QQBotPendingEntry = {
messageId?: string;
targetType: ChatScope;
targetId: string;
};
type QQBotPendingPayload = {
text: string;
keyboard: InlineKeyboard;
};
function isExecRequest(request: ApprovalRequest): request is ExecApprovalRequest {
return "expiresAtMs" in request;
}
function resolveQQTarget(request: ApprovalRequest): { type: ChatScope; id: string } | null {
const sessionConversation = resolveApprovalRequestSessionConversation({
request: request as never,
channel: "qqbot",
bundledFallback: true,
});
const sessionKey = request.request.sessionKey ?? null;
const turnSourceTo = request.request.turnSourceTo ?? null;
const target = resolveApprovalTarget(sessionKey, turnSourceTo);
if (target) {
return target;
}
if (sessionConversation?.id) {
const kind = sessionConversation.kind;
const chatScope: ChatScope = kind === "group" ? "group" : "c2c";
return { type: chatScope, id: sessionConversation.id };
}
return null;
}
type QQBotPreparedTarget = { type: ChatScope; id: string };
const qqbotApprovalRuntimeSpec: ChannelApprovalNativeRuntimeSpec<
QQBotPendingPayload,
QQBotPreparedTarget,
QQBotPendingEntry
> = {
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: ({ cfg, accountId }) => {
if (resolveQQBotExecApprovalConfig({ cfg, accountId }) !== undefined) {
const result = isQQBotExecApprovalClientEnabled({ cfg, accountId });
getBridgeLogger().debug?.(
`[qqbot:approval-runtime] isConfigured(profile) accountId=${accountId}${result}`,
);
return result;
}
const account = resolveQQBotAccount(cfg, accountId ?? undefined);
const result = account.enabled && account.secretSource !== "none";
getBridgeLogger().debug?.(
`[qqbot:approval-runtime] isConfigured(fallback) accountId=${accountId} enabled=${account.enabled} secretSource=${account.secretSource}${result}`,
);
return result;
},
shouldHandle: ({ cfg, accountId, request }) => {
if (resolveQQBotExecApprovalConfig({ cfg, accountId }) !== undefined) {
const result = shouldHandleQQBotExecApprovalRequest({ cfg, accountId, request });
getBridgeLogger().debug?.(
`[qqbot:approval-runtime] shouldHandle(profile) accountId=${accountId}${result}`,
);
return result;
}
const target = resolveQQTarget(request as ApprovalRequest);
if (target === null) {
getBridgeLogger().debug?.(
`[qqbot:approval-runtime] shouldHandle(fallback) accountId=${accountId} target=null → false`,
);
return false;
}
const accountMatches = matchesQQBotApprovalAccount({
cfg,
accountId,
request: request as ApprovalRequest,
});
getBridgeLogger().debug?.(
`[qqbot:approval-runtime] shouldHandle(fallback) accountId=${accountId} target=${JSON.stringify(
target,
)} accountMatches=${accountMatches}${accountMatches}`,
);
return accountMatches;
},
},
presentation: {
buildPendingPayload: ({ request, view }) => {
const req = request as ApprovalRequest;
const text = isExecRequest(req) ? buildExecApprovalText(req) : buildPluginApprovalText(req);
const keyboard = buildApprovalKeyboard(
req.id,
view.actions.map((action) => action.decision),
);
getBridgeLogger().debug?.(
`[qqbot:approval-runtime] buildPendingPayload requestId=${req.id} kind=${
isExecRequest(req) ? "exec" : "plugin"
}`,
);
return { text, keyboard };
},
buildResolvedResult: () => ({ kind: "leave" }),
buildExpiredResult: () => ({ kind: "leave" }),
},
transport: {
prepareTarget: ({ request }) => {
const target = resolveQQTarget(request as ApprovalRequest);
getBridgeLogger().debug?.(
`[qqbot:approval-runtime] prepareTarget requestId=${request.id} target=${JSON.stringify(target)}`,
);
if (!target) {
return null;
}
return { target, dedupeKey: `${target.type}:${target.id}` };
},
deliverPending: async ({ cfg, accountId, preparedTarget, pendingPayload }) => {
// Ensure the PlatformAdapter is registered — resolveQQBotAccount below
// calls getPlatformAdapter() to resolve secret inputs.
ensurePlatformAdapter();
const account = resolveQQBotAccount(cfg, accountId ?? undefined);
const creds = accountToCreds(account);
const messageApi = getMessageApi(account.appId);
let result: MessageResponse;
try {
getBridgeLogger().debug?.(
`[qqbot:approval-runtime] deliverPending accountId=${accountId} target=${preparedTarget.type}:${preparedTarget.id}`,
);
result = await messageApi.sendMessage(
preparedTarget.type,
preparedTarget.id,
pendingPayload.text,
creds,
{ inlineKeyboard: pendingPayload.keyboard },
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to send approval message to ${preparedTarget.type}:${preparedTarget.id}: ${msg}`,
{ cause: err },
);
}
getBridgeLogger().debug?.(
`[qqbot:approval-runtime] deliverPending success accountId=${accountId} messageId=${result.id ?? ""}`,
);
return {
messageId: result.id,
targetType: preparedTarget.type,
targetId: preparedTarget.id,
};
},
},
};
export const qqbotApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter(
qqbotApprovalRuntimeSpec,
) as unknown as ChannelApprovalNativeRuntimeAdapter;

View File

@@ -0,0 +1,135 @@
/**
* Bootstrap the PlatformAdapter for the built-in version.
*
* ## Design
*
* The adapter is registered via two complementary mechanisms:
*
* 1. **Factory registration** (`registerPlatformAdapterFactory`) — a lightweight
* callback stored in `adapter/index.ts` that is invoked lazily by
* `getPlatformAdapter()` on first access. This guarantees the adapter is
* available regardless of module evaluation order or bundler chunk splitting.
*
* 2. **Eager side-effect** (`ensurePlatformAdapter()`) — called at module
* evaluation time when `channel.ts` imports this file. Provides the adapter
* immediately for code that runs synchronously during startup.
*
* Heavy async-only dependencies (`media-runtime`, `config-runtime`,
* `approval-gateway-runtime`) are lazy-imported inside each async method body
* so that this module evaluates with minimal overhead.
*
* Synchronous dependencies (`secret-input`, `temp-path`) are imported
* statically at the top level so they work reliably in both production and
* vitest (which resolves bare specifiers via `resolve.alias`, not Node CJS).
*/
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import {
registerPlatformAdapter,
registerPlatformAdapterFactory,
hasPlatformAdapter,
type PlatformAdapter,
} from "../engine/adapter/index.js";
import type { FetchMediaOptions, FetchMediaResult } from "../engine/adapter/types.js";
import { getBridgeLogger } from "./logger.js";
function createBuiltinAdapter(): PlatformAdapter {
return {
async validateRemoteUrl(_url: string, _options?: { allowPrivate?: boolean }): Promise<void> {
// Built-in version delegates SSRF validation to fetchRemoteMedia's ssrfPolicy.
},
async resolveSecret(value): Promise<string | undefined> {
if (typeof value === "string") {
return value || undefined;
}
return undefined;
},
async downloadFile(url: string, destDir: string, filename?: string): Promise<string> {
const { fetchRemoteMedia } = await import("openclaw/plugin-sdk/media-runtime");
const result = await fetchRemoteMedia({ url, filePathHint: filename });
const fs = await import("node:fs");
const path = await import("node:path");
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const destPath = path.join(destDir, filename ?? "download");
fs.writeFileSync(destPath, result.buffer);
return destPath;
},
async fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
const { fetchRemoteMedia } = await import("openclaw/plugin-sdk/media-runtime");
const result = await fetchRemoteMedia({
url: options.url,
filePathHint: options.filePathHint,
maxBytes: options.maxBytes,
maxRedirects: options.maxRedirects,
ssrfPolicy: options.ssrfPolicy,
requestInit: options.requestInit,
});
return { buffer: result.buffer, fileName: result.fileName };
},
getTempDir(): string {
return resolvePreferredOpenClawTmpDir();
},
hasConfiguredSecret(value: unknown): boolean {
return hasConfiguredSecretInput(value);
},
normalizeSecretInputString(value: unknown): string | undefined {
return normalizeSecretInputString(value) ?? undefined;
},
resolveSecretInputString(params: { value: unknown; path: string }): string | undefined {
return normalizeResolvedSecretInputString(params) ?? undefined;
},
async resolveApproval(approvalId: string, decision: string): Promise<boolean> {
try {
const { loadConfig } = await import("openclaw/plugin-sdk/config-runtime");
const { resolveApprovalOverGateway } =
await import("openclaw/plugin-sdk/approval-gateway-runtime");
const cfg = loadConfig();
await resolveApprovalOverGateway({
cfg,
approvalId,
decision: decision as "allow-once" | "allow-always" | "deny",
clientDisplayName: "QQBot Approval Handler",
});
return true;
} catch (err) {
getBridgeLogger().error(`[qqbot] resolveApproval failed: ${String(err)}`);
return false;
}
},
};
}
/**
* Ensure the built-in PlatformAdapter is registered.
*
* Safe to call multiple times — only registers on the first invocation.
* Exported for backward compatibility with code that calls it explicitly.
*/
export function ensurePlatformAdapter(): void {
if (!hasPlatformAdapter()) {
registerPlatformAdapter(createBuiltinAdapter());
}
}
// Register the adapter factory so getPlatformAdapter() can lazy-init even when
// this module's side-effect import hasn't executed yet (bundler reordering,
// framework-spawned approval handlers, etc.).
registerPlatformAdapterFactory(createBuiltinAdapter);
// Also eagerly register for the normal startup path (imported by channel.ts).
ensurePlatformAdapter();

View File

@@ -0,0 +1,18 @@
/**
* Orchestrator for the QQBot `registerFull` hook.
*
* Keeping this function in `src/bridge/` (rather than inline in the
* `extensions/qqbot/index.ts` channel-entry contract) lets the composition
* be unit-tested and aligns with the layering described in the double-repo
* migration spec, where bridge-layer composition code is expected to live
* under `src/bridge/` (or `src/bootstrap/` in the standalone variant).
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { registerQQBotFrameworkCommands } from "./commands/framework-registration.js";
import { registerQQBotTools } from "./tools/index.js";
export function registerQQBotFull(api: OpenClawPluginApi): void {
registerQQBotTools(api);
registerQQBotFrameworkCommands(api);
}

View File

@@ -0,0 +1,60 @@
/**
* Adapter that builds a `SlashCommandContext` from a framework
* `PluginCommandContext`.
*
* Framework-registered commands enter the plugin through
* `api.registerCommand`, which surfaces a `PluginCommandContext` shape. Our
* engine-side command registry, however, is driven by `SlashCommandContext`.
* This adapter bridges the two so handlers authored against the engine
* registry can be reused unchanged on the framework command surface.
*/
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import type { SlashCommandContext } from "../../engine/commands/slash-commands.js";
import type { ResolvedQQBotAccount } from "../../types.js";
import type { QQBotFromParseResult } from "./from-parser.js";
/**
* Default queue snapshot used for framework-registered commands.
*
* Framework-side command dispatch runs outside the per-sender queue, so
* handlers observe an empty snapshot by design.
*/
const DEFAULT_QUEUE_SNAPSHOT = {
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 10,
senderPending: 0,
} as const;
export interface BuildFrameworkSlashContextInput {
ctx: PluginCommandContext;
account: ResolvedQQBotAccount;
from: QQBotFromParseResult;
commandName: string;
}
export function buildFrameworkSlashContext({
ctx,
account,
from,
commandName,
}: BuildFrameworkSlashContextInput): SlashCommandContext {
const args = ctx.args ?? "";
const rawContent = args ? `/${commandName} ${args}` : `/${commandName}`;
return {
type: from.msgType,
senderId: ctx.senderId ?? "",
messageId: "",
eventTimestamp: new Date().toISOString(),
receivedAt: Date.now(),
rawContent,
args,
accountId: account.accountId,
appId: account.appId,
accountConfig: account.config as unknown as Record<string, unknown>,
commandAuthorized: true,
queueSnapshot: { ...DEFAULT_QUEUE_SNAPSHOT },
};
}

View File

@@ -0,0 +1,47 @@
/**
* Register all `requireAuth: true` slash commands with the framework via
* `api.registerCommand`.
*
* Routing through the framework lets `resolveCommandAuthorization()` apply
* `commands.allowFrom.qqbot` precedence and the `qqbot:` prefix normalization
* before any QQBot command handler runs.
*
* This module is intentionally thin: it wires the engine-side command
* registry (`getFrameworkCommands`) to the framework registration surface via
* the three single-responsibility helpers in this directory.
*/
import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import { getFrameworkCommands } from "../../engine/commands/slash-commands-impl.js";
import { resolveQQBotAccount } from "../config.js";
import { buildFrameworkSlashContext } from "./framework-context-adapter.js";
import { parseQQBotFrom } from "./from-parser.js";
import { dispatchFrameworkSlashResult } from "./result-dispatcher.js";
export function registerQQBotFrameworkCommands(api: OpenClawPluginApi): void {
for (const cmd of getFrameworkCommands()) {
api.registerCommand({
name: cmd.name,
description: cmd.description,
requireAuth: true,
acceptsArgs: true,
handler: async (ctx: PluginCommandContext) => {
const from = parseQQBotFrom(ctx.from);
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
const slashCtx = buildFrameworkSlashContext({
ctx,
account,
from,
commandName: cmd.name,
});
const result = await cmd.handler(slashCtx);
return await dispatchFrameworkSlashResult({
result,
account,
from,
logger: api.logger,
});
},
});
}
}

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { parseQQBotFrom } from "./from-parser.js";
describe("parseQQBotFrom", () => {
it("parses a group from string", () => {
expect(parseQQBotFrom("qqbot:group:ABCDEF")).toEqual({
msgType: "group",
targetType: "group",
targetId: "ABCDEF",
});
});
it("parses a channel prefix into the guild msgType", () => {
expect(parseQQBotFrom("qqbot:channel:123")).toEqual({
msgType: "guild",
targetType: "channel",
targetId: "123",
});
});
it("parses a dm prefix", () => {
expect(parseQQBotFrom("qqbot:dm:456")).toEqual({
msgType: "dm",
targetType: "dm",
targetId: "456",
});
});
it("parses a c2c prefix", () => {
expect(parseQQBotFrom("qqbot:c2c:user-1")).toEqual({
msgType: "c2c",
targetType: "c2c",
targetId: "user-1",
});
});
it("is case-insensitive on the qqbot: prefix", () => {
expect(parseQQBotFrom("QQBOT:group:gid")).toEqual({
msgType: "group",
targetType: "group",
targetId: "gid",
});
});
it("handles target ids that contain a colon", () => {
expect(parseQQBotFrom("qqbot:group:GROUP:ID")).toEqual({
msgType: "group",
targetType: "group",
targetId: "GROUP:ID",
});
});
it("falls back to c2c for unknown prefixes", () => {
expect(parseQQBotFrom("qqbot:unknown:abc")).toEqual({
msgType: "c2c",
targetType: "c2c",
targetId: "abc",
});
});
it("falls back to c2c for missing from", () => {
expect(parseQQBotFrom(undefined)).toEqual({
msgType: "c2c",
targetType: "c2c",
targetId: "",
});
expect(parseQQBotFrom(null)).toEqual({
msgType: "c2c",
targetType: "c2c",
targetId: "",
});
expect(parseQQBotFrom("")).toEqual({
msgType: "c2c",
targetType: "c2c",
targetId: "",
});
});
it("treats a bare prefix (no colon) as c2c with that id", () => {
expect(parseQQBotFrom("qqbot:c2c")).toEqual({
msgType: "c2c",
targetType: "c2c",
targetId: "c2c",
});
});
});

View File

@@ -0,0 +1,60 @@
/**
* Parse the framework `PluginCommandContext.from` string into the QQBot
* message type and send target.
*
* The framework passes `from` in the form `qqbot:<kind>:<id>` (case-insensitive
* prefix). We split that string once and map `<kind>` into the engine-side
* `SlashCommandContext.type` enum and the outbound `MediaTargetContext.targetType`
* enum. Both enums diverge only for guild/channel, so we keep two lookup
* tables to avoid the nested ternary chain the previous implementation used.
*/
export interface QQBotFromParseResult {
/** Message type consumed by SlashCommandContext.type. */
msgType: "c2c" | "guild" | "dm" | "group";
/** Target type consumed by MediaTargetContext.targetType. */
targetType: "c2c" | "group" | "channel" | "dm";
/** Raw target id (everything after the first `:`). */
targetId: string;
}
type FromKind = "c2c" | "group" | "channel" | "dm";
const MSG_TYPE_MAP: Record<FromKind, QQBotFromParseResult["msgType"]> = {
c2c: "c2c",
dm: "dm",
group: "group",
channel: "guild",
};
const TARGET_TYPE_MAP: Record<FromKind, QQBotFromParseResult["targetType"]> = {
c2c: "c2c",
dm: "dm",
group: "group",
channel: "channel",
};
function isFromKind(value: string): value is FromKind {
return value === "c2c" || value === "dm" || value === "group" || value === "channel";
}
/**
* Parse `ctx.from` into the structured fields the QQBot bridge expects.
*
* Unknown or missing prefixes fall back to c2c. The remainder after the first
* `:` is returned verbatim as the target id, matching what the previous inline
* implementation did.
*/
export function parseQQBotFrom(from: string | undefined | null): QQBotFromParseResult {
const stripped = (from ?? "").replace(/^qqbot:/iu, "");
const colonIdx = stripped.indexOf(":");
const rawPrefix = colonIdx === -1 ? stripped : stripped.slice(0, colonIdx);
const targetId = colonIdx === -1 ? stripped : stripped.slice(colonIdx + 1);
const kind: FromKind = isFromKind(rawPrefix) ? rawPrefix : "c2c";
return {
msgType: MSG_TYPE_MAP[kind],
targetType: TARGET_TYPE_MAP[kind],
targetId,
};
}

View File

@@ -0,0 +1,76 @@
/**
* Dispatch a slash command result produced on the framework command surface.
*
* Slash command handlers return one of:
* 1. a plain string (text reply),
* 2. a `SlashCommandFileResult` (text plus a local file to upload), or
* 3. null / unexpected value (we surface a generic warning).
*
* This module isolates the text/file branching so the framework registration
* layer stays declarative and so the file-send side effect has a single
* location where logging and error handling live.
*/
import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
import type { SlashCommandResult } from "../../engine/commands/slash-commands.js";
import { sendDocument, type MediaTargetContext } from "../../engine/messaging/outbound.js";
import type { ResolvedQQBotAccount } from "../../types.js";
import type { QQBotFromParseResult } from "./from-parser.js";
const UNEXPECTED_RESULT_TEXT = "⚠️ 命令返回了意外结果。";
export interface FrameworkSlashReply {
text: string;
}
export interface DispatchFrameworkSlashResultInput {
result: SlashCommandResult;
account: ResolvedQQBotAccount;
from: QQBotFromParseResult;
logger?: PluginLogger;
}
function hasFilePath(value: unknown): value is { text: string; filePath: string } {
return (
typeof value === "object" &&
value !== null &&
"filePath" in value &&
typeof (value as { filePath: unknown }).filePath === "string"
);
}
function buildMediaTarget(
account: ResolvedQQBotAccount,
from: QQBotFromParseResult,
): MediaTargetContext {
return {
targetType: from.targetType,
targetId: from.targetId,
account: account as unknown as MediaTargetContext["account"],
};
}
export async function dispatchFrameworkSlashResult({
result,
account,
from,
logger,
}: DispatchFrameworkSlashResultInput): Promise<FrameworkSlashReply> {
if (typeof result === "string") {
return { text: result };
}
if (hasFilePath(result)) {
const mediaCtx = buildMediaTarget(account, from);
try {
await sendDocument(mediaCtx, result.filePath, {
allowQQBotDataDownloads: true,
});
} catch (err) {
logger?.warn(`framework slash file send failed: ${String(err)}`);
}
return { text: result.text };
}
return { text: UNEXPECTED_RESULT_TEXT };
}

View File

@@ -1,77 +1,41 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
applyAccountNameToChannelSection,
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk/channel-plugin-common";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import { applyAccountNameToChannelSection } from "openclaw/plugin-sdk/setup";
} from "openclaw/plugin-sdk/core";
import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup";
import {
DEFAULT_ACCOUNT_ID,
applyQQBotAccountConfig,
describeAccount as engineDescribeAccount,
formatAllowFrom as engineFormatAllowFrom,
isAccountConfigured as engineIsAccountConfigured,
} from "../engine/config/resolve.js";
import {
applySetupAccountConfig as engineApplySetupAccountConfig,
validateSetupInput as engineValidateSetupInput,
} from "../engine/config/setup-logic.js";
import { normalizeLowercaseStringOrEmpty } from "../engine/utils/string-normalize.js";
import type { ResolvedQQBotAccount } from "../types.js";
import {
listQQBotAccountIds,
resolveDefaultQQBotAccountId,
resolveQQBotAccount,
} from "./config.js";
import type { ResolvedQQBotAccount } from "./types.js";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function normalizeStringifiedOptionalString(
value: string | number | null | undefined,
): string | undefined {
if (value == null) {
return undefined;
}
const normalized = String(value).trim();
return normalized || undefined;
}
export const qqbotMeta = {
id: "qqbot",
label: "QQ Bot",
selectionLabel: "QQ Bot",
selectionLabel: "QQ Bot (Bot API)",
docsPath: "/channels/qqbot",
blurb: "Connect to QQ via official QQ Bot API",
order: 50,
} as const;
function parseQQBotInlineToken(token: string): { appId: string; clientSecret: string } | null {
const colonIdx = token.indexOf(":");
if (colonIdx <= 0 || colonIdx === token.length - 1) {
return null;
}
const appId = token.slice(0, colonIdx).trim();
const clientSecret = token.slice(colonIdx + 1).trim();
if (!appId || !clientSecret) {
return null;
}
return { appId, clientSecret };
}
export function validateQQBotSetupInput(params: {
accountId: string;
input: ChannelSetupInput;
}): string | null {
const { accountId, input } = params;
if (!input.token && !input.tokenFile && !input.useEnv) {
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
}
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "QQBot --use-env only supports the default account";
}
if (input.token && !parseQQBotInlineToken(input.token)) {
return "QQBot --token must be in appId:clientSecret format";
}
return null;
return engineValidateSetupInput(params.accountId, params.input);
}
export function applyQQBotSetupAccountConfig(params: {
@@ -79,61 +43,25 @@ export function applyQQBotSetupAccountConfig(params: {
accountId: string;
input: ChannelSetupInput;
}): OpenClawConfig {
if (params.input.useEnv && params.accountId !== DEFAULT_ACCOUNT_ID) {
return params.cfg;
}
let appId = "";
let clientSecret = "";
if (params.input.token) {
const parsed = parseQQBotInlineToken(params.input.token);
if (!parsed) {
return params.cfg;
}
appId = parsed.appId;
clientSecret = parsed.clientSecret;
}
if (!appId && !params.input.tokenFile && !params.input.useEnv) {
return params.cfg;
}
return applyQQBotAccountConfig(params.cfg, params.accountId, {
appId,
clientSecret,
clientSecretFile: params.input.tokenFile,
name: params.input.name,
});
return engineApplySetupAccountConfig(
params.cfg as unknown as Record<string, unknown>,
params.accountId,
params.input,
) as OpenClawConfig;
}
export function isQQBotConfigured(account: ResolvedQQBotAccount | undefined): boolean {
return Boolean(
account?.appId &&
(Boolean(account?.clientSecret) ||
hasConfiguredSecretInput(account?.config?.clientSecret) ||
Boolean(account?.config?.clientSecretFile?.trim())),
);
return engineIsAccountConfigured(account as never);
}
export function describeQQBotAccount(account: ResolvedQQBotAccount | undefined) {
return {
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
name: account?.name,
enabled: account?.enabled ?? false,
configured: isQQBotConfigured(account),
tokenSource: account?.secretSource,
};
return engineDescribeAccount(account as never);
}
export function formatQQBotAllowFrom(params: {
allowFrom: Array<string | number> | undefined | null;
}): string[] {
return (params.allowFrom ?? [])
.map((entry) => normalizeStringifiedOptionalString(entry))
.filter((entry): entry is string => Boolean(entry))
.map((entry) => entry.replace(/^qqbot:/i, ""))
.map((entry) => entry.toUpperCase());
return engineFormatAllowFrom(params.allowFrom);
}
export const qqbotConfigAdapter = {

View File

@@ -0,0 +1,104 @@
import fs from "node:fs";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { getPlatformAdapter } from "../engine/adapter/index.js";
import {
DEFAULT_ACCOUNT_ID as ENGINE_DEFAULT_ACCOUNT_ID,
applyAccountConfig,
listAccountIds,
resolveAccountBase,
resolveDefaultAccountId,
} from "../engine/config/resolve.js";
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "../types.js";
export const DEFAULT_ACCOUNT_ID = ENGINE_DEFAULT_ACCOUNT_ID;
interface QQBotChannelConfig extends QQBotAccountConfig {
accounts?: Record<string, QQBotAccountConfig>;
defaultAccount?: string;
}
/** List all configured QQBot account IDs. */
export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
return listAccountIds(cfg as unknown as Record<string, unknown>);
}
/** Resolve the default QQBot account ID. */
export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
return resolveDefaultAccountId(cfg as unknown as Record<string, unknown>);
}
/** Resolve QQBot account config for runtime or setup flows. */
export function resolveQQBotAccount(
cfg: OpenClawConfig,
accountId?: string | null,
opts?: { allowUnresolvedSecretRef?: boolean },
): ResolvedQQBotAccount {
const raw = cfg as unknown as Record<string, unknown>;
const base = resolveAccountBase(raw, accountId);
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
const accountConfig: QQBotAccountConfig =
base.accountId === DEFAULT_ACCOUNT_ID
? (qqbot ?? {})
: (qqbot?.accounts?.[base.accountId] ?? {});
let clientSecret = "";
let secretSource: "config" | "file" | "env" | "none" = "none";
const clientSecretPath =
base.accountId === DEFAULT_ACCOUNT_ID
? "channels.qqbot.clientSecret"
: `channels.qqbot.accounts.${base.accountId}.clientSecret`;
const adapter = getPlatformAdapter();
if (adapter.hasConfiguredSecret(accountConfig.clientSecret)) {
clientSecret = opts?.allowUnresolvedSecretRef
? (adapter.normalizeSecretInputString(accountConfig.clientSecret) ?? "")
: (adapter.resolveSecretInputString({
value: accountConfig.clientSecret,
path: clientSecretPath,
}) ?? "");
secretSource = "config";
} else if (accountConfig.clientSecretFile) {
try {
clientSecret = fs.readFileSync(accountConfig.clientSecretFile, "utf8").trim();
secretSource = "file";
} catch {
secretSource = "none";
}
} else if (process.env.QQBOT_CLIENT_SECRET && base.accountId === DEFAULT_ACCOUNT_ID) {
clientSecret = process.env.QQBOT_CLIENT_SECRET;
secretSource = "env";
}
return {
accountId: base.accountId,
name: accountConfig.name,
enabled: base.enabled,
appId: base.appId,
clientSecret,
secretSource,
systemPrompt: base.systemPrompt,
markdownSupport: base.markdownSupport,
config: accountConfig,
};
}
/** Apply account config updates back into the OpenClaw config object. */
export function applyQQBotAccountConfig(
cfg: OpenClawConfig,
accountId: string,
input: {
appId?: string;
clientSecret?: string;
clientSecretFile?: string;
name?: string;
},
): OpenClawConfig {
return applyAccountConfig(
cfg as unknown as Record<string, unknown>,
accountId,
input,
) as OpenClawConfig;
}

View File

@@ -0,0 +1,180 @@
/**
* Gateway entry point — thin shell that passes the PluginRuntime to
* core/gateway/gateway.ts.
*
* All module dependencies are imported directly by the core gateway.
* This file only provides the runtime object (which is dynamically
* injected by the framework at startup).
*/
import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
registerVersionResolver,
registerPluginVersion,
registerApproveRuntimeGetter,
} from "../engine/commands/slash-commands-impl.js";
import {
startGateway as coreStartGateway,
type CoreGatewayContext,
} from "../engine/gateway/gateway.js";
import type { GatewayAccount } from "../engine/gateway/types.js";
import { registerOutboundAudioAdapterFactory } from "../engine/messaging/outbound.js";
import { initSender, registerAccount } from "../engine/messaging/sender.js";
import type { EngineLogger } from "../engine/types.js";
import * as _audioModule from "../engine/utils/audio.js";
import { debugLog, debugError } from "../engine/utils/log.js";
import { registerTextChunker } from "../engine/utils/text-chunk.js";
import type { ResolvedQQBotAccount } from "../types.js";
import { ensurePlatformAdapter } from "./bootstrap.js";
import { setBridgeLogger } from "./logger.js";
import { resolveQQBotPluginVersion } from "./plugin-version.js";
import { getQQBotRuntime, getQQBotRuntimeForEngine } from "./runtime.js";
// Register framework SDK version resolver for core/ slash commands.
registerVersionResolver(resolveRuntimeServiceVersion);
// Inject plugin + framework versions into sender and into the slash
// command registry. The plugin version is read from this plugin's own
// `package.json` by walking up from this file's URL, which is robust
// against source-vs-dist layout differences.
const _pluginVersion = resolveQQBotPluginVersion(import.meta.url);
initSender({
pluginVersion: _pluginVersion,
openclawVersion: resolveRuntimeServiceVersion(),
});
registerPluginVersion(_pluginVersion);
// Register runtime getter for /bot-approve config management.
registerApproveRuntimeGetter(() => {
const rt = getQQBotRuntime();
return {
config: rt.config as {
loadConfig: () => Record<string, unknown>;
writeConfigFile: (cfg: unknown) => Promise<void>;
},
};
});
// Register audio adapter factory so outbound.sendMedia can lazy-init even
// when startGateway() hasn't run yet (bundler chunk-splitting scenario).
registerOutboundAudioAdapterFactory(() => {
// Use a synchronous require-like approach: the audio module should already
// be loaded by the time the factory is invoked (gateway has started).
// We import it at the top and reference it here.
return {
audioFileToSilkBase64: async (p: string, f?: string[]) =>
(await _audioModule.audioFileToSilkBase64(p, f)) ?? undefined,
isAudioFile: (p: string, m?: string) => _audioModule.isAudioFile(p, m),
shouldTranscodeVoice: (p: string) => _audioModule.shouldTranscodeVoice(p),
waitForFile: (p: string, ms?: number) => _audioModule.waitForFile(p, ms),
};
});
export interface GatewayContext {
account: ResolvedQQBotAccount;
abortSignal: AbortSignal;
cfg: OpenClawConfig;
onReady?: (data: unknown) => void;
onResumed?: (data: unknown) => void;
onError?: (error: Error) => void;
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
channelRuntime?: {
runtimeContexts: {
register: (params: {
channelId: string;
accountId: string;
capability: string;
context: unknown;
abortSignal?: AbortSignal;
}) => { dispose: () => void };
};
};
}
/**
* Start the Gateway WebSocket connection.
*
* Passes the PluginRuntime to core/gateway/gateway.ts.
* All other dependencies are imported directly by the core module.
*/
export async function startGateway(ctx: GatewayContext): Promise<void> {
// Ensure the PlatformAdapter is registered before any engine code runs.
// When the bundler splits code into separate chunks, bootstrap.ts's
// side-effect registration may not have executed yet at this point.
ensurePlatformAdapter();
const runtime = getQQBotRuntimeForEngine();
// Create per-account logger with auto [qqbot:{accountId}] prefix.
const accountLogger = createAccountLogger(ctx.log, ctx.account.accountId);
// Register into engine sender (per-appId logger + API config) and bridge layer.
registerAccount(ctx.account.appId, {
logger: accountLogger,
markdownSupport: ctx.account.markdownSupport,
});
setBridgeLogger(accountLogger);
registerTextChunker((text, limit) => runtime.channel.text.chunkMarkdownText(text, limit));
if (ctx.channelRuntime) {
accountLogger.info("Registering approval.native runtime context");
const lease = ctx.channelRuntime.runtimeContexts.register({
channelId: "qqbot",
accountId: ctx.account.accountId,
capability: "approval.native",
context: { account: ctx.account },
abortSignal: ctx.abortSignal,
});
accountLogger.info(`approval.native context registered (lease=${!!lease})`);
} else {
accountLogger.info("No channelRuntime — skipping approval.native registration");
}
const coreCtx: CoreGatewayContext = {
account: ctx.account as unknown as GatewayAccount,
abortSignal: ctx.abortSignal,
cfg: ctx.cfg,
onReady: ctx.onReady,
onResumed: ctx.onResumed,
onError: ctx.onError,
log: accountLogger,
runtime,
};
return coreStartGateway(coreCtx);
}
// ============ Per-account logger factory ============
/**
* Create an EngineLogger that auto-prefixes all messages with `[qqbot:{accountId}]`.
*
* Follows the WhatsApp pattern of per-connection loggers — each account gets
* its own logger instance so multi-account logs are automatically attributed.
*/
function createAccountLogger(
raw: GatewayContext["log"] | undefined,
accountId: string,
): EngineLogger {
const prefix = `[${accountId}]`;
if (!raw) {
return {
info: (msg) => debugLog(`${prefix} ${msg}`),
error: (msg) => debugError(`${prefix} ${msg}`),
warn: (msg) => debugError(`${prefix} ${msg}`),
debug: (msg) => debugLog(`${prefix} ${msg}`),
};
}
return {
info: (msg) => raw.info(`${prefix} ${msg}`),
error: (msg) => raw.error(`${prefix} ${msg}`),
warn: (msg) => raw.error(`${prefix} ${msg}`),
debug: (msg) => raw.debug?.(`${prefix} ${msg}`),
};
}

View File

@@ -0,0 +1,31 @@
/**
* Bridge-layer logger — holds the framework logger injected at gateway startup.
*
* Bridge modules (approval, tools, etc.) use this instead of `console.log` or
* engine's `debugLog` so that all logs flow through the OpenClaw log system.
*/
export interface BridgeLogger {
info: (msg: string) => void;
error: (msg: string) => void;
warn?: (msg: string) => void;
debug?: (msg: string) => void;
}
let _logger: BridgeLogger | null = null;
/** Register the framework logger. Called once in startGateway(). */
export function setBridgeLogger(logger: BridgeLogger): void {
_logger = logger;
}
/** Get the bridge logger. Falls back to console if not yet registered. */
export function getBridgeLogger(): BridgeLogger {
return (
_logger ?? {
info: (msg) => console.log(msg),
error: (msg) => console.error(msg),
debug: (msg) => console.log(msg),
}
);
}

View File

@@ -0,0 +1,146 @@
/**
* Tests for `resolveQQBotPluginVersion`.
*
* These exercise the directory-walk lookup against controlled fixture
* trees rather than the repo's real `package.json`, so the behaviour
* is deterministic regardless of where the test runs.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { QQBOT_PLUGIN_VERSION_UNKNOWN, resolveQQBotPluginVersion } from "./plugin-version.js";
/** Create a temp directory tree for an individual test and return its root. */
function createTempTree(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-pkg-version-"));
}
function writeJson(file: string, data: unknown): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(data), "utf8");
}
function fakeEntryFileUrl(dir: string): string {
const entryPath = path.join(dir, "gateway.ts");
// File need not exist for `fileURLToPath` to work; the resolver
// only uses its *parent directory* as the walk start point.
return pathToFileURL(entryPath).href;
}
describe("resolveQQBotPluginVersion", () => {
let tempRoots: string[] = [];
beforeEach(() => {
tempRoots = [];
});
afterEach(() => {
for (const root of tempRoots) {
fs.rmSync(root, { recursive: true, force: true });
}
});
function newTree(): string {
const root = createTempTree();
tempRoots.push(root);
return root;
}
it("returns the version from the nearest matching package.json", () => {
const root = newTree();
const pluginDir = path.join(root, "extensions", "qqbot");
const bridgeDir = path.join(pluginDir, "src", "bridge");
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/qqbot",
version: "2026.4.16",
});
fs.mkdirSync(bridgeDir, { recursive: true });
const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir));
expect(version).toBe("2026.4.16");
});
it("skips package.json files whose name field does not match", () => {
const root = newTree();
// Parent package.json belongs to the framework, not the plugin.
writeJson(path.join(root, "package.json"), {
name: "openclaw",
version: "9.9.9",
});
const pluginDir = path.join(root, "extensions", "qqbot");
const bridgeDir = path.join(pluginDir, "src", "bridge");
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/qqbot",
version: "2026.4.16",
});
fs.mkdirSync(bridgeDir, { recursive: true });
const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir));
// Must stop at the plugin manifest, never bubble up to the framework one.
expect(version).toBe("2026.4.16");
});
it("ignores manifests with unrelated name and returns unknown when no match is found", () => {
const root = newTree();
// Only an unrelated manifest exists up the tree.
writeJson(path.join(root, "package.json"), {
name: "some-other-package",
version: "1.0.0",
});
const startDir = path.join(root, "extensions", "qqbot", "src", "bridge");
fs.mkdirSync(startDir, { recursive: true });
const version = resolveQQBotPluginVersion(fakeEntryFileUrl(startDir));
expect(version).toBe(QQBOT_PLUGIN_VERSION_UNKNOWN);
});
it("returns unknown when no package.json exists above the start directory", () => {
const root = newTree();
const startDir = path.join(root, "extensions", "qqbot", "src", "bridge");
fs.mkdirSync(startDir, { recursive: true });
const version = resolveQQBotPluginVersion(fakeEntryFileUrl(startDir));
expect(version).toBe(QQBOT_PLUGIN_VERSION_UNKNOWN);
});
it("returns unknown when the matching manifest lacks a version field", () => {
const root = newTree();
const pluginDir = path.join(root, "extensions", "qqbot");
const bridgeDir = path.join(pluginDir, "src", "bridge");
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/qqbot",
// version intentionally missing
});
fs.mkdirSync(bridgeDir, { recursive: true });
const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir));
expect(version).toBe(QQBOT_PLUGIN_VERSION_UNKNOWN);
});
it("tolerates a malformed package.json and keeps walking", () => {
const root = newTree();
const pluginDir = path.join(root, "extensions", "qqbot");
const bridgeDir = path.join(pluginDir, "src", "bridge");
// Broken manifest at the expected plugin location.
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, "package.json"), "{ not valid json", "utf8");
// Valid matching manifest higher up (unusual layout but still resolvable).
writeJson(path.join(root, "package.json"), {
name: "@openclaw/qqbot",
version: "2026.9.9",
});
fs.mkdirSync(bridgeDir, { recursive: true });
const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir));
expect(version).toBe("2026.9.9");
});
});

View File

@@ -0,0 +1,102 @@
/**
* QQBot plugin version resolver.
*
* Reads the version field from this plugin's own `package.json` by
* walking up the directory tree starting from `import.meta.url` of the
* caller until a `package.json` whose `name` field matches the plugin
* package id is located.
*
* Why not a hardcoded relative path?
* - The source file can live at different depths depending on whether
* we run from raw sources (`src/bridge/gateway.ts`) or a future
* compiled output. Hardcoding `"../../package.json"` breaks as soon
* as the source layout changes, which is what caused the previous
* `vunknown` regression.
* - A `name` guard prevents accidentally reading the parent
* `openclaw/package.json` (the framework root) when the plugin
* lives inside the monorepo.
*
* The lookup is performed only once per process at startup, so the
* synchronous file I/O is negligible.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
/** `name` field in this plugin's `package.json`. */
const QQBOT_PLUGIN_PKG_NAME = "@openclaw/qqbot";
/** Sentinel used when the version cannot be resolved. */
export const QQBOT_PLUGIN_VERSION_UNKNOWN = "unknown";
/**
* Resolve the QQBot plugin version from `package.json`.
*
* @param startUrl — pass `import.meta.url` from the call site so the
* lookup begins at the caller's file regardless of where this helper
* itself lives. Falls back to this module's own location when omitted.
*/
export function resolveQQBotPluginVersion(startUrl?: string): string {
const entryUrl = startUrl ?? import.meta.url;
let dir: string;
try {
dir = path.dirname(fileURLToPath(entryUrl));
} catch {
return QQBOT_PLUGIN_VERSION_UNKNOWN;
}
const root = path.parse(dir).root;
while (dir && dir !== root) {
const candidate = path.join(dir, "package.json");
if (fs.existsSync(candidate)) {
const version = readQQBotVersionFromManifest(candidate);
if (version) {
return version;
}
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return QQBOT_PLUGIN_VERSION_UNKNOWN;
}
/**
* Read the `version` field from a `package.json` file and return it
* only when the manifest describes the QQBot plugin itself.
*
* Returning `null` for mismatched or malformed manifests lets the
* caller keep walking up the directory tree until the correct package
* boundary is located.
*/
function readQQBotVersionFromManifest(manifestPath: string): string | null {
let raw: string;
try {
raw = fs.readFileSync(manifestPath, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") {
return null;
}
const manifest = parsed as { name?: unknown; version?: unknown };
if (manifest.name !== QQBOT_PLUGIN_PKG_NAME) {
return null;
}
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
return null;
}
return manifest.version;
}

View File

@@ -0,0 +1,24 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { GatewayPluginRuntime } from "../engine/gateway/types.js";
import { setOpenClawVersion } from "../engine/messaging/sender.js";
const { setRuntime: _setRuntime, getRuntime: getQQBotRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "qqbot",
errorMessage: "QQBot runtime not initialized",
});
/** Set the QQBot runtime and inject the framework version into the User-Agent. */
function setQQBotRuntime(runtime: PluginRuntime): void {
_setRuntime(runtime);
// Inject the framework version into the User-Agent string (same as standalone).
setOpenClawVersion(runtime.version);
}
export { getQQBotRuntime, setQQBotRuntime };
/** Type-narrowed getter for engine/ modules that need GatewayPluginRuntime. */
export function getQQBotRuntimeForEngine(): GatewayPluginRuntime {
return getQQBotRuntime() as unknown as GatewayPluginRuntime;
}

View File

@@ -0,0 +1,151 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { applyQQBotAccountConfig, resolveQQBotAccount } from "../config.js";
type SetupPrompter = Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
type SetupRuntime = Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["runtime"];
function isQQBotAccountConfigured(cfg: OpenClawConfig, accountId: string): boolean {
const account = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true });
return Boolean(account.appId && account.clientSecret);
}
export async function detectQQBotConfigured(
cfg: OpenClawConfig,
accountId: string,
): Promise<boolean> {
return isQQBotAccountConfigured(cfg, accountId);
}
async function linkViaQrCode(params: {
cfg: OpenClawConfig;
accountId: string;
prompter: SetupPrompter;
runtime: SetupRuntime;
}): Promise<OpenClawConfig> {
try {
const { qrConnect } = await import("@tencent-connect/qqbot-connector");
const accounts: { appId: string; appSecret: string }[] = await qrConnect({
source: "openclaw",
});
if (accounts.length === 0) {
await params.prompter.note("未获取到任何 QQ Bot 账号信息。", "QQ Bot");
return params.cfg;
}
let next = params.cfg;
for (let i = 0; i < accounts.length; i++) {
const { appId, appSecret } = accounts[i];
// use current account id for first account, and use app id for subsequent accounts
const targetAccountId = i === 0 ? params.accountId : appId;
next = applyQQBotAccountConfig(next, targetAccountId, {
appId,
clientSecret: appSecret,
});
}
if (accounts.length === 1) {
params.runtime.log(`✔ QQ Bot 绑定成功!(AppID: ${accounts[0].appId})`);
} else {
const idList = accounts.map((a) => a.appId).join(", ");
params.runtime.log(`${accounts.length} 个 QQ Bot 绑定成功!(AppID: ${idList})`);
}
return next;
} catch (error) {
params.runtime.error(`QQ Bot 绑定失败: ${String(error)}`);
await params.prompter.note(
[
"绑定失败,您可以稍后手动配置。",
`文档: ${formatDocsLink("/channels/qqbot", "qqbot")}`,
].join("\n"),
"QQ Bot",
);
return params.cfg;
}
}
async function linkViaManualInput(params: {
cfg: OpenClawConfig;
accountId: string;
prompter: SetupPrompter;
}): Promise<OpenClawConfig> {
const appId = await params.prompter.text({
message: "请输入 QQ Bot AppID",
validate: (value: string) => (value.trim() ? undefined : "AppID 不能为空"),
});
const appSecret = await params.prompter.text({
message: "请输入 QQ Bot AppSecret",
validate: (value: string) => (value.trim() ? undefined : "AppSecret 不能为空"),
});
const next = applyQQBotAccountConfig(params.cfg, params.accountId, {
appId: appId.trim(),
clientSecret: appSecret.trim(),
});
await params.prompter.note("✔ QQ Bot 配置完成!", "QQ Bot");
return next;
}
export async function finalizeQQBotSetup(params: {
cfg: OpenClawConfig;
accountId: string;
forceAllowFrom: boolean;
prompter: SetupPrompter;
runtime: SetupRuntime;
}): Promise<{ cfg: OpenClawConfig }> {
const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID;
let next = params.cfg;
const configured = isQQBotAccountConfigured(next, accountId);
const mode = await params.prompter.select({
message: configured ? "QQ 已绑定,选择操作" : "选择 QQ 绑定方式",
options: [
{
value: "qr",
label: "扫码绑定(推荐)",
hint: "使用 QQ 扫描二维码自动完成绑定",
},
{
value: "manual",
label: "手动输入 QQ Bot AppID 和 AppSecret",
hint: "需到 QQ 开放平台 q.qq.com 查看",
},
{
value: "skip",
label: configured ? "保持当前配置" : "稍后配置",
},
],
});
if (mode === "qr") {
next = await linkViaQrCode({
cfg: next,
accountId,
prompter: params.prompter,
runtime: params.runtime,
});
} else if (mode === "manual") {
next = await linkViaManualInput({
cfg: next,
accountId,
prompter: params.prompter,
});
} else if (!configured) {
await params.prompter.note(
["您可以稍后运行以下命令重新选择 QQ Bot 进行配置:", " openclaw channels add"].join("\n"),
"QQ Bot",
);
}
return { cfg: next };
}

View File

@@ -0,0 +1,34 @@
import {
createStandardChannelSetupStatus,
setSetupChannelEnabled,
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { isAccountConfigured } from "../../engine/config/resolve.js";
import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js";
import { finalizeQQBotSetup } from "./finalize.js";
const channel = "qqbot" as const;
export const qqbotSetupWizard: ChannelSetupWizard = {
channel,
status: createStandardChannelSetupStatus({
channelLabel: "QQ Bot",
configuredLabel: "configured",
unconfiguredLabel: "needs AppID + AppSercet",
configuredHint: "configured",
unconfiguredHint: "needs AppID + AppSercet",
configuredScore: 1,
unconfiguredScore: 6,
resolveConfigured: ({ cfg, accountId }) =>
(accountId ? [accountId] : listQQBotAccountIds(cfg)).some((resolvedAccountId) => {
const account = resolveQQBotAccount(cfg, resolvedAccountId, {
allowUnresolvedSecretRef: true,
});
return isAccountConfigured(account as never);
}),
}),
credentials: [],
finalize: async ({ cfg, accountId, forceAllowFrom, prompter, runtime }) =>
await finalizeQQBotSetup({ cfg, accountId, forceAllowFrom, prompter, runtime }),
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};

View File

@@ -0,0 +1,62 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { getAccessToken } from "../../engine/messaging/sender.js";
import { ChannelApiSchema, executeChannelApi } from "../../engine/tools/channel-api.js";
import type { ChannelApiParams } from "../../engine/tools/channel-api.js";
import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js";
import { getBridgeLogger } from "../logger.js";
/**
* Register the QQ channel API proxy tool.
*
* The tool acts as an authenticated HTTP proxy for the QQ Open Platform
* channel APIs. Agents learn endpoint details from the skill docs and
* send requests through this proxy.
*/
export function registerChannelTool(api: OpenClawPluginApi): void {
const cfg = api.config;
if (!cfg) {
getBridgeLogger().debug?.("[qqbot-channel-api] No config available, skipping");
return;
}
const accountIds = listQQBotAccountIds(cfg);
if (accountIds.length === 0) {
getBridgeLogger().debug?.("[qqbot-channel-api] No QQBot accounts configured, skipping");
return;
}
const firstAccountId = accountIds[0];
const account = resolveQQBotAccount(cfg, firstAccountId);
if (!account.appId || !account.clientSecret) {
getBridgeLogger().debug?.("[qqbot-channel-api] Account not fully configured, skipping");
return;
}
api.registerTool(
{
name: "qqbot_channel_api",
label: "QQBot Channel API",
description:
"Authenticated HTTP proxy for QQ Open Platform channel APIs. " +
"Common endpoints: " +
"list guilds GET /users/@me/guilds | " +
"list channels GET /guilds/{guild_id}/channels | " +
"get channel GET /channels/{channel_id} | " +
"create channel POST /guilds/{guild_id}/channels | " +
"list members GET /guilds/{guild_id}/members?after=0&limit=100 | " +
"get member GET /guilds/{guild_id}/members/{user_id} | " +
"list threads GET /channels/{channel_id}/threads | " +
"create thread PUT /channels/{channel_id}/threads | " +
"create announce POST /guilds/{guild_id}/announces | " +
"create schedule POST /channels/{channel_id}/schedules. " +
"See the qqbot-channel skill for full endpoint details.",
parameters: ChannelApiSchema,
async execute(_toolCallId, params) {
const accessToken = await getAccessToken(account.appId, account.clientSecret);
return executeChannelApi(params as ChannelApiParams, { accessToken });
},
},
{ name: "qqbot_channel_api" },
);
}

View File

@@ -0,0 +1,18 @@
/**
* Aggregate QQBot plugin tool registrations.
*
* New tools should be added here rather than in the channel-entry contract
* file so that the plugin-level `index.ts` stays a pure declaration.
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { registerChannelTool } from "./channel.js";
import { registerRemindTool } from "./remind.js";
export { registerChannelTool } from "./channel.js";
export { registerRemindTool } from "./remind.js";
export function registerQQBotTools(api: OpenClawPluginApi): void {
registerChannelTool(api);
registerRemindTool(api);
}

View File

@@ -0,0 +1,30 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { RemindSchema, executeRemind } from "../../engine/tools/remind-logic.js";
import type { RemindParams } from "../../engine/tools/remind-logic.js";
import { getRequestContext } from "../../engine/utils/request-context.js";
export function registerRemindTool(api: OpenClawPluginApi): void {
api.registerTool(
{
name: "qqbot_remind",
label: "QQBot Reminder",
description:
"Create, list, and remove QQ reminders. " +
"Use simple parameters without manually building cron JSON.\n" +
"Create: action=add, content=message, time=schedule (to is optional, " +
"resolved automatically from the current conversation)\n" +
"List: action=list\n" +
"Remove: action=remove, jobId=job id from list\n" +
'Time examples: "5m", "1h", "0 8 * * *"',
parameters: RemindSchema,
async execute(_toolCallId, params) {
const ctx = getRequestContext();
return executeRemind(params as RemindParams, {
fallbackTo: ctx?.target,
fallbackAccountId: ctx?.accountId,
});
},
},
{ name: "qqbot_remind" },
);
}

View File

@@ -1,30 +0,0 @@
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./channel-config-shared.js";
import { qqbotChannelConfigSchema } from "./config-schema.js";
import { qqbotSetupWizard } from "./setup-surface.js";
import type { ResolvedQQBotAccount } from "./types.js";
export const qqbotBasePluginFields = {
id: "qqbot",
setupWizard: qqbotSetupWizard,
meta: {
...qqbotMeta,
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: false,
threads: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.qqbot"] },
configSchema: qqbotChannelConfigSchema,
config: {
...qqbotConfigAdapter,
},
setup: {
...qqbotSetupAdapterShared,
},
} satisfies Partial<ChannelPlugin<ResolvedQQBotAccount>> & {
id: "qqbot";
};

View File

@@ -1,5 +1,8 @@
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { qqbotBasePluginFields } from "./channel-base.js";
import "./bridge/bootstrap.js";
import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./bridge/config-shared.js";
import { qqbotSetupWizard } from "./bridge/setup/surface.js";
import { qqbotChannelConfigSchema } from "./config-schema.js";
import type { ResolvedQQBotAccount } from "./types.js";
/**
@@ -7,5 +10,24 @@ import type { ResolvedQQBotAccount } from "./types.js";
* and `openclaw configure` without pulling the full runtime dependencies.
*/
export const qqbotSetupPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
...qqbotBasePluginFields,
id: "qqbot",
setupWizard: qqbotSetupWizard,
meta: {
...qqbotMeta,
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: false,
threads: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.qqbot"] },
configSchema: qqbotChannelConfigSchema,
config: {
...qqbotConfigAdapter,
},
setup: {
...qqbotSetupAdapterShared,
},
};

View File

@@ -1,69 +1,103 @@
import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { initApiConfig } from "./api.js";
import { qqbotBasePluginFields } from "./channel-base.js";
import { DEFAULT_ACCOUNT_ID, resolveQQBotAccount } from "./config.js";
import { getQQBotRuntime } from "./runtime.js";
// Re-export text helpers so existing consumers of channel.ts are unaffected.
// The canonical definition lives in text-utils.ts to avoid a circular
// dependency: channel.ts → (dynamic) gateway.ts → outbound-deliver.ts → channel.ts.
export { chunkText, TEXT_CHUNK_LIMIT } from "./text-utils.js";
// Register the PlatformAdapter before any core/ module is used.
import "./bridge/bootstrap.js";
import { getQQBotApprovalCapability } from "./bridge/approval/capability.js";
import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./bridge/config-shared.js";
import {
applyQQBotAccountConfig,
DEFAULT_ACCOUNT_ID,
resolveQQBotAccount,
} from "./bridge/config.js";
import { getQQBotRuntime } from "./bridge/runtime.js";
import { qqbotSetupWizard } from "./bridge/setup/surface.js";
import { qqbotChannelConfigSchema } from "./config-schema.js";
import { loadCredentialBackup, saveCredentialBackup } from "./engine/config/credential-backup.js";
import { clearAccountCredentials } from "./engine/config/credentials.js";
import {
normalizeTarget as coreNormalizeTarget,
looksLikeQQBotTarget,
} from "./engine/messaging/target-parser.js";
// Re-export text helpers from core/.
export { chunkText, TEXT_CHUNK_LIMIT } from "./engine/utils/text-chunk.js";
import type { ResolvedQQBotAccount } from "./types.js";
type QQBotOutboundModule = typeof import("./outbound.js");
// Shared promise so concurrent multi-account startups serialize the dynamic
// import of the gateway module, avoiding an ESM circular-dependency race.
let _gatewayModulePromise: Promise<typeof import("./gateway.js")> | undefined;
let _outboundModulePromise: Promise<QQBotOutboundModule> | undefined;
function loadGatewayModule(): Promise<typeof import("./gateway.js")> {
_gatewayModulePromise ??= import("./gateway.js");
let _gatewayModulePromise: Promise<typeof import("./bridge/gateway.js")> | undefined;
function loadGatewayModule(): Promise<typeof import("./bridge/gateway.js")> {
_gatewayModulePromise ??= import("./bridge/gateway.js");
return _gatewayModulePromise;
}
function loadOutboundModule(): Promise<QQBotOutboundModule> {
_outboundModulePromise ??= import("./outbound.js");
return _outboundModulePromise;
const EXEC_APPROVAL_COMMAND_RE =
/\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(?:allow-once|allow-always|always|deny)\b/i;
function shouldSuppressLocalQQBotApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: { text?: string; channelData?: unknown };
hint?: { kind: "approval-pending" | "approval-resolved"; approvalKind: "exec" | "plugin" };
}): boolean {
if (params.hint?.kind !== "approval-pending" || params.hint.approvalKind !== "exec") {
return false;
}
const account = resolveQQBotAccount(params.cfg, params.accountId);
if (!account.enabled || account.secretSource === "none") {
return false;
}
if (getExecApprovalReplyMetadata(params.payload as never)) {
return true;
}
const text = typeof params.payload.text === "string" ? params.payload.text : "";
return EXEC_APPROVAL_COMMAND_RE.test(text);
}
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
...qqbotBasePluginFields,
id: "qqbot",
setupWizard: qqbotSetupWizard,
meta: {
...qqbotMeta,
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: false,
threads: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.qqbot"] },
configSchema: qqbotChannelConfigSchema,
config: {
...qqbotConfigAdapter,
/**
* Treat an account as configured when either the live config has
* credentials OR a recoverable credential backup exists. This mirrors
* the standalone plugin and lets the gateway survive a hot upgrade
* that wiped openclaw.json mid-flight.
*/
isConfigured: (account: ResolvedQQBotAccount | undefined) => {
if (qqbotConfigAdapter.isConfigured(account)) {
return true;
}
if (!account) {
return false;
}
const backup = loadCredentialBackup(account.accountId);
return Boolean(backup?.appId && backup?.clientSecret);
},
},
setup: {
...qqbotSetupAdapterShared,
},
approvalCapability: getQQBotApprovalCapability(),
messaging: {
/** Normalize common QQ Bot target formats into the canonical qqbot:... form. */
normalizeTarget: (target: string): string | undefined => {
const id = target.replace(/^qqbot:/i, "");
if (id.startsWith("c2c:") || id.startsWith("group:") || id.startsWith("channel:")) {
return `qqbot:${id}`;
}
const openIdHexPattern = /^[0-9a-fA-F]{32}$/;
if (openIdHexPattern.test(id)) {
return `qqbot:c2c:${id}`;
}
const openIdUuidPattern =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
if (openIdUuidPattern.test(id)) {
return `qqbot:c2c:${id}`;
}
return undefined;
},
normalizeTarget: coreNormalizeTarget,
targetResolver: {
/** Return true when the id looks like a QQ Bot target. */
looksLikeId: (id: string): boolean => {
if (/^qqbot:(c2c|group|channel):/i.test(id)) {
return true;
}
if (/^(c2c|group|channel):/i.test(id)) {
return true;
}
if (/^[0-9a-fA-F]{32}$/.test(id)) {
return true;
}
const openIdPattern =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
return openIdPattern.test(id);
},
looksLikeId: looksLikeQQBotTarget,
hint: "QQ Bot target format: qqbot:c2c:openid (direct) or qqbot:group:groupid (group)",
},
},
@@ -72,11 +106,20 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 5000,
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) =>
shouldSuppressLocalQQBotApprovalPrompt({
cfg,
accountId,
payload,
hint,
}),
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
// Ensure bridge/gateway.ts module-level registrations (audio adapter factory,
// platform adapter, etc.) have executed before engine code runs.
await loadGatewayModule();
const account = resolveQQBotAccount(cfg, accountId);
const { sendText } = await loadOutboundModule();
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
const result = await sendText({ to, text, accountId, replyToId, account });
const { sendText } = await import("./engine/messaging/outbound.js");
const result = await sendText({ to, text, accountId, replyToId, account: account as never });
return {
channel: "qqbot" as const,
messageId: result.messageId ?? "",
@@ -84,16 +127,17 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
// Same guard as sendText — ensure adapters are registered.
await loadGatewayModule();
const account = resolveQQBotAccount(cfg, accountId);
const { sendMedia } = await loadOutboundModule();
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
const { sendMedia } = await import("./engine/messaging/outbound.js");
const result = await sendMedia({
to,
text: text ?? "",
mediaUrl: mediaUrl ?? "",
accountId,
replyToId,
account,
account: account as never,
});
return {
channel: "qqbot" as const,
@@ -104,8 +148,39 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
},
gateway: {
startAccount: async (ctx) => {
const { account } = ctx;
const { abortSignal, log, cfg } = ctx;
let { account, cfg } = ctx;
const { abortSignal, log } = ctx;
// Recover credentials from the per-account backup if the live
// config is missing appId/secret (e.g. a hot-upgrade wiped
// openclaw.json). We only restore when both fields are empty so a
// user's intentional clear isn't silently undone.
if (!account.appId || !account.clientSecret) {
const backup = loadCredentialBackup(account.accountId);
if (backup?.appId && backup?.clientSecret) {
try {
const nextCfg = applyQQBotAccountConfig(cfg, account.accountId, {
appId: backup.appId,
clientSecret: backup.clientSecret,
});
const runtime = getQQBotRuntime();
const configApi = runtime.config as {
writeConfigFile: (cfg: OpenClawConfig) => Promise<void>;
};
await configApi.writeConfigFile(nextCfg);
cfg = nextCfg;
account = resolveQQBotAccount(nextCfg, account.accountId);
log?.info(
`[qqbot:${account.accountId}] Restored credentials from backup (appId=${account.appId})`,
);
} catch (err) {
log?.error(
`[qqbot:${account.accountId}] Failed to restore credentials from backup: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
// Serialize the dynamic import so concurrent multi-account startups
// do not hit an ESM circular-dependency race where the gateway chunk's
// transitive imports have not finished evaluating yet.
@@ -120,6 +195,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
abortSignal,
cfg,
log,
channelRuntime: ctx.channelRuntime as never,
onReady: () => {
log?.info(`[qqbot:${account.accountId}] Gateway ready`);
ctx.setStatus({
@@ -128,6 +204,23 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
connected: true,
lastConnectedAt: Date.now(),
});
// Snapshot credentials so we can recover from the next hot
// upgrade that might wipe openclaw.json mid-flight.
if (account.appId && account.clientSecret) {
saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
}
},
onResumed: () => {
log?.info(`[qqbot:${account.accountId}] Gateway resumed`);
ctx.setStatus({
...ctx.getStatus(),
running: true,
connected: true,
lastConnectedAt: Date.now(),
});
if (account.appId && account.clientSecret) {
saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
}
},
onError: (error) => {
log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
@@ -139,55 +232,20 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
});
},
logoutAccount: async ({ accountId, cfg }) => {
const nextCfg = { ...cfg } as OpenClawConfig;
const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
let cleared = false;
let changed = false;
const { nextCfg, cleared, changed } = clearAccountCredentials(
cfg as unknown as Record<string, unknown>,
accountId,
);
if (nextQQBot) {
const qqbot = nextQQBot as Record<string, unknown>;
if (accountId === DEFAULT_ACCOUNT_ID) {
if (qqbot.clientSecret) {
delete qqbot.clientSecret;
cleared = true;
changed = true;
}
if (qqbot.clientSecretFile) {
delete qqbot.clientSecretFile;
cleared = true;
changed = true;
}
}
const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId] as Record<string, unknown> | undefined;
if (entry && "clientSecret" in entry) {
delete entry.clientSecret;
cleared = true;
changed = true;
}
if (entry && "clientSecretFile" in entry) {
delete entry.clientSecretFile;
cleared = true;
changed = true;
}
if (entry && Object.keys(entry).length === 0) {
delete accounts[accountId];
changed = true;
}
}
}
if (changed && nextQQBot) {
nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
if (changed) {
const runtime = getQQBotRuntime();
const configApi = runtime.config as {
writeConfigFile: (cfg: OpenClawConfig) => Promise<void>;
};
await configApi.writeConfigFile(nextCfg);
await configApi.writeConfigFile(nextCfg as OpenClawConfig);
}
const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
const resolved = resolveQQBotAccount((changed ? nextCfg : cfg) as OpenClawConfig, accountId);
const loggedOut = resolved.secretSource === "none";
const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);

View File

@@ -1,4 +0,0 @@
import { asOptionalObjectRecord, readStringField } from "openclaw/plugin-sdk/text-runtime";
export const asRecord = asOptionalObjectRecord;
export const readString = readStringField;

View File

@@ -13,27 +13,17 @@ const AudioFormatPolicySchema = z
})
.optional();
const QQBotSpeechQueryParamsSchema = z.record(z.string(), z.string()).optional();
const QQBotSpeechProviderSchema = z.object({
enabled: z.boolean().optional(),
provider: z.string().optional(),
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
model: z.string().optional(),
});
const QQBotTtsSchema = QQBotSpeechProviderSchema.extend({
voice: z.string().optional(),
authStyle: z.enum(["bearer", "api-key"]).optional(),
queryParams: QQBotSpeechQueryParamsSchema,
speed: z.number().optional(),
})
const QQBotSttSchema = z
.object({
enabled: z.boolean().optional(),
provider: z.string().optional(),
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
model: z.string().optional(),
})
.strict()
.optional();
const QQBotSttSchema = QQBotSpeechProviderSchema.strict().optional();
const QQBotStreamingSchema = z
.union([
z.boolean(),
@@ -46,6 +36,20 @@ const QQBotStreamingSchema = z
])
.optional();
const QQBotExecApprovalsSchema = z
.object({
enabled: z.union([z.boolean(), z.literal("auto")]).optional(),
approvers: z.array(z.string()).optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
target: z.enum(["dm", "channel", "both"]).optional(),
})
.strict()
.optional();
const QQBotDmPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
const QQBotGroupPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
const QQBotAccountSchema = z
.object({
enabled: z.boolean().optional(),
@@ -54,6 +58,9 @@ const QQBotAccountSchema = z
clientSecret: buildSecretInputSchema().optional(),
clientSecretFile: z.string().optional(),
allowFrom: AllowFromListSchema,
groupAllowFrom: AllowFromListSchema,
dmPolicy: QQBotDmPolicySchema,
groupPolicy: QQBotGroupPolicySchema,
systemPrompt: z.string().optional(),
markdownSupport: z.boolean().optional(),
voiceDirectUploadFormats: z.array(z.string()).optional(),
@@ -62,11 +69,11 @@ const QQBotAccountSchema = z
upgradeUrl: z.string().optional(),
upgradeMode: z.enum(["doc", "hot-reload"]).optional(),
streaming: QQBotStreamingSchema,
execApprovals: QQBotExecApprovalsSchema,
})
.passthrough();
export const QQBotConfigSchema = QQBotAccountSchema.extend({
tts: QQBotTtsSchema,
stt: QQBotSttSchema,
accounts: z.object({}).catchall(QQBotAccountSchema.passthrough()).optional(),
defaultAccount: z.string().optional(),

View File

@@ -1,11 +1,60 @@
import fs from "node:fs";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { qqbotConfigAdapter, qqbotSetupAdapterShared } from "./channel-config-shared.js";
import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js";
import { qqbotSetupAdapterShared } from "./bridge/config-shared.js";
import {
DEFAULT_ACCOUNT_ID,
resolveDefaultQQBotAccountId,
resolveQQBotAccount,
} from "./bridge/config.js";
import { qqbotSetupPlugin } from "./channel.setup.js";
import { QQBotConfigSchema } from "./config-schema.js";
import { DEFAULT_ACCOUNT_ID, resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./config.js";
import { makeQqbotDefaultAccountConfig, makeQqbotSecretRefConfig } from "./qqbot-test-support.js";
describe("qqbot config", () => {
it("accepts top-level speech overrides in the manifest schema", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
) as { configSchema: Record<string, unknown> };
const result = validateJsonSchemaValue({
schema: manifest.configSchema,
cacheKey: "qqbot.manifest.speech-overrides",
value: {
stt: {
provider: "openai",
baseUrl: "https://example.com/v1",
apiKey: "stt-key",
model: "whisper-1",
},
},
});
expect(result.ok).toBe(true);
});
it("accepts defaultAccount in the manifest schema", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
) as { configSchema: Record<string, unknown> };
const result = validateJsonSchemaValue({
schema: manifest.configSchema,
cacheKey: "qqbot.manifest.default-account",
value: {
defaultAccount: "bot2",
accounts: {
bot2: {
appId: "654321",
},
},
},
});
expect(result.ok).toBe(true);
});
it("honors configured defaultAccount when resolving the default QQ Bot account id", () => {
const cfg = {
channels: {
@@ -62,7 +111,7 @@ describe("qqbot config", () => {
accounts: {
bot2: {
appId: "654321",
tts: {
stt: {
provider: "openai",
},
},
@@ -144,8 +193,8 @@ describe("qqbot config", () => {
expect(resolved.clientSecret).toBe("");
expect(resolved.secretSource).toBe("config");
expect(qqbotConfigAdapter.isConfigured(resolved)).toBe(true);
expect(qqbotConfigAdapter.describeAccount(resolved).configured).toBe(true);
expect(qqbotSetupPlugin.config.isConfigured?.(resolved, cfg)).toBe(true);
expect(qqbotSetupPlugin.config.describeAccount?.(resolved, cfg)?.configured).toBe(true);
});
it.each([
@@ -160,7 +209,10 @@ describe("qqbot config", () => {
expectedPath: ["channels", "qqbot", "accounts", "bot2"],
},
])("splits --token on the first colon for $accountId", ({ inputAccountId, expectedPath }) => {
const next = qqbotSetupAdapterShared.applyAccountConfig({
const setup = qqbotSetupPlugin.setup;
expect(setup).toBeDefined();
const next = setup!.applyAccountConfig?.({
cfg: {} as OpenClawConfig,
accountId: inputAccountId,
input: {
@@ -182,9 +234,11 @@ describe("qqbot config", () => {
});
});
it("rejects malformed --token in shared setup config", () => {
it("rejects malformed --token consistently across setup paths", () => {
const runtimeSetup = qqbotSetupAdapterShared;
const lightweightSetup = qqbotSetupPlugin.setup;
expect(runtimeSetup).toBeDefined();
expect(lightweightSetup).toBeDefined();
const input = { token: "broken", name: "Bad" };
@@ -195,6 +249,13 @@ describe("qqbot config", () => {
input,
} as never),
).toBe("QQBot --token must be in appId:clientSecret format");
expect(
lightweightSetup!.validateInput?.({
cfg: {} as OpenClawConfig,
accountId: DEFAULT_ACCOUNT_ID,
input,
} as never),
).toBe("QQBot --token must be in appId:clientSecret format");
expect(
runtimeSetup.applyAccountConfig?.({
cfg: {} as OpenClawConfig,
@@ -202,11 +263,20 @@ describe("qqbot config", () => {
input,
} as never),
).toEqual({});
expect(
lightweightSetup!.applyAccountConfig?.({
cfg: {} as OpenClawConfig,
accountId: DEFAULT_ACCOUNT_ID,
input,
} as never),
).toEqual({});
});
it("preserves the --use-env add flow in shared setup config", () => {
it("preserves the --use-env add flow across setup paths", () => {
const runtimeSetup = qqbotSetupAdapterShared;
const lightweightSetup = qqbotSetupPlugin.setup;
expect(runtimeSetup).toBeDefined();
expect(lightweightSetup).toBeDefined();
const input = { useEnv: true, name: "Env Bot" };
@@ -225,6 +295,21 @@ describe("qqbot config", () => {
},
},
});
expect(
lightweightSetup!.applyAccountConfig?.({
cfg: {} as OpenClawConfig,
accountId: DEFAULT_ACCOUNT_ID,
input,
} as never),
).toMatchObject({
channels: {
qqbot: {
enabled: true,
allowFrom: ["*"],
name: "Env Bot",
},
},
});
});
it("uses configured defaultAccount when runtime setup accountId is omitted", () => {
@@ -239,9 +324,11 @@ describe("qqbot config", () => {
).toBe("bot2");
});
it("rejects --use-env for named accounts in shared setup config", () => {
it("rejects --use-env for named accounts across setup paths", () => {
const runtimeSetup = qqbotSetupAdapterShared;
const lightweightSetup = qqbotSetupPlugin.setup;
expect(runtimeSetup).toBeDefined();
expect(lightweightSetup).toBeDefined();
const input = { useEnv: true, name: "Env Bot" };
@@ -252,6 +339,13 @@ describe("qqbot config", () => {
input,
} as never),
).toBe("QQBot --use-env only supports the default account");
expect(
lightweightSetup!.validateInput?.({
cfg: {} as OpenClawConfig,
accountId: "bot2",
input,
} as never),
).toBe("QQBot --use-env only supports the default account");
expect(
runtimeSetup.applyAccountConfig?.({
cfg: {} as OpenClawConfig,
@@ -259,5 +353,12 @@ describe("qqbot config", () => {
input,
} as never),
).toEqual({});
expect(
lightweightSetup!.applyAccountConfig?.({
cfg: {} as OpenClawConfig,
accountId: "bot2",
input,
} as never),
).toEqual({});
});
});

View File

@@ -1,227 +0,0 @@
import fs from "node:fs";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
export const DEFAULT_ACCOUNT_ID = "default";
interface QQBotChannelConfig extends QQBotAccountConfig {
accounts?: Record<string, QQBotAccountConfig>;
defaultAccount?: string;
}
function normalizeConfiguredDefaultAccountId(raw: unknown): string | null {
if (typeof raw !== "string") {
return null;
}
const normalized = raw.trim().toLowerCase();
return normalized || null;
}
function normalizeQQBotAccountConfig(account: QQBotAccountConfig | undefined): QQBotAccountConfig {
if (!account) {
return {};
}
return {
...account,
...(account.audioFormatPolicy ? { audioFormatPolicy: { ...account.audioFormatPolicy } } : {}),
};
}
function normalizeAppId(raw: unknown): string {
if (typeof raw === "string") {
return raw.trim();
}
if (typeof raw === "number") {
return String(raw);
}
return "";
}
function buildQQBotAccountConfigPatch(input: {
appId?: string;
clientSecret?: string;
clientSecretFile?: string;
name?: string;
}): Partial<QQBotAccountConfig> {
return {
...(input.appId ? { appId: input.appId } : {}),
...(input.clientSecret
? { clientSecret: input.clientSecret, clientSecretFile: undefined }
: input.clientSecretFile
? { clientSecretFile: input.clientSecretFile, clientSecret: undefined }
: {}),
...(input.name ? { name: input.name } : {}),
};
}
/** List all configured QQBot account IDs. */
export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>();
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
if (qqbot?.appId || process.env.QQBOT_APP_ID) {
ids.add(DEFAULT_ACCOUNT_ID);
}
if (qqbot?.accounts) {
for (const accountId of Object.keys(qqbot.accounts)) {
if (qqbot.accounts[accountId]?.appId) {
ids.add(accountId);
}
}
}
return Array.from(ids);
}
/** Resolve the default QQBot account ID. */
export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
const configuredDefaultAccountId = normalizeConfiguredDefaultAccountId(qqbot?.defaultAccount);
if (
configuredDefaultAccountId &&
(configuredDefaultAccountId === DEFAULT_ACCOUNT_ID ||
Boolean(qqbot?.accounts?.[configuredDefaultAccountId]?.appId))
) {
return configuredDefaultAccountId;
}
if (qqbot?.appId || process.env.QQBOT_APP_ID) {
return DEFAULT_ACCOUNT_ID;
}
if (qqbot?.accounts) {
const ids = Object.keys(qqbot.accounts);
if (ids.length > 0) {
return ids[0];
}
}
return DEFAULT_ACCOUNT_ID;
}
/** Resolve QQBot account config for runtime or setup flows. */
export function resolveQQBotAccount(
cfg: OpenClawConfig,
accountId?: string | null,
opts?: { allowUnresolvedSecretRef?: boolean },
): ResolvedQQBotAccount {
const resolvedAccountId = accountId ?? resolveDefaultQQBotAccountId(cfg);
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
let accountConfig: QQBotAccountConfig = {};
let appId = "";
let clientSecret = "";
let secretSource: "config" | "file" | "env" | "none" = "none";
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
// Default account reads from top-level config and keeps the full field surface.
accountConfig = normalizeQQBotAccountConfig(qqbot);
appId = normalizeAppId(qqbot?.appId);
} else {
// Named accounts read from channels.qqbot.accounts.
const account = qqbot?.accounts?.[resolvedAccountId];
accountConfig = normalizeQQBotAccountConfig(account);
appId = normalizeAppId(account?.appId);
}
const clientSecretPath =
resolvedAccountId === DEFAULT_ACCOUNT_ID
? "channels.qqbot.clientSecret"
: `channels.qqbot.accounts.${resolvedAccountId}.clientSecret`;
// Resolve clientSecret from config, file, or environment.
if (hasConfiguredSecretInput(accountConfig.clientSecret)) {
clientSecret = opts?.allowUnresolvedSecretRef
? (normalizeSecretInputString(accountConfig.clientSecret) ?? "")
: (normalizeResolvedSecretInputString({
value: accountConfig.clientSecret,
path: clientSecretPath,
}) ?? "");
secretSource = "config";
} else if (accountConfig.clientSecretFile) {
try {
clientSecret = fs.readFileSync(accountConfig.clientSecretFile, "utf8").trim();
secretSource = "file";
} catch {
secretSource = "none";
}
} else if (process.env.QQBOT_CLIENT_SECRET && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
clientSecret = process.env.QQBOT_CLIENT_SECRET;
secretSource = "env";
}
// AppId can also fall back to an environment variable.
if (!appId && process.env.QQBOT_APP_ID && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
appId = normalizeAppId(process.env.QQBOT_APP_ID);
}
return {
accountId: resolvedAccountId,
name: accountConfig.name,
enabled: accountConfig.enabled !== false,
appId,
clientSecret,
secretSource,
systemPrompt: accountConfig.systemPrompt,
markdownSupport: accountConfig.markdownSupport !== false,
config: accountConfig,
};
}
/** Apply account config updates back into the OpenClaw config object. */
export function applyQQBotAccountConfig(
cfg: OpenClawConfig,
accountId: string,
input: {
appId?: string;
clientSecret?: string;
clientSecretFile?: string;
name?: string;
},
): OpenClawConfig {
const next = { ...cfg };
const accountConfigPatch = buildQQBotAccountConfigPatch(input);
if (accountId === DEFAULT_ACCOUNT_ID) {
// Default allowFrom to ["*"] when not yet configured.
const existingConfig = (next.channels?.qqbot as QQBotChannelConfig) || {};
const allowFrom = existingConfig.allowFrom ?? ["*"];
next.channels = {
...next.channels,
qqbot: {
...(next.channels?.qqbot as Record<string, unknown> | undefined),
enabled: true,
allowFrom,
...accountConfigPatch,
},
};
} else {
// Default allowFrom to ["*"] when not yet configured.
const existingAccountConfig =
(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {};
const allowFrom = existingAccountConfig.allowFrom ?? ["*"];
next.channels = {
...next.channels,
qqbot: {
...(next.channels?.qqbot as Record<string, unknown> | undefined),
enabled: true,
accounts: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
[accountId]: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
enabled: true,
allowFrom,
...accountConfigPatch,
},
},
},
};
}
return next;
}

View File

@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import { resolveQQBotAccess } from "./access-control.js";
import { QQBOT_ACCESS_REASON } from "./types.js";
describe("resolveQQBotAccess", () => {
describe("DM scenarios", () => {
it("allows everyone when no allowFrom is configured (open)", () => {
const result = resolveQQBotAccess({ isGroup: false, senderId: "USER1" });
expect(result).toMatchObject({
decision: "allow",
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN,
dmPolicy: "open",
});
});
it("allows everyone with wildcard allowFrom", () => {
const result = resolveQQBotAccess({
isGroup: false,
senderId: "USER1",
allowFrom: ["*"],
});
expect(result.decision).toBe("allow");
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_OPEN);
});
it("allows sender matching the allowlist", () => {
const result = resolveQQBotAccess({
isGroup: false,
senderId: "USER1",
allowFrom: ["USER1"],
});
expect(result.decision).toBe("allow");
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED);
expect(result.dmPolicy).toBe("allowlist");
});
it("blocks sender not in allowlist", () => {
const result = resolveQQBotAccess({
isGroup: false,
senderId: "USER2",
allowFrom: ["USER1"],
});
expect(result.decision).toBe("block");
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED);
});
it("blocks DM when dmPolicy=disabled (even with wildcard)", () => {
const result = resolveQQBotAccess({
isGroup: false,
senderId: "USER1",
allowFrom: ["*"],
dmPolicy: "disabled",
});
expect(result.decision).toBe("block");
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_DISABLED);
});
it("blocks DM with allowlist policy but empty allowlist", () => {
const result = resolveQQBotAccess({
isGroup: false,
senderId: "USER1",
dmPolicy: "allowlist",
});
expect(result.decision).toBe("block");
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST);
});
it("normalizes qqbot: prefix and case when matching", () => {
const result = resolveQQBotAccess({
isGroup: false,
senderId: "qqbot:user1",
allowFrom: ["QQBot:USER1"],
});
expect(result.decision).toBe("allow");
});
});
describe("group scenarios", () => {
it("inherits allowFrom for group access when no groupAllowFrom is set", () => {
const allowed = resolveQQBotAccess({
isGroup: true,
senderId: "USER1",
allowFrom: ["USER1"],
});
expect(allowed.decision).toBe("allow");
expect(allowed.groupPolicy).toBe("allowlist");
const blocked = resolveQQBotAccess({
isGroup: true,
senderId: "USER2",
allowFrom: ["USER1"],
});
expect(blocked.decision).toBe("block");
expect(blocked.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED);
});
it("uses groupAllowFrom when explicitly provided", () => {
const result = resolveQQBotAccess({
isGroup: true,
senderId: "USER2",
allowFrom: ["USER1"],
groupAllowFrom: ["USER2"],
});
expect(result.decision).toBe("allow");
});
it("blocks when groupPolicy=disabled", () => {
const result = resolveQQBotAccess({
isGroup: true,
senderId: "USER1",
allowFrom: ["*"],
groupPolicy: "disabled",
});
expect(result.decision).toBe("block");
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED);
});
it("allows anyone when groupPolicy=open", () => {
const result = resolveQQBotAccess({
isGroup: true,
senderId: "RANDOM_USER",
allowFrom: ["USER1"],
groupPolicy: "open",
});
expect(result.decision).toBe("allow");
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED);
});
it("blocks when groupPolicy=allowlist but list is empty", () => {
const result = resolveQQBotAccess({
isGroup: true,
senderId: "USER1",
groupPolicy: "allowlist",
});
expect(result.decision).toBe("block");
expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST);
});
});
describe("backwards compatibility (legacy allowFrom-only configs)", () => {
it("legacy allowFrom=['*'] stays fully open for both DM and group", () => {
const dm = resolveQQBotAccess({
isGroup: false,
senderId: "RANDOM",
allowFrom: ["*"],
});
const group = resolveQQBotAccess({
isGroup: true,
senderId: "RANDOM",
allowFrom: ["*"],
});
expect(dm.decision).toBe("allow");
expect(group.decision).toBe("allow");
});
it("legacy allowFrom=['USER1'] locks down both DM and group to USER1", () => {
const allowedDm = resolveQQBotAccess({
isGroup: false,
senderId: "USER1",
allowFrom: ["USER1"],
});
const blockedGroup = resolveQQBotAccess({
isGroup: true,
senderId: "INTRUDER",
allowFrom: ["USER1"],
});
expect(allowedDm.decision).toBe("allow");
expect(blockedGroup.decision).toBe("block");
});
});
});

View File

@@ -0,0 +1,208 @@
/**
* QQBot inbound access decision.
*
* This module is the single place where the QQBot engine decides
* whether an inbound message from a given sender is allowed to
* proceed into the outbound pipeline. The implementation mirrors the
* semantics of the framework-wide `resolveDmGroupAccessDecision`
* (`src/security/dm-policy-shared.ts`) but is kept standalone so the
* `engine/` layer does not pull in `openclaw/plugin-sdk/*` modules —
* a hard constraint shared with the standalone `openclaw-qqbot` build.
*
* If in the future we lift the zero-dependency rule in the engine
* layer, this file can be replaced by a thin adapter around the
* framework API with identical semantics.
*/
import { resolveQQBotEffectivePolicies, type EffectivePolicyInput } from "./resolve-policy.js";
import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "./sender-match.js";
import {
QQBOT_ACCESS_REASON,
type QQBotAccessResult,
type QQBotDmPolicy,
type QQBotGroupPolicy,
} from "./types.js";
export interface QQBotAccessInput extends EffectivePolicyInput {
/** Whether the inbound originated in a group (or guild) chat. */
isGroup: boolean;
/** The raw inbound sender id as provided by the QQ event. */
senderId: string;
}
/**
* Evaluate the inbound access policy.
*
* Semantics (aligned with `resolveDmGroupAccessDecision`):
* - Group message:
* - `groupPolicy=disabled` → block
* - `groupPolicy=open` → allow
* - `groupPolicy=allowlist`:
* - empty effectiveGroupAllowFrom → block (empty_allowlist)
* - sender not in list → block (not_allowlisted)
* - otherwise → allow
* - Direct message:
* - `dmPolicy=disabled` → block
* - `dmPolicy=open` → allow
* - `dmPolicy=allowlist`:
* - empty effectiveAllowFrom → block (empty_allowlist)
* - sender not in list → block (not_allowlisted)
* - otherwise → allow
*
* The function never throws; callers can rely on the returned
* `decision`/`reasonCode` pair for branching.
*/
export function resolveQQBotAccess(input: QQBotAccessInput): QQBotAccessResult {
const { dmPolicy, groupPolicy } = resolveQQBotEffectivePolicies(input);
// Per framework convention: groupAllowFrom falls back to allowFrom
// when not provided. We preserve that behaviour so a single
// `allowFrom` entry locks down both DM and group.
const rawGroupAllowFrom =
input.groupAllowFrom && input.groupAllowFrom.length > 0
? input.groupAllowFrom
: (input.allowFrom ?? []);
const effectiveAllowFrom = normalizeQQBotAllowFrom(input.allowFrom);
const effectiveGroupAllowFrom = normalizeQQBotAllowFrom(rawGroupAllowFrom);
const isSenderAllowed = createQQBotSenderMatcher(input.senderId);
if (input.isGroup) {
return evaluateGroupDecision({
groupPolicy,
dmPolicy,
effectiveAllowFrom,
effectiveGroupAllowFrom,
isSenderAllowed,
});
}
return evaluateDmDecision({
groupPolicy,
dmPolicy,
effectiveAllowFrom,
effectiveGroupAllowFrom,
isSenderAllowed,
});
}
// ---- internal helpers ------------------------------------------------
interface DecisionContext {
dmPolicy: QQBotDmPolicy;
groupPolicy: QQBotGroupPolicy;
effectiveAllowFrom: string[];
effectiveGroupAllowFrom: string[];
isSenderAllowed: (allowFrom: string[]) => boolean;
}
function evaluateGroupDecision(ctx: DecisionContext): QQBotAccessResult {
const base = buildResultBase(ctx);
if (ctx.groupPolicy === "disabled") {
return {
...base,
decision: "block",
reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED,
reason: "groupPolicy=disabled",
};
}
if (ctx.groupPolicy === "open") {
return {
...base,
decision: "allow",
reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED,
reason: "groupPolicy=open",
};
}
// groupPolicy === "allowlist"
if (ctx.effectiveGroupAllowFrom.length === 0) {
return {
...base,
decision: "block",
reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
reason: "groupPolicy=allowlist (empty allowlist)",
};
}
if (!ctx.isSenderAllowed(ctx.effectiveGroupAllowFrom)) {
return {
...base,
decision: "block",
reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
reason: "groupPolicy=allowlist (not allowlisted)",
};
}
return {
...base,
decision: "allow",
reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED,
reason: "groupPolicy=allowlist (allowlisted)",
};
}
function evaluateDmDecision(ctx: DecisionContext): QQBotAccessResult {
const base = buildResultBase(ctx);
if (ctx.dmPolicy === "disabled") {
return {
...base,
decision: "block",
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_DISABLED,
reason: "dmPolicy=disabled",
};
}
if (ctx.dmPolicy === "open") {
return {
...base,
decision: "allow",
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN,
reason: "dmPolicy=open",
};
}
// dmPolicy === "allowlist"
if (ctx.effectiveAllowFrom.length === 0) {
return {
...base,
decision: "block",
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST,
reason: "dmPolicy=allowlist (empty allowlist)",
};
}
if (!ctx.isSenderAllowed(ctx.effectiveAllowFrom)) {
return {
...base,
decision: "block",
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
reason: "dmPolicy=allowlist (not allowlisted)",
};
}
return {
...base,
decision: "allow",
reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
reason: "dmPolicy=allowlist (allowlisted)",
};
}
function buildResultBase(
ctx: DecisionContext,
): Pick<
QQBotAccessResult,
"effectiveAllowFrom" | "effectiveGroupAllowFrom" | "dmPolicy" | "groupPolicy"
> {
return {
effectiveAllowFrom: ctx.effectiveAllowFrom,
effectiveGroupAllowFrom: ctx.effectiveGroupAllowFrom,
dmPolicy: ctx.dmPolicy,
groupPolicy: ctx.groupPolicy,
};
}

View File

@@ -0,0 +1,22 @@
/**
* QQBot inbound access control — public entry points.
*
* Consumers (inbound-pipeline and future adapters) should import from
* this barrel to keep the internal module layout opaque.
*/
export { resolveQQBotAccess, type QQBotAccessInput } from "./access-control.js";
export {
createQQBotSenderMatcher,
normalizeQQBotAllowFrom,
normalizeQQBotSenderId,
} from "./sender-match.js";
export { resolveQQBotEffectivePolicies, type EffectivePolicyInput } from "./resolve-policy.js";
export {
QQBOT_ACCESS_REASON,
type QQBotAccessDecision,
type QQBotAccessReasonCode,
type QQBotAccessResult,
type QQBotDmPolicy,
type QQBotGroupPolicy,
} from "./types.js";

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { resolveQQBotEffectivePolicies } from "./resolve-policy.js";
describe("resolveQQBotEffectivePolicies", () => {
describe("backwards-compatible inference", () => {
it("defaults to open when no allowFrom is configured", () => {
expect(resolveQQBotEffectivePolicies({})).toEqual({
dmPolicy: "open",
groupPolicy: "open",
});
});
it("defaults to open when allowFrom only contains wildcard", () => {
expect(resolveQQBotEffectivePolicies({ allowFrom: ["*"] })).toEqual({
dmPolicy: "open",
groupPolicy: "open",
});
});
it("infers allowlist when allowFrom has a concrete entry", () => {
expect(resolveQQBotEffectivePolicies({ allowFrom: ["USER1"] })).toEqual({
dmPolicy: "allowlist",
groupPolicy: "allowlist",
});
});
it("infers group=allowlist when only groupAllowFrom is restricted", () => {
expect(
resolveQQBotEffectivePolicies({ allowFrom: ["*"], groupAllowFrom: ["USER1"] }),
).toEqual({
dmPolicy: "open",
groupPolicy: "allowlist",
});
});
});
describe("explicit policy precedence", () => {
it("honours explicit dmPolicy over inference", () => {
expect(
resolveQQBotEffectivePolicies({ allowFrom: ["USER1"], dmPolicy: "open" }),
).toMatchObject({ dmPolicy: "open" });
});
it("honours explicit groupPolicy over inference", () => {
expect(
resolveQQBotEffectivePolicies({
allowFrom: ["USER1"],
groupPolicy: "disabled",
}),
).toMatchObject({ groupPolicy: "disabled" });
});
it("allows dmPolicy=disabled to cut off DM entirely", () => {
expect(resolveQQBotEffectivePolicies({ dmPolicy: "disabled" })).toMatchObject({
dmPolicy: "disabled",
});
});
});
});

View File

@@ -0,0 +1,57 @@
/**
* Effective-policy resolver.
*
* Maps a raw `QQBotAccountConfig` to the concrete `dmPolicy`/`groupPolicy`
* values that the access engine consumes. Provides backwards-compatible
* defaults for accounts that only have the legacy `allowFrom` field:
*
* - Empty `allowFrom` or containing `"*"` → `"open"` (the historical
* behaviour before P0/P1 landed).
* - Non-empty `allowFrom` without `"*"` → `"allowlist"` (what a
* security-conscious operator almost certainly meant).
*
* An explicit `dmPolicy`/`groupPolicy` always wins over the inference.
*/
import type { QQBotDmPolicy, QQBotGroupPolicy } from "./types.js";
/** Subset of the account config fields this resolver actually reads. */
export interface EffectivePolicyInput {
allowFrom?: Array<string | number> | null;
groupAllowFrom?: Array<string | number> | null;
dmPolicy?: QQBotDmPolicy | null;
groupPolicy?: QQBotGroupPolicy | null;
}
function hasRealRestriction(list: Array<string | number> | null | undefined): boolean {
if (!list || list.length === 0) {
return false;
}
// A list that only contains `"*"` is logically equivalent to open.
return !list.every((entry) => String(entry).trim() === "*");
}
/**
* Derive the effective dmPolicy and groupPolicy applied at runtime.
*
* Caller should pass the raw `QQBotAccountConfig`. The resolver does
* not look at `groups[id]` overrides — per-group overrides are layered
* on top elsewhere (see `inbound-pipeline` mention gating).
*/
export function resolveQQBotEffectivePolicies(input: EffectivePolicyInput): {
dmPolicy: QQBotDmPolicy;
groupPolicy: QQBotGroupPolicy;
} {
const allowFromRestricted = hasRealRestriction(input.allowFrom);
const groupAllowFromRestricted = hasRealRestriction(input.groupAllowFrom);
const dmPolicy: QQBotDmPolicy = input.dmPolicy ?? (allowFromRestricted ? "allowlist" : "open");
// groupPolicy defaults: if an explicit groupAllowFrom is provided and
// restricts, enforce allowlist. Otherwise fall back to the same rule
// as DM (so a single `allowFrom` entry locks down both DM and group).
const groupPolicy: QQBotGroupPolicy =
input.groupPolicy ?? (groupAllowFromRestricted || allowFromRestricted ? "allowlist" : "open");
return { dmPolicy, groupPolicy };
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
createQQBotSenderMatcher,
normalizeQQBotAllowFrom,
normalizeQQBotSenderId,
} from "./sender-match.js";
describe("normalizeQQBotSenderId", () => {
it("uppercases and strips qqbot: prefix", () => {
expect(normalizeQQBotSenderId("qqbot:abc123")).toBe("ABC123");
expect(normalizeQQBotSenderId("QQBot:abc123")).toBe("ABC123");
});
it("trims whitespace", () => {
expect(normalizeQQBotSenderId(" USER1 ")).toBe("USER1");
});
it("returns empty string for non-string input", () => {
expect(normalizeQQBotSenderId(undefined as unknown as string)).toBe("");
expect(normalizeQQBotSenderId(null as unknown as string)).toBe("");
expect(normalizeQQBotSenderId({} as unknown as string)).toBe("");
});
it("accepts numeric input", () => {
expect(normalizeQQBotSenderId(42)).toBe("42");
});
});
describe("normalizeQQBotAllowFrom", () => {
it("normalizes all entries and drops empty ones", () => {
expect(normalizeQQBotAllowFrom(["qqbot:user1", "USER2", "", " "])).toEqual(["USER1", "USER2"]);
});
it("returns empty array for undefined/null", () => {
expect(normalizeQQBotAllowFrom(undefined)).toEqual([]);
expect(normalizeQQBotAllowFrom(null)).toEqual([]);
});
});
describe("createQQBotSenderMatcher", () => {
it("matches wildcard regardless of sender", () => {
expect(createQQBotSenderMatcher("USER1")(["*"])).toBe(true);
expect(createQQBotSenderMatcher("")(["*"])).toBe(true);
});
it("matches case-insensitive with qqbot: prefix", () => {
const match = createQQBotSenderMatcher("qqbot:USER1");
expect(match(["qqbot:user1"])).toBe(true);
expect(match(["USER1"])).toBe(true);
expect(match(["USER2"])).toBe(false);
});
it("returns false on empty allowlist", () => {
expect(createQQBotSenderMatcher("USER1")([])).toBe(false);
});
it("returns false for empty sender against non-wildcard list", () => {
expect(createQQBotSenderMatcher("")(["USER1"])).toBe(false);
});
});

View File

@@ -0,0 +1,55 @@
/**
* QQBot sender normalization and allowlist matching.
*
* Keeps QQ-specific quirks (the `qqbot:` prefix, uppercase-insensitive
* comparison) localized to this module so the policy engine itself can
* stay channel-agnostic.
*/
/** Normalize a single entry (openid): strip `qqbot:` prefix, uppercase, trim. */
export function normalizeQQBotSenderId(raw: unknown): string {
if (typeof raw !== "string" && typeof raw !== "number") {
return "";
}
return String(raw)
.trim()
.replace(/^qqbot:/i, "")
.toUpperCase();
}
/** Normalize an entire allowFrom list, dropping empty entries. */
export function normalizeQQBotAllowFrom(list: Array<string | number> | undefined | null): string[] {
if (!list || list.length === 0) {
return [];
}
const out: string[] = [];
for (const entry of list) {
const normalized = normalizeQQBotSenderId(entry);
if (normalized) {
out.push(normalized);
}
}
return out;
}
/**
* Build a matcher closure suitable for passing to the policy engine's
* `isSenderAllowed` callback. The caller supplies the sender once, and
* the returned function can be invoked against different allowlists
* (DM allowlist vs group allowlist) without repeating normalization.
*/
export function createQQBotSenderMatcher(senderId: string): (allowFrom: string[]) => boolean {
const normalizedSender = normalizeQQBotSenderId(senderId);
return (allowFrom: string[]) => {
if (allowFrom.length === 0) {
return false;
}
if (allowFrom.includes("*")) {
return true;
}
if (!normalizedSender) {
return false;
}
return allowFrom.some((entry) => normalizeQQBotSenderId(entry) === normalizedSender);
};
}

View File

@@ -0,0 +1,52 @@
/**
* QQBot access-control primitive types.
*
* Mirrors the semantics of the framework-shared `DmPolicy` and
* `DmGroupAccessDecision` types while staying zero-dependency so the
* engine layer remains portable across the built-in and standalone
* plugin builds.
*
* The reason codes here intentionally match
* `src/security/dm-policy-shared.ts::DM_GROUP_ACCESS_REASON` so metric
* dashboards can treat QQBot decisions identically to WhatsApp /
* Telegram / Discord decisions.
*/
/** DM-level policy selecting between open / allowlist / disabled gating. */
export type QQBotDmPolicy = "open" | "allowlist" | "disabled";
/** Group-level policy selecting between open / allowlist / disabled gating. */
export type QQBotGroupPolicy = "open" | "allowlist" | "disabled";
/** High-level outcome returned by the access gate. */
export type QQBotAccessDecision = "allow" | "block";
/** Structured reason codes used in logs and metrics. */
export const QQBOT_ACCESS_REASON = {
DM_POLICY_OPEN: "dm_policy_open",
DM_POLICY_DISABLED: "dm_policy_disabled",
DM_POLICY_ALLOWLISTED: "dm_policy_allowlisted",
DM_POLICY_NOT_ALLOWLISTED: "dm_policy_not_allowlisted",
DM_POLICY_EMPTY_ALLOWLIST: "dm_policy_empty_allowlist",
GROUP_POLICY_ALLOWED: "group_policy_allowed",
GROUP_POLICY_DISABLED: "group_policy_disabled",
GROUP_POLICY_EMPTY_ALLOWLIST: "group_policy_empty_allowlist",
GROUP_POLICY_NOT_ALLOWLISTED: "group_policy_not_allowlisted",
} as const;
export type QQBotAccessReasonCode = (typeof QQBOT_ACCESS_REASON)[keyof typeof QQBOT_ACCESS_REASON];
/** Result of the access gate evaluation. */
export interface QQBotAccessResult {
decision: QQBotAccessDecision;
reasonCode: QQBotAccessReasonCode;
/** Human-readable reason suitable for logging. */
reason: string;
/** The allowFrom list that was actually compared against. */
effectiveAllowFrom: string[];
/** The groupAllowFrom list that was actually compared against. */
effectiveGroupAllowFrom: string[];
/** The dm/group policies that were used (after defaults were applied). */
dmPolicy: QQBotDmPolicy;
groupPolicy: QQBotGroupPolicy;
}

View File

@@ -0,0 +1,106 @@
/**
* Platform adapter interface — abstracts framework-specific capabilities
* so core/ modules remain portable between the built-in and standalone versions.
*
* Each version implements this interface in its own `bootstrap/adapter/` directory
* and calls `registerPlatformAdapter()` during startup.
*
* core/ modules access platform capabilities via `getPlatformAdapter()`.
*
* ## Lazy initialization
*
* When the adapter has not been explicitly registered yet, `getPlatformAdapter()`
* will invoke the factory registered via `registerPlatformAdapterFactory()` to
* create and register the adapter on first access. This eliminates fragile
* dependency on side-effect import ordering — the adapter is guaranteed to be
* available whenever any engine module needs it, regardless of which code path
* triggers the first access.
*/
import type { FetchMediaOptions, FetchMediaResult, SecretInputRef } from "./types.js";
/** Platform adapter that core/ modules use for framework-specific operations. */
export interface PlatformAdapter {
/** Validate that a remote URL is safe to fetch (SSRF protection). */
validateRemoteUrl(url: string, options?: { allowPrivate?: boolean }): Promise<void>;
/** Resolve a secret value (SecretInput or plain string) to a plain string. */
resolveSecret(value: string | SecretInputRef | undefined): Promise<string | undefined>;
/** Download a remote file to a local directory. Returns the local file path. */
downloadFile(url: string, destDir: string, filename?: string): Promise<string>;
/**
* Fetch remote media with SSRF protection.
* Replaces direct usage of `fetchRemoteMedia` from `plugin-sdk/media-runtime`.
*/
fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult>;
/** Return the preferred temporary directory for the platform. */
getTempDir(): string;
/** Check whether a secret input value has been configured (non-empty). */
hasConfiguredSecret(value: unknown): boolean;
/**
* Normalize a raw SecretInput value into a plain string.
* For unresolved references (e.g. `$secret:xxx`), returns the raw reference string.
*/
normalizeSecretInputString(value: unknown): string | undefined;
/**
* Resolve a SecretInput value into the final plain-text secret.
* For secret references, resolves them to actual values via the platform's secret store.
*/
resolveSecretInputString(params: { value: unknown; path: string }): string | undefined;
/**
* Submit an approval decision to the framework's approval gateway.
* Optional — only available when the framework supports approvals.
* Returns true if the decision was submitted successfully.
*/
resolveApproval?(approvalId: string, decision: string): Promise<boolean>;
}
let _adapter: PlatformAdapter | null = null;
let _adapterFactory: (() => PlatformAdapter) | null = null;
/** Register the platform adapter. Called once during startup. */
export function registerPlatformAdapter(adapter: PlatformAdapter): void {
_adapter = adapter;
}
/**
* Register a factory that creates the PlatformAdapter on first access.
*
* This decouples adapter availability from side-effect import ordering.
* The factory is invoked at most once — on the first `getPlatformAdapter()`
* call when no adapter has been explicitly registered yet.
*/
export function registerPlatformAdapterFactory(factory: () => PlatformAdapter): void {
_adapterFactory = factory;
}
/**
* Get the registered platform adapter.
*
* If no adapter has been explicitly registered yet but a factory was provided
* via `registerPlatformAdapterFactory()`, the factory is invoked to create
* and register the adapter automatically.
*/
export function getPlatformAdapter(): PlatformAdapter {
if (!_adapter && _adapterFactory) {
_adapter = _adapterFactory();
}
if (!_adapter) {
throw new Error(
"PlatformAdapter not registered. Call registerPlatformAdapter() during bootstrap.",
);
}
return _adapter;
}
/** Check whether a platform adapter has been registered (or can be created from a factory). */
export function hasPlatformAdapter(): boolean {
return _adapter !== null || _adapterFactory !== null;
}

View File

@@ -0,0 +1,38 @@
/**
* Shared types used by the PlatformAdapter interface.
*/
/** Reference to a secret stored in the platform's secret management system. */
export interface SecretInputRef {
source: "env" | "file" | "config";
id: string;
}
/** Options for fetching remote media through the platform adapter. */
export interface FetchMediaOptions {
url: string;
/** Hint for the local filename when saving. */
filePathHint?: string;
/** Maximum bytes to download. */
maxBytes?: number;
/** Maximum redirects to follow. */
maxRedirects?: number;
/** SSRF policy configuration. */
ssrfPolicy?: SsrfPolicyConfig;
/** Extra fetch() RequestInit options. */
requestInit?: RequestInit;
}
/** Result of a remote media fetch operation. */
export interface FetchMediaResult {
buffer: Buffer;
fileName?: string;
}
/** SSRF policy configuration — platform-agnostic subset. */
export interface SsrfPolicyConfig {
/** Hostnames that are always allowed (supports `*.example.com` wildcards). */
hostnameAllowlist?: string[];
/** Whether to allow RFC 2544 benchmark ranges (198.18.0.0/15). */
allowRfc2544BenchmarkRange?: boolean;
}

View File

@@ -0,0 +1,196 @@
/**
* Core HTTP client for the QQ Open Platform REST API.
*
* Key improvements over the old `src/api.ts#apiRequest`:
* - `ApiClient` is an **instance** — config (baseUrl, timeout, logger, UA)
* is injected via the constructor, eliminating module-level globals.
* - Throws structured `ApiError` with httpStatus, bizCode, and path fields.
* - Detects HTML error pages from CDN/gateway and returns user-friendly messages.
* - `redactBodyKeys` replaces the hardcoded `file_data` redaction.
*/
import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
const DEFAULT_BASE_URL = "https://api.sgroup.qq.com";
const DEFAULT_TIMEOUT_MS = 30_000;
const FILE_UPLOAD_TIMEOUT_MS = 120_000;
export interface RequestOptions {
/** Request timeout override in milliseconds. */
timeoutMs?: number;
/** Body keys to redact in debug logs (e.g. `['file_data']`). */
redactBodyKeys?: string[];
}
/**
* Stateful HTTP client for the QQ Open Platform.
*
* Usage:
* ```ts
* const client = new ApiClient({ logger, userAgent: 'QQBotPlugin/1.0' });
* const data = await client.request<{ url: string }>(token, 'GET', '/gateway');
* ```
*/
export class ApiClient {
private readonly baseUrl: string;
private readonly defaultTimeoutMs: number;
private readonly fileUploadTimeoutMs: number;
private readonly logger?: EngineLogger;
private readonly resolveUserAgent: () => string;
constructor(config: ApiClientConfig = {}) {
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
this.defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
this.fileUploadTimeoutMs = config.fileUploadTimeoutMs ?? FILE_UPLOAD_TIMEOUT_MS;
this.logger = config.logger;
const ua = config.userAgent ?? "QQBotPlugin/unknown";
this.resolveUserAgent = typeof ua === "function" ? ua : () => ua;
}
/**
* Send an authenticated JSON request to the QQ Open Platform.
*
* @param accessToken - Bearer token (`QQBot {token}`).
* @param method - HTTP method.
* @param path - API path (appended to baseUrl).
* @param body - Optional JSON body.
* @param options - Optional request overrides.
* @returns Parsed JSON response.
* @throws {ApiError} On HTTP or parse errors.
*/
async request<T = unknown>(
accessToken: string,
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
Authorization: `QQBot ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": this.resolveUserAgent(),
};
const isFileUpload = path.includes("/files");
const timeout =
options?.timeoutMs ?? (isFileUpload ? this.fileUploadTimeoutMs : this.defaultTimeoutMs);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const fetchInit: RequestInit = {
method,
headers,
signal: controller.signal,
};
if (body) {
fetchInit.body = JSON.stringify(body);
}
// Debug logging with optional body redaction.
this.logger?.debug?.(`[qqbot:api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
if (body && this.logger?.debug) {
const logBody = { ...(body as Record<string, unknown>) };
for (const key of options?.redactBodyKeys ?? ["file_data"]) {
if (typeof logBody[key] === "string") {
logBody[key] = `<redacted ${logBody[key].length} chars>`;
}
}
this.logger.debug(`[qqbot:api] >>> Body: ${JSON.stringify(logBody)}`);
}
let res: Response;
try {
res = await fetch(url, fetchInit);
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === "AbortError") {
this.logger?.error?.(`[qqbot:api] <<< Timeout after ${timeout}ms`);
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);
} finally {
clearTimeout(timeoutId);
}
// Log response status and trace ID.
const traceId = res.headers.get("x-tps-trace-id") ?? "";
this.logger?.info?.(
`[qqbot:api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`,
);
let rawBody: string;
try {
rawBody = await res.text();
} catch (err) {
throw new ApiError(
`Failed to read response [${path}]: ${formatErrorMessage(err)}`,
res.status,
path,
);
}
this.logger?.debug?.(`[qqbot:api] <<< Body: ${rawBody}`);
// Detect non-JSON responses (HTML gateway errors, CDN rate-limit pages).
const contentType = res.headers.get("content-type") ?? "";
const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
if (!res.ok) {
if (isHtmlResponse) {
const statusHint =
res.status === 502 || res.status === 503 || res.status === 504
? "调用发生异常,请稍候重试"
: res.status === 429
? "请求过于频繁,已被限流"
: `开放平台返回 HTTP ${res.status}`;
throw new ApiError(`${statusHint}${path}),请稍后重试`, res.status, path);
}
// JSON error response.
try {
const error = JSON.parse(rawBody) as {
message?: string;
code?: number;
err_code?: number;
};
const bizCode = error.code ?? error.err_code;
throw new ApiError(
`API Error [${path}]: ${error.message ?? rawBody}`,
res.status,
path,
bizCode,
error.message,
);
} catch (parseErr) {
if (parseErr instanceof ApiError) {
throw parseErr;
}
throw new ApiError(
`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`,
res.status,
path,
);
}
}
// Successful response but not JSON (extreme edge case).
if (isHtmlResponse) {
throw new ApiError(
`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`,
res.status,
path,
);
}
try {
return JSON.parse(rawBody) as T;
} catch {
throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path);
}
}
}

View File

@@ -0,0 +1,178 @@
/**
* Media upload API for the QQ Open Platform (small-file direct upload).
*
* Key improvements:
* - Unified `uploadMedia(scope, ...)` replaces `uploadC2CMedia` + `uploadGroupMedia`.
* - Upload cache integration via composition (passed in constructor).
* - Uses `withRetry` from the shared retry engine.
*/
import {
MediaFileType,
type ChatScope,
type UploadMediaResponse,
type MessageResponse,
type EngineLogger,
} from "../types.js";
import { ApiClient } from "./api-client.js";
import { withRetry, UPLOAD_RETRY_POLICY } from "./retry.js";
import { mediaUploadPath, getNextMsgSeq } from "./routes.js";
import { TokenManager } from "./token.js";
/** Upload cache interface — the caller provides the implementation. */
export interface UploadCacheAdapter {
computeHash: (data: string) => string;
get: (hash: string, scope: string, targetId: string, fileType: number) => string | null;
set: (
hash: string,
scope: string,
targetId: string,
fileType: number,
fileInfo: string,
fileUuid: string,
ttl: number,
) => void;
}
/** File name sanitizer — injected to avoid importing platform-specific utils. */
export type SanitizeFileNameFn = (name: string) => string;
export interface MediaApiConfig {
logger?: EngineLogger;
/** Upload cache adapter (optional, omit to disable caching). */
uploadCache?: UploadCacheAdapter;
/** File name sanitizer. */
sanitizeFileName?: SanitizeFileNameFn;
}
/**
* Small-file media upload module.
*
* Handles base64 and URL-based uploads with optional caching and retry.
*/
export class MediaApi {
private readonly client: ApiClient;
private readonly tokenManager: TokenManager;
private readonly logger?: EngineLogger;
private readonly cache?: UploadCacheAdapter;
private readonly sanitize: SanitizeFileNameFn;
constructor(client: ApiClient, tokenManager: TokenManager, config: MediaApiConfig = {}) {
this.client = client;
this.tokenManager = tokenManager;
this.logger = config.logger;
this.cache = config.uploadCache;
this.sanitize = config.sanitizeFileName ?? ((n) => n);
}
/**
* Upload media via base64 or URL to a C2C or Group target.
*
* @param scope - `'c2c'` or `'group'`.
* @param targetId - User openid or group openid.
* @param fileType - Media file type code.
* @param creds - Authentication credentials.
* @param opts - Upload options.
* @returns Upload result containing `file_info` for subsequent message sends.
*/
async uploadMedia(
scope: ChatScope,
targetId: string,
fileType: MediaFileType,
creds: { appId: string; clientSecret: string },
opts: {
url?: string;
fileData?: string;
srvSendMsg?: boolean;
fileName?: string;
},
): Promise<UploadMediaResponse> {
if (!opts.url && !opts.fileData) {
throw new Error(`uploadMedia: url or fileData is required`);
}
// Check cache for base64 uploads.
if (opts.fileData && this.cache) {
const hash = this.cache.computeHash(opts.fileData);
const cached = this.cache.get(hash, scope, targetId, fileType);
if (cached) {
return { file_uuid: "", file_info: cached, ttl: 0 };
}
}
const body: Record<string, unknown> = {
file_type: fileType,
srv_send_msg: opts.srvSendMsg ?? false,
};
if (opts.url) {
body.url = opts.url;
} else if (opts.fileData) {
body.file_data = opts.fileData;
}
if (fileType === MediaFileType.FILE && opts.fileName) {
body.file_name = this.sanitize(opts.fileName);
}
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
const path = mediaUploadPath(scope, targetId);
const result = await withRetry(
() =>
this.client.request<UploadMediaResponse>(token, "POST", path, body, {
redactBodyKeys: ["file_data"],
}),
UPLOAD_RETRY_POLICY,
undefined,
this.logger,
);
// Cache the result for future dedup.
if (opts.fileData && result.file_info && result.ttl > 0 && this.cache) {
const hash = this.cache.computeHash(opts.fileData);
this.cache.set(
hash,
scope,
targetId,
fileType,
result.file_info,
result.file_uuid,
result.ttl,
);
}
return result;
}
/**
* Send a media message (upload result → message) to a C2C or Group target.
*
* @param scope - `'c2c'` or `'group'`.
* @param targetId - User openid or group openid.
* @param fileInfo - `file_info` from a prior upload.
* @param creds - Authentication credentials.
* @param opts - Message options.
*/
async sendMediaMessage(
scope: ChatScope,
targetId: string,
fileInfo: string,
creds: { appId: string; clientSecret: string },
opts?: {
msgId?: string;
content?: string;
},
): Promise<MessageResponse> {
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1;
const path =
scope === "c2c" ? `/v2/users/${targetId}/messages` : `/v2/groups/${targetId}/messages`;
return this.client.request<MessageResponse>(token, "POST", path, {
msg_type: 7,
media: { file_info: fileInfo },
msg_seq: msgSeq,
...(opts?.content ? { content: opts.content } : {}),
...(opts?.msgId ? { msg_id: opts.msgId } : {}),
});
}
}

View File

@@ -0,0 +1,267 @@
/**
* Message sending API for the QQ Open Platform.
*
* Key design improvements:
* - Unified `sendMessage(scope, ...)` replaces `sendC2CMessage` + `sendGroupMessage`.
* - `onMessageSent` hook is scoped to the instance, not a module-level global.
* - Markdown support flag is per-instance, not a global Map.
*/
import type {
ChatScope,
MessageResponse,
OutboundMeta,
EngineLogger,
InlineKeyboard,
} from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
import { ApiClient } from "./api-client.js";
import {
messagePath,
channelMessagePath,
dmMessagePath,
gatewayPath,
interactionPath,
getNextMsgSeq,
} from "./routes.js";
import { TokenManager } from "./token.js";
export interface MessageApiConfig {
/** Whether the QQ Bot has markdown permission. */
markdownSupport: boolean;
/** Logger for diagnostics. */
logger?: EngineLogger;
}
type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
/**
* Message sending module.
*
* Usage:
* ```ts
* const api = new MessageApi(client, tokenMgr, { markdownSupport: true });
* await api.sendMessage('c2c', openid, 'Hello!', { appId, clientSecret, msgId });
* ```
*/
export class MessageApi {
private readonly client: ApiClient;
private readonly tokenManager: TokenManager;
private readonly markdownSupport: boolean;
private readonly logger?: EngineLogger;
private messageSentHook: OnMessageSentCallback | null = null;
constructor(client: ApiClient, tokenManager: TokenManager, config: MessageApiConfig) {
this.client = client;
this.tokenManager = tokenManager;
this.markdownSupport = config.markdownSupport;
this.logger = config.logger;
}
/** Register a callback invoked when a sent message returns a ref_idx. */
onMessageSent(callback: OnMessageSentCallback): void {
this.messageSentHook = callback;
}
/**
* Notify the registered hook about a sent message.
* Use this for media sends that bypass `sendAndNotify`.
*/
notifyMessageSent(refIdx: string, meta: OutboundMeta): void {
if (this.messageSentHook) {
try {
this.messageSentHook(refIdx, meta);
} catch (err) {
this.logger?.error?.(
`[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`,
);
}
}
}
// ---- Unified message sending ----
/**
* Send a text message to a C2C or Group target.
*
* Automatically constructs the correct path, body format (markdown vs plain),
* and message sequence number.
*/
async sendMessage(
scope: ChatScope,
targetId: string,
content: string,
creds: Credentials,
opts?: {
msgId?: string;
messageReference?: string;
inlineKeyboard?: InlineKeyboard;
},
): Promise<MessageResponse> {
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1;
const body = this.buildMessageBody(
content,
opts?.msgId,
msgSeq,
opts?.messageReference,
opts?.inlineKeyboard,
);
const path = messagePath(scope, targetId);
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
}
/** Send a proactive (no msgId) message to a C2C or Group target. */
async sendProactiveMessage(
scope: ChatScope,
targetId: string,
content: string,
creds: Credentials,
): Promise<MessageResponse> {
if (!content?.trim()) {
throw new Error("Proactive message content must not be empty");
}
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
const body = this.buildProactiveBody(content);
const path = messagePath(scope, targetId);
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
}
// ---- Channel / DM ----
/** Send a channel message. */
async sendChannelMessage(opts: {
channelId: string;
content: string;
creds: Credentials;
msgId?: string;
}): Promise<MessageResponse> {
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
return this.client.request<MessageResponse>(token, "POST", channelMessagePath(opts.channelId), {
content: opts.content,
...(opts.msgId ? { msg_id: opts.msgId } : {}),
});
}
/** Send a DM (guild direct message). */
async sendDmMessage(opts: {
guildId: string;
content: string;
creds: Credentials;
msgId?: string;
}): Promise<MessageResponse> {
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
return this.client.request<MessageResponse>(token, "POST", dmMessagePath(opts.guildId), {
content: opts.content,
...(opts.msgId ? { msg_id: opts.msgId } : {}),
});
}
// ---- C2C Input Notify ----
/** Send a typing indicator to a C2C user. */
async sendInputNotify(opts: {
openid: string;
creds: Credentials;
msgId?: string;
inputSecond?: number;
}): Promise<{ refIdx?: string }> {
const inputSecond = opts.inputSecond ?? 60;
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
const msgSeq = opts.msgId ? getNextMsgSeq(opts.msgId) : 1;
const response = await this.client.request<{ ext_info?: { ref_idx?: string } }>(
token,
"POST",
messagePath("c2c", opts.openid),
{
msg_type: 6,
input_notify: { input_type: 1, input_second: inputSecond },
msg_seq: msgSeq,
...(opts.msgId ? { msg_id: opts.msgId } : {}),
},
);
return { refIdx: response.ext_info?.ref_idx };
}
// ---- Interaction ----
/** Acknowledge an INTERACTION_CREATE event. */
async acknowledgeInteraction(
interactionId: string,
creds: Credentials,
code: 0 | 1 | 2 | 3 | 4 | 5 = 0,
): Promise<void> {
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
await this.client.request(token, "PUT", interactionPath(interactionId), { code });
}
// ---- Gateway ----
/** Get the WebSocket gateway URL. */
async getGatewayUrl(creds: Credentials): Promise<string> {
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
const data = await this.client.request<{ url: string }>(token, "GET", gatewayPath());
return data.url;
}
// ---- Internal ----
private async sendAndNotify(
appId: string,
accessToken: string,
method: string,
path: string,
body: unknown,
meta: OutboundMeta,
): Promise<MessageResponse> {
const result = await this.client.request<MessageResponse>(accessToken, method, path, body);
if (result.ext_info?.ref_idx && this.messageSentHook) {
try {
this.messageSentHook(result.ext_info.ref_idx, meta);
} catch (err) {
this.logger?.error?.(
`[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`,
);
}
}
return result;
}
private buildMessageBody(
content: string,
msgId: string | undefined,
msgSeq: number,
messageReference?: string,
inlineKeyboard?: InlineKeyboard,
): Record<string, unknown> {
const body: Record<string, unknown> = this.markdownSupport
? { markdown: { content }, msg_type: 2, msg_seq: msgSeq }
: { content, msg_type: 0, msg_seq: msgSeq };
if (msgId) {
body.msg_id = msgId;
}
if (messageReference && !this.markdownSupport) {
body.message_reference = { message_id: messageReference };
}
if (inlineKeyboard) {
body.keyboard = inlineKeyboard;
}
return body;
}
private buildProactiveBody(content: string): Record<string, unknown> {
return this.markdownSupport ? { markdown: { content }, msg_type: 2 } : { content, msg_type: 0 };
}
}
// ---- Shared helpers ----
/** Credentials needed to authenticate API requests. */
export interface Credentials {
appId: string;
clientSecret: string;
}
// Re-export getNextMsgSeq for consumers that import from messages.ts.
export { getNextMsgSeq } from "./routes.js";

View File

@@ -0,0 +1,219 @@
/**
* Generic retry engine for QQ Bot API requests.
*
* Replaces the three separate retry implementations in the old `api.ts`:
* - `apiRequestWithRetry` (upload retry with exponential backoff)
* - `partFinishWithRetry` (part-finish retry + persistent retry on specific biz codes)
* - `completeUploadWithRetry` (unconditional retry for complete-upload)
*
* All three patterns are expressed as a single `withRetry` function
* parameterized by `RetryPolicy` and optional `PersistentRetryPolicy`.
*/
import type { EngineLogger } from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
/** Standard retry policy with exponential or fixed backoff. */
export interface RetryPolicy {
/** Maximum retry attempts (excluding the initial attempt). */
maxRetries: number;
/** Base delay in milliseconds. */
baseDelayMs: number;
/** Backoff strategy. */
backoff: "exponential" | "fixed";
/**
* Predicate to decide whether an error is retryable.
* Return `false` to immediately rethrow.
* Defaults to always-retry when omitted.
*/
shouldRetry?: (error: Error, attempt: number) => boolean;
}
/**
* Persistent retry policy for specific business error codes.
*
* When `shouldPersistRetry` returns true, the engine switches from
* the standard retry loop into a tight fixed-interval loop bounded
* only by the total timeout.
*/
export interface PersistentRetryPolicy {
/** Total timeout in milliseconds for the persistent retry loop. */
timeoutMs: number;
/** Fixed interval between retries in milliseconds. */
intervalMs: number;
/** Predicate to decide whether an error triggers persistent retry. */
shouldPersistRetry: (error: Error) => boolean;
}
/**
* Execute an async operation with configurable retry semantics.
*
* @param fn - The async operation to retry.
* @param policy - Standard retry configuration.
* @param persistentPolicy - Optional persistent retry for specific error codes.
* @param logger - Optional logger for retry diagnostics.
* @returns The result of the first successful invocation.
*/
export async function withRetry<T>(
fn: () => Promise<T>,
policy: RetryPolicy,
persistentPolicy?: PersistentRetryPolicy,
logger?: EngineLogger,
): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= policy.maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err instanceof Error ? err : new Error(formatErrorMessage(err));
// Check for persistent-retry trigger before standard retry logic.
if (persistentPolicy?.shouldPersistRetry(lastError)) {
(logger?.warn ?? logger?.error)?.(
`[qqbot:retry] Hit persistent-retry trigger, entering persistent loop (timeout=${persistentPolicy.timeoutMs / 1000}s)`,
);
return await persistentRetryLoop(fn, persistentPolicy, logger);
}
// Check whether this error is retryable under the standard policy.
if (policy.shouldRetry?.(lastError, attempt) === false) {
throw lastError;
}
// Schedule the next retry with the configured backoff.
if (attempt < policy.maxRetries) {
const delay =
policy.backoff === "exponential"
? policy.baseDelayMs * Math.pow(2, attempt)
: policy.baseDelayMs;
logger?.debug?.(
`[qqbot:retry] Attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 100)}`,
);
await sleep(delay);
}
}
}
throw lastError!;
}
/**
* Persistent retry loop: fixed-interval retries bounded by a total timeout.
*
* Used for `upload_part_finish` when the server returns specific business
* error codes indicating the backend is still processing.
*/
async function persistentRetryLoop<T>(
fn: () => Promise<T>,
policy: PersistentRetryPolicy,
logger?: EngineLogger,
): Promise<T> {
const deadline = Date.now() + policy.timeoutMs;
let attempt = 0;
let lastError: Error | null = null;
while (Date.now() < deadline) {
try {
const result = await fn();
logger?.debug?.(`[qqbot:retry] Persistent retry succeeded after ${attempt} retries`);
return result;
} catch (err) {
lastError = err instanceof Error ? err : new Error(formatErrorMessage(err));
// If the error is no longer retryable, abort immediately.
if (!policy.shouldPersistRetry(lastError)) {
logger?.error?.(`[qqbot:retry] Persistent retry: error is no longer retryable, aborting`);
throw lastError;
}
attempt++;
const remaining = deadline - Date.now();
if (remaining <= 0) {
break;
}
const actualDelay = Math.min(policy.intervalMs, remaining);
(logger?.warn ?? logger?.error)?.(
`[qqbot:retry] Persistent retry #${attempt}: retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`,
);
await sleep(actualDelay);
}
}
logger?.error?.(
`[qqbot:retry] Persistent retry timed out after ${policy.timeoutMs / 1000}s (${attempt} attempts)`,
);
throw lastError ?? new Error(`Persistent retry timed out (${policy.timeoutMs / 1000}s)`);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ============ Pre-built Retry Policies ============
/** Standard upload retry: exponential backoff, skip 400/401/timeout errors. */
export const UPLOAD_RETRY_POLICY: RetryPolicy = {
maxRetries: 2,
baseDelayMs: 1000,
backoff: "exponential",
shouldRetry: (error) => {
const msg = error.message;
return !(
msg.includes("400") ||
msg.includes("401") ||
msg.includes("Invalid") ||
msg.includes("timeout") ||
msg.includes("Timeout")
);
},
};
/** Complete-upload retry: unconditional retry with exponential backoff. */
export const COMPLETE_UPLOAD_RETRY_POLICY: RetryPolicy = {
maxRetries: 2,
baseDelayMs: 2000,
backoff: "exponential",
// Always retry — complete-upload failures are often transient server-side.
};
/** Part-finish standard retry policy. */
export const PART_FINISH_RETRY_POLICY: RetryPolicy = {
maxRetries: 2,
baseDelayMs: 1000,
backoff: "exponential",
};
/**
* Build a persistent retry policy for part-finish with a specific timeout.
*
* @param retryTimeoutMs - Total timeout (defaults to 2 minutes).
* @param retryableCodes - Business error codes that trigger persistent retry.
*/
export function buildPartFinishPersistentPolicy(
retryTimeoutMs?: number,
retryableCodes: Set<number> = PART_FINISH_RETRYABLE_CODES,
): PersistentRetryPolicy {
return {
timeoutMs: retryTimeoutMs ?? 2 * 60 * 1000,
intervalMs: 1000,
shouldPersistRetry: (error) => {
if (retryableCodes.size === 0) {
return false;
}
// Check for ApiError with matching bizCode.
if ("bizCode" in error && typeof (error as { bizCode?: number }).bizCode === "number") {
return retryableCodes.has((error as { bizCode: number }).bizCode);
}
return false;
},
};
}
/** Business error codes that trigger persistent part-finish retry. */
export const PART_FINISH_RETRYABLE_CODES: Set<number> = new Set([40093001]);
/** upload_prepare error code indicating daily limit exceeded. */
export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;

View File

@@ -0,0 +1,95 @@
/**
* Centralized API route templates for the QQ Open Platform.
*
* Eliminates C2C/Group path duplication by parameterizing on `ChatScope`.
* Inspired by `bot-node-sdk/src/openapi/v1/resource.ts`.
*/
import type { ChatScope } from "../types.js";
/**
* Build the message-send path for C2C or Group.
*
* - C2C: `/v2/users/{id}/messages`
* - Group: `/v2/groups/{id}/messages`
*/
export function messagePath(scope: ChatScope, targetId: string): string {
return scope === "c2c" ? `/v2/users/${targetId}/messages` : `/v2/groups/${targetId}/messages`;
}
/** Channel message path. */
export function channelMessagePath(channelId: string): string {
return `/channels/${channelId}/messages`;
}
/** DM (direct message inside a guild) path. */
export function dmMessagePath(guildId: string): string {
return `/dms/${guildId}/messages`;
}
/**
* Build the media upload (small-file) path for C2C or Group.
*
* - C2C: `/v2/users/{id}/files`
* - Group: `/v2/groups/{id}/files`
*/
export function mediaUploadPath(scope: ChatScope, targetId: string): string {
return scope === "c2c" ? `/v2/users/${targetId}/files` : `/v2/groups/${targetId}/files`;
}
/**
* Build the upload_prepare path for C2C or Group.
*
* - C2C: `/v2/users/{id}/upload_prepare`
* - Group: `/v2/groups/{id}/upload_prepare`
*/
export function uploadPreparePath(scope: ChatScope, targetId: string): string {
return scope === "c2c"
? `/v2/users/${targetId}/upload_prepare`
: `/v2/groups/${targetId}/upload_prepare`;
}
/**
* Build the upload_part_finish path for C2C or Group.
*/
export function uploadPartFinishPath(scope: ChatScope, targetId: string): string {
return scope === "c2c"
? `/v2/users/${targetId}/upload_part_finish`
: `/v2/groups/${targetId}/upload_part_finish`;
}
/**
* Build the complete-upload (files) path for C2C or Group.
* (Same as mediaUploadPath — the complete endpoint reuses the files path.)
*/
export function uploadCompletePath(scope: ChatScope, targetId: string): string {
return mediaUploadPath(scope, targetId);
}
/** Stream message path (C2C only). */
export function streamMessagePath(openid: string): string {
return `/v2/users/${openid}/stream_messages`;
}
/** Gateway URL path. */
export function gatewayPath(): string {
return "/gateway";
}
/** Interaction acknowledgement path. */
export function interactionPath(interactionId: string): string {
return `/interactions/${interactionId}`;
}
// ============ Shared Helpers ============
/**
* Generate a message sequence number in the 0..65535 range.
*
* Used by both `messages.ts` and `media.ts` to avoid duplicate definitions.
*/
export function getNextMsgSeq(_msgId: string): number {
const timePart = Date.now() % 100_000_000;
const random = Math.floor(Math.random() * 65536);
return (timePart ^ random) % 65536;
}

View File

@@ -0,0 +1,271 @@
/**
* Token management for the QQ Open Platform.
*
* All state (cache, singleflight promises, background refresh controllers)
* is encapsulated in the `TokenManager` class instance — no module-level
* globals, fully supporting multi-account concurrent operation.
*/
import type { EngineLogger } from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
interface CachedToken {
token: string;
expiresAt: number;
appId: string;
}
export interface BackgroundRefreshOptions {
refreshAheadMs?: number;
randomOffsetMs?: number;
minRefreshIntervalMs?: number;
retryDelayMs?: number;
}
/**
* Per-appId token manager with caching, singleflight, and background refresh.
*
* Usage:
* ```ts
* const tm = new TokenManager({ logger, userAgent: 'QQBotPlugin/1.0' });
* const token = await tm.getAccessToken('appId', 'secret');
* ```
*/
export class TokenManager {
private readonly cache = new Map<string, CachedToken>();
private readonly fetchPromises = new Map<string, Promise<string>>();
private readonly refreshControllers = new Map<string, AbortController>();
private readonly logger?: EngineLogger;
private readonly resolveUserAgent: () => string;
constructor(config?: { logger?: EngineLogger; userAgent?: string | (() => string) }) {
this.logger = config?.logger;
const ua = config?.userAgent ?? "QQBotPlugin/unknown";
this.resolveUserAgent = typeof ua === "function" ? ua : () => ua;
}
/**
* Obtain an access token with caching and singleflight semantics.
*
* When multiple callers request a token for the same appId concurrently,
* only one actual HTTP request is made — the others await the same promise.
*/
async getAccessToken(appId: string, clientSecret: string): Promise<string> {
const normalizedId = appId.trim();
const cached = this.cache.get(normalizedId);
// Refresh slightly before expiry without making short-lived tokens unusable.
const refreshAheadMs = cached
? Math.min(5 * 60 * 1000, (cached.expiresAt - Date.now()) / 3)
: 0;
if (cached && Date.now() < cached.expiresAt - refreshAheadMs) {
return cached.token;
}
// Singleflight: reuse an in-progress fetch.
let pending = this.fetchPromises.get(normalizedId);
if (pending) {
this.logger?.debug?.(`[qqbot:token:${normalizedId}] Fetch in progress, reusing promise`);
return pending;
}
pending = (async () => {
try {
return await this.doFetchToken(normalizedId, clientSecret);
} finally {
this.fetchPromises.delete(normalizedId);
}
})();
this.fetchPromises.set(normalizedId, pending);
return pending;
}
/** Clear the cached token for one appId, or all. */
clearCache(appId?: string): void {
if (appId) {
this.cache.delete(appId.trim());
this.logger?.debug?.(`[qqbot:token:${appId}] Cache cleared`);
} else {
this.cache.clear();
this.logger?.debug?.(`[token] All caches cleared`);
}
}
/** Return token status for diagnostics. */
getStatus(appId: string): {
status: "valid" | "expired" | "refreshing" | "none";
expiresAt: number | null;
} {
if (this.fetchPromises.has(appId)) {
return { status: "refreshing", expiresAt: this.cache.get(appId)?.expiresAt ?? null };
}
const cached = this.cache.get(appId);
if (!cached) {
return { status: "none", expiresAt: null };
}
const remaining = cached.expiresAt - Date.now();
const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3);
return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt };
}
/** Start a background token refresh loop for one appId. */
startBackgroundRefresh(
appId: string,
clientSecret: string,
options?: BackgroundRefreshOptions,
): void {
if (this.refreshControllers.has(appId)) {
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh already running`);
return;
}
const {
refreshAheadMs = 5 * 60 * 1000,
randomOffsetMs = 30 * 1000,
minRefreshIntervalMs = 60 * 1000,
retryDelayMs = 5 * 1000,
} = options ?? {};
const controller = new AbortController();
this.refreshControllers.set(appId, controller);
const { signal } = controller;
const loop = async () => {
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh started`);
while (!signal.aborted) {
try {
await this.getAccessToken(appId, clientSecret);
const cached = this.cache.get(appId);
if (cached) {
const expiresIn = cached.expiresAt - Date.now();
const randomOffset = Math.random() * randomOffsetMs;
const refreshIn = Math.max(
expiresIn - refreshAheadMs - randomOffset,
minRefreshIntervalMs,
);
this.logger?.debug?.(
`[qqbot:token:${appId}] Next refresh in ${Math.round(refreshIn / 1000)}s`,
);
await this.abortableSleep(refreshIn, signal);
} else {
await this.abortableSleep(minRefreshIntervalMs, signal);
}
} catch (err) {
if (signal.aborted) {
break;
}
this.logger?.error?.(
`[qqbot:token:${appId}] Background refresh failed: ${formatErrorMessage(err)}`,
);
await this.abortableSleep(retryDelayMs, signal);
}
}
this.refreshControllers.delete(appId);
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh stopped`);
};
loop().catch((err) => {
this.refreshControllers.delete(appId);
this.logger?.error?.(`[qqbot:token:${appId}] Background refresh crashed: ${err}`);
});
}
/** Stop background refresh for one appId, or all. */
stopBackgroundRefresh(appId?: string): void {
if (appId) {
const ctrl = this.refreshControllers.get(appId);
if (ctrl) {
ctrl.abort();
this.refreshControllers.delete(appId);
}
} else {
for (const ctrl of this.refreshControllers.values()) {
ctrl.abort();
}
this.refreshControllers.clear();
}
}
/** Check whether background refresh is running. */
isBackgroundRefreshRunning(appId?: string): boolean {
if (appId) {
return this.refreshControllers.has(appId);
}
return this.refreshControllers.size > 0;
}
// ---- Internal ----
private async doFetchToken(appId: string, clientSecret: string): Promise<string> {
this.logger?.debug?.(`[qqbot:token:${appId}] >>> POST ${TOKEN_URL}`);
let response: Response;
try {
response = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": this.resolveUserAgent(),
},
body: JSON.stringify({ appId, clientSecret }),
});
} catch (err) {
this.logger?.error?.(`[qqbot:token:${appId}] Network error: ${formatErrorMessage(err)}`);
throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, {
cause: err,
});
}
const traceId = response.headers.get("x-tps-trace-id") ?? "";
this.logger?.debug?.(
`[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`,
);
let data: { access_token?: string; expires_in?: number };
try {
const rawBody = await response.text();
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`);
data = JSON.parse(rawBody);
} catch (err) {
throw new Error(`Failed to parse access_token response: ${formatErrorMessage(err)}`, {
cause: err,
});
}
if (!data.access_token) {
throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
}
const expiresAt = Date.now() + (data.expires_in ?? 7200) * 1000;
this.cache.set(appId, { token: data.access_token, expiresAt, appId });
this.logger?.debug?.(
`[qqbot:token:${appId}] Cached, expires at: ${new Date(expiresAt).toISOString()}`,
);
return data.access_token;
}
private abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
if (signal.aborted) {
clearTimeout(timer);
reject(new Error("Aborted"));
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(new Error("Aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { buildApprovalKeyboard } from "./index.js";
describe("buildApprovalKeyboard", () => {
it("omits allow-always when the decision is unavailable", () => {
const keyboard = buildApprovalKeyboard("approval-123", ["allow-once", "deny"]);
const buttons = keyboard.content.rows[0]?.buttons ?? [];
expect(buttons.map((button) => button.id)).toEqual(["allow", "deny"]);
expect(buttons.map((button) => button.action.data)).toEqual([
"approve:approval-123:allow-once",
"approve:approval-123:deny",
]);
});
it("keeps all buttons when all decisions are allowed", () => {
const keyboard = buildApprovalKeyboard("approval-123", ["allow-once", "allow-always", "deny"]);
const buttons = keyboard.content.rows[0]?.buttons ?? [];
expect(buttons.map((button) => button.id)).toEqual(["allow", "always", "deny"]);
});
});

View File

@@ -0,0 +1,238 @@
/**
* Approval helpers — pure functions, zero framework dependencies.
*
* - Build approval message text + inline keyboard
* - Resolve delivery target from session metadata
* - Parse INTERACTION_CREATE button data
*/
import type { ChatScope, InlineKeyboard, KeyboardButton } from "../types.js";
// ============ Types ============
export interface ExecApprovalRequest {
id: string;
expiresAtMs: number;
request: {
commandPreview?: string;
command?: string;
cwd?: string;
agentId?: string;
turnSourceAccountId?: string;
sessionKey?: string;
turnSourceTo?: string;
[key: string]: unknown;
};
}
export interface PluginApprovalRequest {
id: string;
request: {
timeoutMs?: number;
severity?: string;
title: string;
description?: string;
toolName?: string;
pluginId?: string;
agentId?: string;
turnSourceAccountId?: string;
sessionKey?: string;
turnSourceTo?: string;
[key: string]: unknown;
};
}
export interface ExecApprovalResolved {
id: string;
decision: string;
resolvedBy?: string;
[key: string]: unknown;
}
export interface PluginApprovalResolved {
id: string;
decision: string;
resolvedBy?: string;
[key: string]: unknown;
}
export type ApprovalDecision = "allow-once" | "allow-always" | "deny";
export interface ApprovalTarget {
type: ChatScope;
id: string;
}
export interface ParsedApprovalAction {
approvalId: string;
decision: ApprovalDecision;
}
// ============ Text Builders ============
export function buildExecApprovalText(request: ExecApprovalRequest): string {
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));
const lines: string[] = ["\u{1f510} \u547d\u4ee4\u6267\u884c\u5ba1\u6279", ""];
const cmd = request.request.commandPreview ?? request.request.command ?? "";
if (cmd) {
lines.push(`\`\`\`\n${cmd.slice(0, 300)}\n\`\`\``);
}
if (request.request.cwd) {
lines.push(`\u{1f4c1} \u76ee\u5f55: ${request.request.cwd}`);
}
if (request.request.agentId) {
lines.push(`\u{1f916} Agent: ${request.request.agentId}`);
}
lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${expiresIn} \u79d2`);
return lines.join("\n");
}
export function buildPluginApprovalText(request: PluginApprovalRequest): string {
const timeoutSec = Math.round((request.request.timeoutMs ?? 120_000) / 1000);
const severityIcon =
request.request.severity === "critical"
? "\u{1f534}"
: request.request.severity === "info"
? "\u{1f535}"
: "\u{1f7e1}";
const lines: string[] = [`${severityIcon} \u5ba1\u6279\u8bf7\u6c42`, ""];
lines.push(`\u{1f4cb} ${request.request.title}`);
if (request.request.description) {
lines.push(`\u{1f4dd} ${request.request.description}`);
}
if (request.request.toolName) {
lines.push(`\u{1f527} \u5de5\u5177: ${request.request.toolName}`);
}
if (request.request.pluginId) {
lines.push(`\u{1f50c} \u63d2\u4ef6: ${request.request.pluginId}`);
}
if (request.request.agentId) {
lines.push(`\u{1f916} Agent: ${request.request.agentId}`);
}
lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${timeoutSec} \u79d2`);
return lines.join("\n");
}
// ============ Keyboard Builder ============
/**
* Build the three-button inline keyboard for approval messages.
*
* type=1 (Callback): click triggers INTERACTION_CREATE, button_data = data field.
* group_id "approval": clicking one button grays out the others (mutual exclusion).
* click_limit=1: each user can only click once.
* permission.type=2: all users can interact.
*/
export function buildApprovalKeyboard(
approvalId: string,
allowedDecisions: readonly ApprovalDecision[] = ["allow-once", "allow-always", "deny"],
): InlineKeyboard {
const makeBtn = (
id: string,
label: string,
visitedLabel: string,
data: string,
style: 0 | 1,
): KeyboardButton => ({
id,
render_data: { label, visited_label: visitedLabel, style },
action: {
type: 1,
data,
permission: { type: 2 },
click_limit: 1,
},
group_id: "approval",
});
const buttons: KeyboardButton[] = [];
if (allowedDecisions.includes("allow-once")) {
buttons.push(
makeBtn(
"allow",
"\u2705 \u5141\u8bb8\u4e00\u6b21",
"\u5df2\u5141\u8bb8",
`approve:${approvalId}:allow-once`,
1,
),
);
}
if (allowedDecisions.includes("allow-always")) {
buttons.push(
makeBtn(
"always",
"\u2b50 \u59cb\u7ec8\u5141\u8bb8",
"\u5df2\u59cb\u7ec8\u5141\u8bb8",
`approve:${approvalId}:allow-always`,
1,
),
);
}
if (allowedDecisions.includes("deny")) {
buttons.push(
makeBtn("deny", "\u274c \u62d2\u7edd", "\u5df2\u62d2\u7edd", `approve:${approvalId}:deny`, 0),
);
}
return {
content: {
rows: [
{
buttons,
},
],
},
};
}
// ============ Target Resolver ============
/**
* Extract the delivery target from a sessionKey or turnSourceTo string.
*
* Expected formats:
* agent:main:qqbot:direct:OPENID -> { type: "c2c", id: "OPENID" }
* agent:main:qqbot:c2c:OPENID -> { type: "c2c", id: "OPENID" }
* agent:main:qqbot:group:GROUPID -> { type: "group", id: "GROUPID" }
*
* Returns null if neither field matches the expected pattern.
*/
export function resolveApprovalTarget(
sessionKey: string | null | undefined,
turnSourceTo: string | null | undefined,
): ApprovalTarget | null {
const sk = sessionKey ?? turnSourceTo;
if (!sk) {
return null;
}
const m = sk.match(/qqbot:(c2c|direct|group):([A-F0-9]+)/i);
if (!m) {
return null;
}
const type: ChatScope = m[1].toLowerCase() === "group" ? "group" : "c2c";
return { type, id: m[2] };
}
// ============ Interaction Parser ============
/**
* Parse the button_data string from an INTERACTION_CREATE event.
*
* Expected format: `approve:<approvalId>:<decision>`
* where approvalId may be prefixed with "exec:" or "plugin:".
*
* Returns null if the data does not match the approval button format.
*/
export function parseApprovalButtonData(buttonData: string): ParsedApprovalAction | null {
const m = buttonData.match(
/^approve:((?:(?:exec|plugin):)?[0-9a-f-]+):(allow-once|allow-always|deny)$/i,
);
if (!m) {
return null;
}
return {
approvalId: m[1],
decision: m[2] as ApprovalDecision,
};
}

View File

@@ -0,0 +1,139 @@
/**
* Slash command handler — intercept slash commands before message queue.
*
* Extracted from gateway.ts to keep the gateway connection logic thin.
* Handles urgent commands, normal slash commands, and file delivery.
*/
import type { QueuedMessage } from "../gateway/message-queue.js";
import type { GatewayAccount, EngineLogger } from "../gateway/types.js";
import { sendDocument } from "../messaging/outbound.js";
import {
sendText as senderSendText,
buildDeliveryTarget,
accountToCreds,
} from "../messaging/sender.js";
import { matchSlashCommand } from "./slash-commands-impl.js";
import type { SlashCommandContext, QueueSnapshot } from "./slash-commands.js";
// ============ Types ============
export interface SlashCommandHandlerContext {
account: GatewayAccount;
log?: EngineLogger;
getMessagePeerId: (msg: QueuedMessage) => string;
getQueueSnapshot: (peerId: string) => QueueSnapshot;
}
// ============ Constants ============
const URGENT_COMMANDS = ["/stop"];
// ============ trySlashCommandOrEnqueue ============
/**
* Check if the message is a slash command and handle it.
*
* @returns `true` if handled (command executed or enqueued as urgent),
* `false` if the message should be queued for normal processing.
*/
export async function trySlashCommand(
msg: QueuedMessage,
ctx: SlashCommandHandlerContext,
): Promise<"handled" | "urgent" | "enqueue"> {
const { account, log } = ctx;
const content = (msg.content ?? "").trim();
if (!content.startsWith("/")) {
return "enqueue";
}
// Urgent command detection — bypass queue and execute immediately.
const contentLower = content.toLowerCase();
const isUrgentCommand = URGENT_COMMANDS.some(
(cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "),
);
if (isUrgentCommand) {
log?.info(`Urgent command detected: ${content.slice(0, 20)}`);
return "urgent";
}
// Normal slash command — try to match and execute.
const receivedAt = Date.now();
const peerId = ctx.getMessagePeerId(msg);
const cmdCtx: SlashCommandContext = {
type: msg.type,
senderId: msg.senderId,
senderName: msg.senderName,
messageId: msg.messageId,
eventTimestamp: msg.timestamp,
receivedAt,
rawContent: content,
args: "",
channelId: msg.channelId,
groupOpenid: msg.groupOpenid,
accountId: account.accountId,
appId: account.appId,
accountConfig: account.config,
commandAuthorized: true,
queueSnapshot: ctx.getQueueSnapshot(peerId),
};
try {
const reply = await matchSlashCommand(cmdCtx);
if (reply === null) {
return "enqueue";
}
log?.debug?.(`Slash command matched: ${content}`);
const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply;
const replyText = isFileResult ? (reply as { text: string }).text : reply;
const replyFile = isFileResult ? (reply as { filePath: string }).filePath : null;
// Send text reply.
if (msg.type === "c2c" || msg.type === "group" || msg.type === "dm" || msg.type === "guild") {
const slashTarget = buildDeliveryTarget(msg);
const slashCreds = accountToCreds(account);
await senderSendText(slashTarget, replyText, slashCreds, { msgId: msg.messageId });
}
// Send file attachment if present.
if (replyFile) {
try {
const targetType =
msg.type === "group"
? "group"
: msg.type === "dm"
? "dm"
: msg.type === "c2c"
? "c2c"
: "channel";
const targetId =
msg.type === "group"
? msg.groupOpenid || msg.senderId
: msg.type === "dm"
? msg.guildId || msg.senderId
: msg.type === "c2c"
? msg.senderId
: msg.channelId || msg.senderId;
await sendDocument(
{
targetType,
targetId,
account,
replyToId: msg.messageId,
},
replyFile,
);
} catch (fileErr) {
log?.error(`Failed to send slash command file: ${String(fileErr)}`);
}
}
return "handled";
} catch (err) {
log?.error(`Slash command error: ${String(err)}`);
return "enqueue";
}
}

View File

@@ -0,0 +1,987 @@
/**
* QQBot plugin-level slash command handler.
*
* Type definitions and the command registry/dispatcher are in
* core/gateway/slash-commands.ts. This file contains the concrete
* built-in command implementations that depend on framework SDK.
*/
import fs from "node:fs";
import path from "node:path";
import { debugLog } from "../utils/log.js";
import { getHomeDir, getQQBotDataDir, isWindows } from "../utils/platform.js";
import {
SlashCommandRegistry,
type SlashCommandContext,
type SlashCommandResult,
type SlashCommandFileResult,
type QQBotFrameworkCommand,
type QueueSnapshot,
} from "./slash-commands.js";
// ---- Injected dependency ----
/** Resolve the framework runtime version — injected to avoid plugin-sdk dependency. */
let _resolveVersion: (() => string) | null = null;
/** Register the version resolver — called by the outer layer. */
export function registerVersionResolver(fn: () => string): void {
_resolveVersion = fn;
}
function resolveRuntimeServiceVersion(): string {
return _resolveVersion?.() ?? "unknown";
}
// Re-export core types for backward compatibility.
export type {
SlashCommandContext,
SlashCommandResult,
SlashCommandFileResult,
QQBotFrameworkCommand,
QueueSnapshot,
} from "./slash-commands.js";
// Plugin version — injected by the outer layer via registerPluginVersion().
let PLUGIN_VERSION = "unknown";
/** Register the plugin version — called by the outer layer. */
export function registerPluginVersion(version: string): void {
if (version) {
PLUGIN_VERSION = version;
}
}
const QQBOT_PLUGIN_GITHUB_URL = "https://github.com/openclaw/openclaw/tree/main/extensions/qqbot";
const QQBOT_UPGRADE_GUIDE_URL = "https://q.qq.com/qqbot/openclaw/upgrade.html";
// ============ Module-level registry instance ============
const registry = new SlashCommandRegistry();
function registerCommand(cmd: {
name: string;
description: string;
usage?: string;
requireAuth?: boolean;
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
}): void {
registry.register(cmd);
}
/**
* Return all commands that require authorization, for registration with the
* framework via api.registerCommand() in registerFull().
*/
export function getFrameworkCommands(): QQBotFrameworkCommand[] {
return registry.getFrameworkCommands();
}
// ============ Built-in commands ============
/**
* /bot-ping — test current network latency between OpenClaw and QQ.
*/
registerCommand({
name: "bot-ping",
description: "测试 OpenClaw 与 QQ 之间的网络延迟",
usage: [
`/bot-ping`,
``,
`测试当前 OpenClaw 宿主机与 QQ 服务器之间的网络延迟。`,
`返回网络传输耗时和插件处理耗时。`,
].join("\n"),
handler: (ctx) => {
const now = Date.now();
const eventTime = new Date(ctx.eventTimestamp).getTime();
if (isNaN(eventTime)) {
return `✅ pong!`;
}
const totalMs = now - eventTime;
const qqToPlugin = ctx.receivedAt - eventTime;
const pluginProcess = now - ctx.receivedAt;
const lines = [
`✅ pong!`,
``,
`⏱ 延迟:${totalMs}ms`,
` ├ 网络传输:${qqToPlugin}ms`,
` └ 插件处理:${pluginProcess}ms`,
];
return lines.join("\n");
},
});
/**
* /bot-version — show both the QQBot plugin version and the OpenClaw
* framework version. Aligned with the standalone `openclaw-qqbot`
* build so users see the same identification regardless of which
* distribution they run.
*
* Note: unlike the standalone build, the built-in plugin is released
* in-tree with the OpenClaw framework (same version), so an online
* npm dist-tag check is not applicable here and is intentionally
* omitted.
*/
registerCommand({
name: "bot-version",
description: "查看 QQBot 插件版本和 OpenClaw 框架版本",
usage: [`/bot-version`, ``, `查看当前 QQBot 插件版本和 OpenClaw 框架版本。`].join("\n"),
handler: async () => {
const frameworkVersion = resolveRuntimeServiceVersion();
const lines = [
`🦞 OpenClaw 框架版本:${frameworkVersion}`,
`🤖 QQBot 插件版本v${PLUGIN_VERSION}`,
`🌟 官方 GitHub 仓库:[点击前往](${QQBOT_PLUGIN_GITHUB_URL})`,
];
return lines.join("\n");
},
});
/**
* /bot-upgrade — show the upgrade guide.
*/
registerCommand({
name: "bot-upgrade",
description: "查看 QQBot 升级指引",
usage: [`/bot-upgrade`, ``, `查看 QQBot 升级说明。`].join("\n"),
handler: () =>
[`📘 QQBot 升级指引:`, `[点击查看升级说明](${QQBOT_UPGRADE_GUIDE_URL})`].join("\n"),
});
/**
* /bot-help — list all built-in QQBot commands.
*/
registerCommand({
name: "bot-help",
description: "查看所有内置命令",
usage: [
`/bot-help`,
``,
`查看所有可用的 QQBot 内置命令及其简要说明。`,
`在命令后追加 ? 可查看详细用法。`,
].join("\n"),
handler: (ctx) => {
// Exclude c2c-only commands from group listings.
const GROUP_EXCLUDED = new Set(["bot-upgrade", "bot-clear-storage"]);
const isGroup = ctx.type === "group";
const lines = [`### QQBot 内置命令`, ``];
for (const [name, cmd] of registry.getAllCommands()) {
if (isGroup && GROUP_EXCLUDED.has(name)) {
continue;
}
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
}
lines.push(``, `> 插件版本 v${PLUGIN_VERSION}`);
return lines.join("\n");
},
});
/** Read user-configured log file paths from local config files. */
function getConfiguredLogFiles(): string[] {
const homeDir = getHomeDir();
const files: string[] = [];
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
try {
const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
if (!fs.existsSync(cfgPath)) {
continue;
}
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
const logFile = cfg?.logging?.file;
if (logFile && typeof logFile === "string") {
files.push(path.resolve(logFile));
}
break;
} catch {
// ignore
}
}
return files;
}
/** Collect directories that may contain runtime logs across common install layouts. */
function collectCandidateLogDirs(): string[] {
const homeDir = getHomeDir();
const dirs = new Set<string>();
const pushDir = (p?: string) => {
if (!p) {
return;
}
const normalized = path.resolve(p);
dirs.add(normalized);
};
const pushStateDir = (stateDir?: string) => {
if (!stateDir) {
return;
}
pushDir(stateDir);
pushDir(path.join(stateDir, "logs"));
};
for (const logFile of getConfiguredLogFiles()) {
pushDir(path.dirname(logFile));
}
for (const [key, value] of Object.entries(process.env)) {
if (!value) {
continue;
}
if (/STATE_DIR$/i.test(key) && /(OPENCLAW|CLAWDBOT|MOLTBOT)/i.test(key)) {
pushStateDir(value);
}
}
for (const name of [".openclaw", ".clawdbot", ".moltbot", "openclaw", "clawdbot", "moltbot"]) {
pushDir(path.join(homeDir, name));
pushDir(path.join(homeDir, name, "logs"));
}
const searchRoots = new Set<string>([homeDir, process.cwd(), path.dirname(process.cwd())]);
if (process.env.APPDATA) {
searchRoots.add(process.env.APPDATA);
}
if (process.env.LOCALAPPDATA) {
searchRoots.add(process.env.LOCALAPPDATA);
}
for (const root of searchRoots) {
try {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (!/(openclaw|clawdbot|moltbot)/i.test(entry.name)) {
continue;
}
const base = path.join(root, entry.name);
pushDir(base);
pushDir(path.join(base, "logs"));
}
} catch {
// Ignore missing or inaccessible directories.
}
}
// Common Linux log directories under /var/log.
if (!isWindows()) {
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
pushDir(path.join("/var/log", name));
}
}
// Temporary directories may also contain gateway logs.
const tmpRoots = new Set<string>();
if (isWindows()) {
// Windows temp locations.
tmpRoots.add("C:\\tmp");
if (process.env.TEMP) {
tmpRoots.add(process.env.TEMP);
}
if (process.env.TMP) {
tmpRoots.add(process.env.TMP);
}
if (process.env.LOCALAPPDATA) {
tmpRoots.add(path.join(process.env.LOCALAPPDATA, "Temp"));
}
} else {
tmpRoots.add("/tmp");
}
for (const tmpRoot of tmpRoots) {
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
pushDir(path.join(tmpRoot, name));
}
}
return Array.from(dirs);
}
type LogCandidate = {
filePath: string;
sourceDir: string;
mtimeMs: number;
};
function collectRecentLogFiles(logDirs: string[]): LogCandidate[] {
const candidates: LogCandidate[] = [];
const dedupe = new Set<string>();
const pushFile = (filePath: string, sourceDir: string) => {
const normalized = path.resolve(filePath);
if (dedupe.has(normalized)) {
return;
}
try {
const stat = fs.statSync(normalized);
if (!stat.isFile()) {
return;
}
dedupe.add(normalized);
candidates.push({ filePath: normalized, sourceDir, mtimeMs: stat.mtimeMs });
} catch {
// Ignore missing or inaccessible files.
}
};
// Highest priority: explicit logging.file paths from config.
for (const logFile of getConfiguredLogFiles()) {
pushFile(logFile, path.dirname(logFile));
}
for (const dir of logDirs) {
pushFile(path.join(dir, "gateway.log"), dir);
pushFile(path.join(dir, "gateway.err.log"), dir);
pushFile(path.join(dir, "openclaw.log"), dir);
pushFile(path.join(dir, "clawdbot.log"), dir);
pushFile(path.join(dir, "moltbot.log"), dir);
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
if (!/\.(log|txt)$/i.test(entry.name)) {
continue;
}
if (!/(gateway|openclaw|clawdbot|moltbot)/i.test(entry.name)) {
continue;
}
pushFile(path.join(dir, entry.name), dir);
}
} catch {
// Ignore missing or inaccessible directories.
}
}
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
return candidates;
}
/**
* Read the last N lines of a file without loading the entire file into memory.
* Uses a reverse-read strategy: reads fixed-size chunks from the end of the
* file until the requested number of newline characters are found.
*
* Also estimates the total line count from the file size and the average bytes
* per line observed in the tail portion (exact count is not feasible for
* multi-GB files without a full scan).
*/
function tailFileLines(
filePath: string,
maxLines: number,
): { tail: string[]; totalFileLines: number } {
const fd = fs.openSync(filePath, "r");
try {
const stat = fs.fstatSync(fd);
const fileSize = stat.size;
if (fileSize === 0) {
return { tail: [], totalFileLines: 0 };
}
const CHUNK_SIZE = 64 * 1024;
const chunks: Buffer[] = [];
let bytesRead = 0;
let position = fileSize;
let newlineCount = 0;
while (position > 0 && newlineCount <= maxLines) {
const readSize = Math.min(CHUNK_SIZE, position);
position -= readSize;
const buf = Buffer.alloc(readSize);
fs.readSync(fd, buf, 0, readSize, position);
chunks.unshift(buf);
bytesRead += readSize;
for (let i = 0; i < readSize; i++) {
if (buf[i] === 0x0a) {
newlineCount++;
}
}
}
const tailContent = Buffer.concat(chunks).toString("utf8");
const allLines = tailContent.split("\n");
const tail = allLines.slice(-maxLines);
let totalFileLines: number;
if (bytesRead >= fileSize) {
totalFileLines = allLines.length;
} else {
const avgBytesPerLine = bytesRead / Math.max(allLines.length, 1);
totalFileLines = Math.round(fileSize / avgBytesPerLine);
}
return { tail, totalFileLines };
} finally {
fs.closeSync(fd);
}
}
/**
* Build the /bot-logs result: collect recent log files, write them to a temp
* file, and return the summary text plus the temp file path.
*
* Authorization is enforced upstream by the framework (registerCommand with
* requireAuth:true); this function contains no auth logic.
*
* Returns a SlashCommandFileResult on success (text + filePath), or a plain
* string error message when no logs are found or files cannot be read.
*/
function buildBotLogsResult(): SlashCommandResult {
const logDirs = collectCandidateLogDirs();
const recentFiles = collectRecentLogFiles(logDirs).slice(0, 4);
if (recentFiles.length === 0) {
const existingDirs = logDirs.filter((d) => {
try {
return fs.existsSync(d);
} catch {
return false;
}
});
const searched =
existingDirs.length > 0
? existingDirs.map((d) => `${d}`).join("\n")
: logDirs
.slice(0, 6)
.map((d) => `${d}`)
.join("\n") + (logDirs.length > 6 ? `\n …以及另外 ${logDirs.length - 6} 个路径` : "");
return [
`⚠️ 未找到日志文件`,
``,
`已搜索以下${existingDirs.length > 0 ? "存在的" : ""}路径:`,
searched,
``,
`💡 如果日志存放在自定义路径,请在配置中添加:`,
` "logging": { "file": "/path/to/your/logfile.log" }`,
].join("\n");
}
const lines: string[] = [];
let totalIncluded = 0;
let totalOriginal = 0;
let truncatedCount = 0;
const MAX_LINES_PER_FILE = 1000;
for (const logFile of recentFiles) {
try {
const { tail, totalFileLines } = tailFileLines(logFile.filePath, MAX_LINES_PER_FILE);
if (tail.length > 0) {
const fileName = path.basename(logFile.filePath);
lines.push(
`\n========== ${fileName} (last ${tail.length} of ${totalFileLines} lines) ==========`,
);
lines.push(`from: ${logFile.sourceDir}`);
lines.push(...tail);
totalIncluded += tail.length;
totalOriginal += totalFileLines;
if (totalFileLines > MAX_LINES_PER_FILE) {
truncatedCount++;
}
}
} catch {
lines.push(`[Failed to read ${path.basename(logFile.filePath)}]`);
}
}
if (lines.length === 0) {
return `⚠️ 找到了日志文件,但无法读取。请检查文件权限。`;
}
const tmpDir = getQQBotDataDir("downloads");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const tmpFile = path.join(tmpDir, `bot-logs-${timestamp}.txt`);
fs.writeFileSync(tmpFile, lines.join("\n"), "utf8");
const fileCount = recentFiles.length;
const topSources = Array.from(new Set(recentFiles.map((item) => item.sourceDir))).slice(0, 3);
let summaryText = `${fileCount} 个日志文件,包含 ${totalIncluded} 行内容`;
if (truncatedCount > 0) {
summaryText += `(其中 ${truncatedCount} 个文件已截断为最后 ${MAX_LINES_PER_FILE} 行,总计原始 ${totalOriginal} 行)`;
}
return {
text: `📋 ${summaryText}\n📂 来源:${topSources.join(" | ")}`,
filePath: tmpFile,
};
}
registerCommand({
name: "bot-logs",
description: "导出本地日志文件",
requireAuth: true,
usage: [
`/bot-logs`,
``,
`导出最近的 OpenClaw 日志文件(最多 4 个文件)。`,
`每个文件只保留最后 1000 行,并作为附件返回。`,
].join("\n"),
handler: (ctx) => {
// Defense in depth: require an explicit QQ allowlist entry for log export.
// This keeps `/bot-logs` closed when setup leaves allowFrom in permissive mode.
if (!hasExplicitCommandAllowlist(ctx.accountConfig)) {
return `⛔ 权限不足:请先在 channels.qqbot.allowFrom或对应账号 allowFrom中配置明确的发送者列表后再使用 /bot-logs。`;
}
return buildBotLogsResult();
},
});
// ============ /bot-clear-storage ============
/** Recursively scan all files under a directory, sorted by size descending. */
function scanDirectoryFiles(dirPath: string): { filePath: string; size: number }[] {
const files: { filePath: string; size: number }[] = [];
if (!fs.existsSync(dirPath)) {
return files;
}
const walk = (dir: string) => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile()) {
try {
const stat = fs.statSync(fullPath);
files.push({ filePath: fullPath, size: stat.size });
} catch {
// Skip inaccessible files.
}
}
}
};
walk(dirPath);
files.sort((a, b) => b.size - a.size);
return files;
}
/** Format byte count into a human-readable string. */
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
/** Recursively remove empty directories (leaf-to-root). */
function removeEmptyDirs(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
return;
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dirPath, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
removeEmptyDirs(path.join(dirPath, entry.name));
}
}
try {
const remaining = fs.readdirSync(dirPath);
if (remaining.length === 0) {
fs.rmdirSync(dirPath);
}
} catch {
// Directory may be in use, skip.
}
}
/** Maximum number of files to display in the scan preview. */
const CLEAR_STORAGE_MAX_DISPLAY = 10;
registerCommand({
name: "bot-clear-storage",
description: "清理通过 QQBot 对话产生的下载文件,释放主机磁盘空间",
usage: [
`/bot-clear-storage`,
``,
`扫描当前机器人产生的下载文件并列出明细。`,
`确认后执行删除,释放主机磁盘空间。`,
``,
`/bot-clear-storage --force 确认执行清理`,
``,
`⚠️ 仅在私聊中可用。`,
].join("\n"),
handler: (ctx) => {
const { appId, type } = ctx;
if (type !== "c2c") {
return `💡 请在私聊中使用此指令`;
}
const isForce = ctx.args.trim() === "--force";
const targetDir = path.join(getHomeDir(), ".openclaw", "media", "qqbot", "downloads", appId);
const displayDir = `~/.openclaw/media/qqbot/downloads/${appId}`;
if (!isForce) {
// Step 1: scan and display file list with a confirmation button.
const files = scanDirectoryFiles(targetDir);
if (files.length === 0) {
return [`✅ 当前没有需要清理的文件`, ``, `目录 \`${displayDir}\` 为空或不存在。`].join(
"\n",
);
}
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
const lines: string[] = [
`即将清理 \`${displayDir}\` 目录下所有文件,总共 ${files.length} 个文件,占用磁盘存储空间 ${formatBytes(totalSize)}`,
``,
`目录文件概况:`,
];
const displayFiles = files.slice(0, CLEAR_STORAGE_MAX_DISPLAY);
for (const f of displayFiles) {
const relativePath = path.relative(targetDir, f.filePath).replace(/\\/g, "/");
lines.push(`${relativePath} (${formatBytes(f.size)})`, ``, ``);
}
if (files.length > CLEAR_STORAGE_MAX_DISPLAY) {
lines.push(`...[合计:${files.length} 个文件(${formatBytes(totalSize)}]`, ``);
}
lines.push(
``,
`---`,
``,
`确认清理后,上述保存在 OpenClaw 运行主机磁盘上的文件将永久删除,后续对话过程中 AI 无法再找回相关文件。`,
`‼️ 点击指令确认删除`,
`<qqbot-cmd-enter text="/bot-clear-storage --force" />`,
);
return lines.join("\n");
}
// Step 2: --force — execute deletion.
const files = scanDirectoryFiles(targetDir);
if (files.length === 0) {
return `✅ 目录已为空,无需清理`;
}
let deletedCount = 0;
let deletedSize = 0;
let failedCount = 0;
for (const f of files) {
try {
fs.unlinkSync(f.filePath);
deletedCount++;
deletedSize += f.size;
} catch {
failedCount++;
}
}
try {
removeEmptyDirs(targetDir);
} catch {
// Non-critical, silently ignore.
}
if (failedCount === 0) {
return [
`✅ 清理成功`,
``,
`已删除 ${deletedCount} 个文件,释放 ${formatBytes(deletedSize)} 磁盘空间。`,
].join("\n");
}
return [
`⚠️ 部分清理完成`,
``,
`已删除 ${deletedCount} 个文件(${formatBytes(deletedSize)}${failedCount} 个文件删除失败。`,
].join("\n");
},
});
// ============ /bot-approve 审批配置管理 ============
/** Injected runtime getter — set by the outer bootstrap layer. */
let _runtimeGetter:
| (() => {
config: {
loadConfig: () => Record<string, unknown>;
writeConfigFile: (cfg: unknown) => Promise<void>;
};
})
| null = null;
/** Register the runtime getter — called by the outer layer during startup. */
export function registerApproveRuntimeGetter(
getter: () => {
config: {
loadConfig: () => Record<string, unknown>;
writeConfigFile: (cfg: unknown) => Promise<void>;
};
},
): void {
_runtimeGetter = getter;
}
/**
* /bot-approve — 管理命令执行审批配置
*
* 修改 openclaw.json 中 tools.exec.security / tools.exec.ask 字段。
*
* security: deny | allowlist | full
* ask: off | on-miss | always
*/
registerCommand({
name: "bot-approve",
description: "管理命令执行审批配置",
usage: [
`/bot-approve 查看操作指引`,
`/bot-approve on 开启审批(白名单模式,推荐)`,
`/bot-approve off 关闭审批,命令直接执行`,
`/bot-approve always 始终审批,每次执行都需审批`,
`/bot-approve reset 恢复框架默认值`,
`/bot-approve status 查看当前审批配置`,
].join("\n"),
handler: async (ctx) => {
const arg = ctx.args.trim().toLowerCase();
let runtime: ReturnType<NonNullable<typeof _runtimeGetter>>;
try {
if (!_runtimeGetter) {
throw new Error("runtime not available");
}
runtime = _runtimeGetter();
} catch {
// runtime 不可用时返回操作指引
return [
`🔐 命令执行审批配置`,
``,
`❌ 当前环境不支持在线配置修改,请通过 CLI 手动配置:`,
``,
`\`\`\`shell`,
`# 开启审批(白名单模式)`,
`openclaw config set tools.exec.security allowlist`,
`openclaw config set tools.exec.ask on-miss`,
``,
`# 关闭审批`,
`openclaw config set tools.exec.security full`,
`openclaw config set tools.exec.ask off`,
`\`\`\``,
].join("\n");
}
const configApi = runtime.config;
const loadExecConfig = () => {
const cfg = configApi.loadConfig();
const tools = (cfg.tools ?? {}) as Record<string, unknown>;
const exec = (tools.exec ?? {}) as Record<string, unknown>;
const security = typeof exec.security === "string" ? exec.security : "deny";
const ask = typeof exec.ask === "string" ? exec.ask : "on-miss";
return { security, ask };
};
const writeExecConfig = async (security: string, ask: string) => {
const cfg = structuredClone(configApi.loadConfig());
const tools = (cfg.tools ?? {}) as Record<string, unknown>;
const exec = (tools.exec ?? {}) as Record<string, unknown>;
exec.security = security;
exec.ask = ask;
tools.exec = exec;
cfg.tools = tools;
await configApi.writeConfigFile(cfg);
};
const formatStatus = (security: string, ask: string) => {
const secIcon = security === "full" ? "🟢" : security === "allowlist" ? "🟡" : "🔴";
const askIcon = ask === "off" ? "🟢" : ask === "always" ? "🔴" : "🟡";
return [
`🔐 当前审批配置`,
``,
`${secIcon} 安全模式 (security): **${security}**`,
`${askIcon} 审批模式 (ask): **${ask}**`,
``,
security === "deny"
? `⚠️ 当前为 deny 模式,所有命令执行被拒绝`
: security === "full" && ask === "off"
? `✅ 所有命令无需审批直接执行`
: security === "allowlist" && ask === "on-miss"
? `🛡️ 白名单命令直接执行,其余需审批`
: ask === "always"
? `🔒 每次命令执行都需要人工审批`
: ` security=${security}, ask=${ask}`,
].join("\n");
};
// 无参数:操作指引
if (!arg) {
return [
`🔐 命令执行审批配置`,
``,
`<qqbot-cmd-input text="/bot-approve on" show="/bot-approve on"/> 开启审批(白名单模式)`,
`<qqbot-cmd-input text="/bot-approve off" show="/bot-approve off"/> 关闭审批`,
`<qqbot-cmd-input text="/bot-approve always" show="/bot-approve always"/> 严格模式`,
`<qqbot-cmd-input text="/bot-approve reset" show="/bot-approve reset"/> 恢复默认`,
`<qqbot-cmd-input text="/bot-approve status" show="/bot-approve status"/> 查看当前配置`,
].join("\n");
}
// status: 查看当前配置
if (arg === "status") {
const { security, ask } = loadExecConfig();
return [
formatStatus(security, ask),
``,
`<qqbot-cmd-input text="/bot-approve on" show="/bot-approve on"/> 开启审批`,
`<qqbot-cmd-input text="/bot-approve off" show="/bot-approve off"/> 关闭审批`,
`<qqbot-cmd-input text="/bot-approve always" show="/bot-approve always"/> 严格模式`,
`<qqbot-cmd-input text="/bot-approve reset" show="/bot-approve reset"/> 恢复默认`,
].join("\n");
}
// on: 开启审批(白名单 + 未命中审批)
if (arg === "on") {
try {
await writeExecConfig("allowlist", "on-miss");
return [
`✅ 审批已开启`,
``,
`• security = allowlist白名单模式`,
`• ask = on-miss未命中白名单时需审批`,
``,
`已批准的命令自动加入白名单,下次直接执行。`,
].join("\n");
} catch (err: unknown) {
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
}
}
// off: 关闭审批
if (arg === "off") {
try {
await writeExecConfig("full", "off");
return [
`✅ 审批已关闭`,
``,
`• security = full允许所有命令`,
`• ask = off不需要审批`,
``,
`⚠️ 所有命令将直接执行,不会弹出审批确认。`,
].join("\n");
} catch (err: unknown) {
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
}
}
// always: 始终审批
if (arg === "always" || arg === "strict") {
try {
await writeExecConfig("allowlist", "always");
return [
`✅ 已切换为严格审批模式`,
``,
`• security = allowlist`,
`• ask = always每次执行都需审批`,
``,
`每个命令都会弹出审批按钮,需手动确认。`,
].join("\n");
} catch (err: unknown) {
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
}
}
// reset: 删除配置,恢复框架默认值
if (arg === "reset") {
try {
const cfg = structuredClone(configApi.loadConfig());
const tools = (cfg.tools ?? {}) as Record<string, unknown>;
const exec = (tools.exec ?? {}) as Record<string, unknown>;
delete exec.security;
delete exec.ask;
if (Object.keys(exec).length === 0) {
delete tools.exec;
} else {
tools.exec = exec;
}
if (Object.keys(tools).length === 0) {
delete cfg.tools;
} else {
cfg.tools = tools;
}
await configApi.writeConfigFile(cfg);
return [
`✅ 审批配置已重置`,
``,
`已移除 tools.exec.security 和 tools.exec.ask`,
`框架将使用默认值security=deny, ask=on-miss`,
``,
`如需开启命令执行,请使用 /bot-approve on`,
].join("\n");
} catch (err: unknown) {
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
}
}
return [
`❌ 未知参数: ${arg}`,
``,
`可用选项: on | off | always | reset | status`,
`输入 /bot-approve ? 查看详细用法`,
].join("\n");
},
});
// Slash command entry point — delegates to core/ registry.
/**
* Try to match and execute a plugin-level slash command.
*
* @returns A reply when matched, or null when the message should continue through normal routing.
*/
export async function matchSlashCommand(ctx: SlashCommandContext): Promise<SlashCommandResult> {
return registry.matchSlashCommand(ctx, { info: debugLog });
}
/** Return the plugin version for external callers. */
export function getPluginVersion(): string {
return PLUGIN_VERSION;
}
// Utility used by /bot-logs command.
function normalizeCommandAllowlistEntry(entry: unknown): string {
if (
typeof entry === "string" ||
typeof entry === "number" ||
typeof entry === "boolean" ||
typeof entry === "bigint"
) {
return `${entry}`
.trim()
.replace(/^qqbot:\s*/i, "")
.trim();
}
return "";
}
function hasExplicitCommandAllowlist(accountConfig?: Record<string, unknown>): boolean {
const allowFrom = accountConfig?.allowFrom;
if (!Array.isArray(allowFrom) || allowFrom.length === 0) {
return false;
}
return allowFrom.every((entry) => {
const normalized = normalizeCommandAllowlistEntry(entry);
return normalized.length > 0 && normalized !== "*";
});
}

View File

@@ -0,0 +1,186 @@
/**
* Slash command registration and dispatch framework.
*
* This module provides the type definitions, command registry, and
* `matchSlashCommand` dispatcher that both plugin versions share.
*
* Concrete command implementations (e.g. `/bot-ping`, `/bot-logs`) are
* registered by the upper-layer bootstrap code, NOT defined here.
*
* Zero external dependencies.
*/
// ============ Types ============
/** Slash command context (message metadata plus runtime state). */
export interface SlashCommandContext {
/** Message type. */
type: "c2c" | "guild" | "dm" | "group";
/** Sender ID. */
senderId: string;
/** Sender display name. */
senderName?: string;
/** Message ID used for passive replies. */
messageId: string;
/** Event timestamp from QQ as an ISO string. */
eventTimestamp: string;
/** Local receipt timestamp in milliseconds. */
receivedAt: number;
/** Raw message content. */
rawContent: string;
/** Command arguments after stripping the command name. */
args: string;
/** Channel ID for guild messages. */
channelId?: string;
/** Group openid for group messages. */
groupOpenid?: string;
/** Account ID. */
accountId: string;
/** Bot App ID. */
appId: string;
/** Account config available to the command handler. */
accountConfig?: Record<string, unknown>;
/** Whether the sender is authorized per the allowFrom config. */
commandAuthorized: boolean;
/** Queue snapshot for the current sender. */
queueSnapshot: QueueSnapshot;
}
/** Queue status snapshot. */
export interface QueueSnapshot {
totalPending: number;
activeUsers: number;
maxConcurrentUsers: number;
senderPending: number;
}
/** Slash command result: text, a text+file result, or null to skip handling. */
export type SlashCommandResult = string | SlashCommandFileResult | null;
/** Slash command result that sends text first and then a local file. */
export interface SlashCommandFileResult {
text: string;
/** Local file path to send. */
filePath: string;
}
/** Slash command definition. */
export interface SlashCommand {
/** Command name without the leading slash. */
name: string;
/** Short description. */
description: string;
/** Detailed usage text shown by `/command ?`. */
usage?: string;
/** When true, the command requires the sender to pass the allowFrom authorization check. */
requireAuth?: boolean;
/** Command handler. */
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
}
/** Framework command definition for commands that require authorization. */
export interface QQBotFrameworkCommand {
name: string;
description: string;
usage?: string;
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
}
// ============ Command Registry ============
/** Lowercase and trim a string. */
function lc(s: string): string {
return (s ?? "").toLowerCase().trim();
}
/**
* Slash command registry.
*
* Maintains two maps:
* - `commands` — pre-dispatch commands (requireAuth: false)
* - `frameworkCommands` — auth-gated commands (requireAuth: true)
*/
export class SlashCommandRegistry {
private readonly commands = new Map<string, SlashCommand>();
private readonly frameworkCommands = new Map<string, SlashCommand>();
/** Register one command. */
register(cmd: SlashCommand): void {
if (cmd.requireAuth) {
this.frameworkCommands.set(lc(cmd.name), cmd);
} else {
this.commands.set(lc(cmd.name), cmd);
}
}
/** Return all auth-gated commands for framework registration. */
getFrameworkCommands(): QQBotFrameworkCommand[] {
return Array.from(this.frameworkCommands.values()).map((cmd) => ({
name: cmd.name,
description: cmd.description,
usage: cmd.usage,
handler: cmd.handler,
}));
}
/** Return all pre-dispatch commands. */
getPreDispatchCommands(): Map<string, SlashCommand> {
return this.commands;
}
/** Return all registered commands (both maps) for help listing. */
getAllCommands(): Map<string, SlashCommand> {
const all = new Map<string, SlashCommand>();
for (const [k, v] of this.commands) {
all.set(k, v);
}
for (const [k, v] of this.frameworkCommands) {
all.set(k, v);
}
return all;
}
/**
* Try to match and execute a pre-dispatch slash command.
*
* @returns A reply when matched, or null when the message should continue
* through normal routing.
*/
async matchSlashCommand(
ctx: SlashCommandContext,
log?: { info?: (msg: string) => void },
): Promise<SlashCommandResult> {
const content = ctx.rawContent.trim();
if (!content.startsWith("/")) {
return null;
}
const spaceIdx = content.indexOf(" ");
const cmdName = lc(spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx));
const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim();
const cmd = this.commands.get(cmdName);
if (!cmd) {
return null;
}
// Gate sensitive commands behind the allowFrom authorization check.
if (cmd.requireAuth && !ctx.commandAuthorized) {
log?.info?.(
`[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`,
);
return `⛔ 权限不足:/${cmd.name} 需要管理员权限。`;
}
// `/command ?` returns usage help.
if (args === "?") {
if (cmd.usage) {
return `📖 /${cmd.name} 用法:\n\n${cmd.usage}`;
}
return `/${cmd.name} - ${cmd.description}`;
}
ctx.args = args;
return await cmd.handler(ctx);
}
}

View File

@@ -0,0 +1,27 @@
/**
* AllowFrom normalization — zero external dependency version.
*
* Extracted from channel-config-shared.ts. The original used
* `normalizeStringifiedOptionalString` from plugin-sdk, which is
* just `String(x).trim()` for non-null primitives.
*/
/** Normalize a config entry to a trimmed string (empty string for null/undefined). */
function normalizeEntry(entry: unknown): string {
if (entry === null || entry === undefined) {
return "";
}
if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") {
return String(entry).trim();
}
return "";
}
/** Normalize allowFrom entries: strip `qqbot:` prefix, uppercase. */
export function formatAllowFrom(params: { allowFrom: unknown[] | undefined | null }): string[] {
return (params.allowFrom ?? [])
.map((entry) => normalizeEntry(entry))
.filter((entry): entry is string => entry.length > 0)
.map((entry) => entry.replace(/^qqbot:/i, ""))
.map((entry) => entry.toUpperCase());
}

View File

@@ -0,0 +1,88 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "../utils/data-paths.js";
import { loadCredentialBackup, saveCredentialBackup } from "./credential-backup.js";
/**
* These tests write to `~/.openclaw/qqbot/data` under a test-specific
* accountId prefix and clean up after themselves. Mirrors the approach
* used by `platform.test.ts` in the same package.
*/
describe("engine/config/credential-backup", () => {
const acct = `test-cb-${process.pid}-${Date.now()}`;
const legacyPath = getLegacyCredentialBackupFile();
let legacyBackup: string | null = null;
beforeEach(() => {
// Preserve any legacy backup that might happen to live in the user's
// real home so we can restore it after the test.
legacyBackup = null;
if (fs.existsSync(legacyPath)) {
legacyBackup = fs.readFileSync(legacyPath, "utf8");
fs.unlinkSync(legacyPath);
}
});
afterEach(() => {
try {
fs.unlinkSync(getCredentialBackupFile(acct));
} catch {
/* ignore */
}
if (fs.existsSync(legacyPath)) {
fs.unlinkSync(legacyPath);
}
if (legacyBackup != null) {
fs.writeFileSync(legacyPath, legacyBackup);
}
});
it("round-trips a credential snapshot", () => {
saveCredentialBackup(acct, "app-1", "secret-1");
const loaded = loadCredentialBackup(acct);
expect(loaded?.appId).toBe("app-1");
expect(loaded?.clientSecret).toBe("secret-1");
expect(loaded?.accountId).toBe(acct);
expect(fs.existsSync(getCredentialBackupFile(acct))).toBe(true);
});
it("returns null when no backup exists", () => {
expect(loadCredentialBackup(acct)).toBeNull();
});
it("returns null when legacy backup belongs to a different accountId", () => {
fs.writeFileSync(
legacyPath,
JSON.stringify({
accountId: "other-acct",
appId: "app-old",
clientSecret: "secret-old",
savedAt: new Date().toISOString(),
}),
);
expect(loadCredentialBackup(acct)).toBeNull();
});
it("migrates legacy single-file backup to per-account path on load", () => {
fs.writeFileSync(
legacyPath,
JSON.stringify({
accountId: acct,
appId: "app-1",
clientSecret: "secret-1",
savedAt: new Date().toISOString(),
}),
);
const loaded = loadCredentialBackup(acct);
expect(loaded?.appId).toBe("app-1");
expect(fs.existsSync(legacyPath)).toBe(false);
expect(fs.existsSync(getCredentialBackupFile(acct))).toBe(true);
});
it("ignores empty appId/clientSecret on save", () => {
saveCredentialBackup(acct, "", "secret");
saveCredentialBackup(acct, "app", "");
expect(fs.existsSync(getCredentialBackupFile(acct))).toBe(false);
});
});

View File

@@ -0,0 +1,103 @@
/**
* Credential backup & recovery.
* 凭证暂存与恢复。
*
* Solves the "hot-upgrade interrupted, appId/secret vanished from
* openclaw.json" failure mode.
*
* Mechanics:
* - After each successful gateway start we snapshot the currently
* resolved `appId` / `clientSecret` to a per-account backup file.
* - During plugin startup, if the live config has an empty appId or
* secret, the gateway consults the backup and restores the values
* via `writeConfigFile`.
* - Backups live under `~/.openclaw/qqbot/data/` so they survive
* plugin directory replacement.
*
* Safety notes:
* - Only restore when credentials are **actually empty** — never
* overwrite a user's intentional config change.
* - Atomic write (temp file + rename) to avoid torn files.
* - Per-account file: `credential-backup-<accountId>.json`. We do
* **not** also key by appId because recovery happens precisely
* when appId is unknown.
* - Legacy single `credential-backup.json` is migrated automatically
* when the stored accountId matches the caller.
*/
import fs from "node:fs";
import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "../utils/data-paths.js";
interface CredentialBackup {
accountId: string;
appId: string;
clientSecret: string;
savedAt: string;
}
/** Persist a credential snapshot (called once gateway reaches READY). */
export function saveCredentialBackup(accountId: string, appId: string, clientSecret: string): void {
if (!appId || !clientSecret) {
return;
}
try {
const backupPath = getCredentialBackupFile(accountId);
const data: CredentialBackup = {
accountId,
appId,
clientSecret,
savedAt: new Date().toISOString(),
};
const tmpPath = `${backupPath}.tmp`;
fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
fs.renameSync(tmpPath, backupPath);
} catch {
/* best-effort — ignore */
}
}
/**
* Load a credential snapshot for `accountId`.
*
* Consults the new per-account file first; falls back to the legacy
* global backup file and migrates it when the embedded `accountId`
* matches the request. Returns `null` when no usable backup exists.
*/
export function loadCredentialBackup(accountId?: string): CredentialBackup | null {
try {
if (accountId) {
const newPath = getCredentialBackupFile(accountId);
if (fs.existsSync(newPath)) {
const data = JSON.parse(fs.readFileSync(newPath, "utf8")) as CredentialBackup;
if (data?.appId && data.clientSecret) {
return data;
}
}
}
const legacy = getLegacyCredentialBackupFile();
if (fs.existsSync(legacy)) {
const data = JSON.parse(fs.readFileSync(legacy, "utf8")) as CredentialBackup;
if (!data?.appId || !data?.clientSecret) {
return null;
}
if (accountId && data.accountId !== accountId) {
return null;
}
if (data.accountId) {
try {
const tmpPath = `${getCredentialBackupFile(data.accountId)}.tmp`;
fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
fs.renameSync(tmpPath, getCredentialBackupFile(data.accountId));
fs.unlinkSync(legacy);
} catch {
/* ignore migration errors */
}
}
return data;
}
} catch {
/* corrupt file — ignore */
}
return null;
}

View File

@@ -0,0 +1,120 @@
/**
* QQBot credential management (pure logic layer).
* QQBot 凭证管理(纯逻辑层)。
*
* Credential clearing and field-level cleanup for logout and setup
* flows. All functions operate on plain objects (Record<string, unknown>)
* and stay framework-agnostic.
*/
import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js";
import { DEFAULT_ACCOUNT_ID } from "./resolve.js";
// ---- Logout: clear all credential fields for an account ----
export interface ClearCredentialsResult {
nextCfg: Record<string, unknown>;
cleared: boolean;
changed: boolean;
}
/**
* Remove clientSecret / clientSecretFile from a QQBot account config.
*
* Returns a shallow-cloned config with credentials removed, plus flags
* indicating whether anything actually changed.
*/
export function clearAccountCredentials(
cfg: Record<string, unknown>,
accountId: string,
): ClearCredentialsResult {
const nextCfg = { ...cfg };
const channels = asRecord(cfg.channels);
const nextQQBot = channels?.qqbot ? { ...asRecord(channels.qqbot) } : undefined;
let cleared = false;
let changed = false;
if (nextQQBot) {
const qqbot = nextQQBot as Record<string, unknown>;
if (accountId === DEFAULT_ACCOUNT_ID) {
if (qqbot.clientSecret) {
delete qqbot.clientSecret;
cleared = true;
changed = true;
}
if (qqbot.clientSecretFile) {
delete qqbot.clientSecretFile;
cleared = true;
changed = true;
}
}
const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId] as Record<string, unknown> | undefined;
if (entry && "clientSecret" in entry) {
delete entry.clientSecret;
cleared = true;
changed = true;
}
if (entry && "clientSecretFile" in entry) {
delete entry.clientSecretFile;
cleared = true;
changed = true;
}
if (entry && Object.keys(entry).length === 0) {
delete accounts[accountId];
changed = true;
}
}
}
if (changed && nextQQBot) {
nextCfg.channels = { ...channels, qqbot: nextQQBot };
}
return { nextCfg, cleared, changed };
}
// ---- Setup: clear a single credential field ----
export type CredentialField = "appId" | "clientSecret";
/**
* Clear a single credential field from a QQBot account config.
*
* Used by setup flows when switching to env-backed credential resolution.
* Returns a new config with the specified field removed.
*/
export function clearCredentialField(
cfg: Record<string, unknown>,
accountId: string,
field: CredentialField,
): Record<string, unknown> {
const next = { ...cfg };
const channels = asRecord(cfg.channels);
const qqbot = { ...asRecord(channels?.qqbot) };
const clearField = (entry: Record<string, unknown>) => {
if (field === "appId") {
delete entry.appId;
return;
}
delete entry.clientSecret;
delete entry.clientSecretFile;
};
if (accountId === DEFAULT_ACCOUNT_ID) {
clearField(qqbot);
} else {
const accounts = { ...(qqbot.accounts as Record<string, Record<string, unknown>> | undefined) };
if (accounts[accountId]) {
const entry = { ...accounts[accountId] };
clearField(entry);
accounts[accountId] = entry;
qqbot.accounts = accounts;
}
}
next.channels = { ...channels, qqbot };
return next;
}

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_ACCOUNT_ID,
listAccountIds,
resolveDefaultAccountId,
resolveAccountBase,
} from "./resolve.js";
describe("engine/config/resolve", () => {
it("returns empty list when no accounts configured", () => {
expect(listAccountIds({})).toEqual([]);
});
it("returns default when top-level appId is set", () => {
const cfg = {
channels: {
qqbot: { appId: "123456" },
},
};
expect(listAccountIds(cfg)).toEqual([DEFAULT_ACCOUNT_ID]);
});
it("lists named accounts", () => {
const cfg = {
channels: {
qqbot: {
accounts: {
bot2: { appId: "654321" },
bot3: { appId: "111222" },
},
},
},
};
const ids = listAccountIds(cfg);
expect(ids).toContain("bot2");
expect(ids).toContain("bot3");
});
it("resolves default account id to 'default' when top-level appId exists", () => {
const cfg = {
channels: {
qqbot: { appId: "123456" },
},
};
expect(resolveDefaultAccountId(cfg)).toBe(DEFAULT_ACCOUNT_ID);
});
it("honors configured defaultAccount", () => {
const cfg = {
channels: {
qqbot: {
defaultAccount: "bot2",
accounts: {
bot2: { appId: "654321" },
},
},
},
};
expect(resolveDefaultAccountId(cfg)).toBe("bot2");
});
it("falls back to first named account when no default configured", () => {
const cfg = {
channels: {
qqbot: {
accounts: {
mybot: { appId: "999999" },
},
},
},
};
expect(resolveDefaultAccountId(cfg)).toBe("mybot");
});
it("resolves base account info for default account", () => {
const cfg = {
channels: {
qqbot: {
appId: "123456",
name: "Test Bot",
systemPrompt: "You are helpful.",
markdownSupport: true,
},
},
};
const base = resolveAccountBase(cfg, DEFAULT_ACCOUNT_ID);
expect(base.accountId).toBe(DEFAULT_ACCOUNT_ID);
expect(base.appId).toBe("123456");
expect(base.name).toBe("Test Bot");
expect(base.systemPrompt).toBe("You are helpful.");
expect(base.markdownSupport).toBe(true);
expect(base.enabled).toBe(true);
});
it("resolves base account info for named account", () => {
const cfg = {
channels: {
qqbot: {
accounts: {
bot2: {
appId: "654321",
name: "Bot Two",
enabled: false,
},
},
},
},
};
const base = resolveAccountBase(cfg, "bot2");
expect(base.accountId).toBe("bot2");
expect(base.appId).toBe("654321");
expect(base.name).toBe("Bot Two");
expect(base.enabled).toBe(false);
});
it("uses configured defaultAccount when accountId is omitted", () => {
const cfg = {
channels: {
qqbot: {
defaultAccount: "bot2",
accounts: {
bot2: { appId: "654321" },
},
},
},
};
const base = resolveAccountBase(cfg);
expect(base.accountId).toBe("bot2");
expect(base.appId).toBe("654321");
});
it("preserves audioFormatPolicy on the config object", () => {
const cfg = {
channels: {
qqbot: {
appId: "123456",
audioFormatPolicy: {
sttDirectFormats: [".wav"],
uploadDirectFormats: [".mp3"],
transcodeEnabled: false,
},
},
},
};
const base = resolveAccountBase(cfg, DEFAULT_ACCOUNT_ID);
expect(base.config.audioFormatPolicy).toEqual({
sttDirectFormats: [".wav"],
uploadDirectFormats: [".mp3"],
transcodeEnabled: false,
});
});
});

View File

@@ -0,0 +1,283 @@
/**
* QQBot config resolution (pure logic layer).
* QQBot 配置解析(纯逻辑层)。
*
* Resolves account IDs, default account selection, and base account
* info from raw config objects. Secret/credential resolution is
* intentionally left to the outer layer (src/bridge/config.ts) so that
* this module stays framework-agnostic and self-contained.
*/
import { getPlatformAdapter } from "../adapter/index.js";
import {
asOptionalObjectRecord as asRecord,
normalizeOptionalLowercaseString,
normalizeStringifiedOptionalString,
readStringField as readString,
} from "../utils/string-normalize.js";
/**
* Default account ID, used for the unnamed top-level account.
* 默认账号 ID用于顶层配置中未命名的账号。
*/
export const DEFAULT_ACCOUNT_ID = "default";
/**
* Internal shape of the channels.qqbot config section.
* channels.qqbot 配置节的内部结构。
*/
interface QQBotChannelConfig {
appId?: unknown;
clientSecret?: unknown;
clientSecretFile?: string;
accounts?: Record<string, Record<string, unknown>>;
defaultAccount?: unknown;
[key: string]: unknown;
}
/**
* Base account resolution result (without credentials).
* 账号基础解析结果(不含凭证信息)。
*
* The outer config.ts layer extends this with clientSecret / secretSource.
*/
export interface ResolvedAccountBase {
accountId: string;
name?: string;
enabled: boolean;
appId: string;
systemPrompt?: string;
markdownSupport: boolean;
config: Record<string, unknown>;
}
function normalizeAppId(raw: unknown): string {
if (typeof raw === "string") {
return raw.trim();
}
if (typeof raw === "number") {
return String(raw);
}
return "";
}
function normalizeAccountConfig(
account: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!account) {
return {};
}
const audioPolicy = asRecord(account.audioFormatPolicy);
return {
...account,
...(audioPolicy ? { audioFormatPolicy: { ...audioPolicy } } : {}),
};
}
function readQQBotSection(cfg: Record<string, unknown>): QQBotChannelConfig | undefined {
const channels = asRecord(cfg.channels);
return asRecord(channels?.qqbot) as QQBotChannelConfig | undefined;
}
/**
* List all configured QQBot account IDs.
* 列出所有已配置的 QQBot 账号 ID。
*/
export function listAccountIds(cfg: Record<string, unknown>): string[] {
const ids = new Set<string>();
const qqbot = readQQBotSection(cfg);
if (qqbot?.appId || process.env.QQBOT_APP_ID) {
ids.add(DEFAULT_ACCOUNT_ID);
}
if (qqbot?.accounts) {
for (const accountId of Object.keys(qqbot.accounts)) {
if (qqbot.accounts[accountId]?.appId) {
ids.add(accountId);
}
}
}
return Array.from(ids);
}
/**
* Resolve the default QQBot account ID.
* 解析默认 QQBot 账号 ID优先级defaultAccount > 顶层 appId > 第一个命名账号)。
*/
export function resolveDefaultAccountId(cfg: Record<string, unknown>): string {
const qqbot = readQQBotSection(cfg);
const configuredDefaultAccountId = normalizeOptionalLowercaseString(qqbot?.defaultAccount);
if (
configuredDefaultAccountId &&
(configuredDefaultAccountId === DEFAULT_ACCOUNT_ID ||
Boolean(qqbot?.accounts?.[configuredDefaultAccountId]?.appId))
) {
return configuredDefaultAccountId;
}
if (qqbot?.appId || process.env.QQBOT_APP_ID) {
return DEFAULT_ACCOUNT_ID;
}
if (qqbot?.accounts) {
const ids = Object.keys(qqbot.accounts);
if (ids.length > 0) {
return ids[0];
}
}
return DEFAULT_ACCOUNT_ID;
}
/**
* Resolve base account info (without credentials).
* 解析账号基础信息(不含凭证)。
*
* Resolves everything except Secret/credential fields. The outer
* config.ts layer calls this and adds Secret handling on top.
*/
export function resolveAccountBase(
cfg: Record<string, unknown>,
accountId?: string | null,
): ResolvedAccountBase {
const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg);
const qqbot = readQQBotSection(cfg);
let accountConfig: Record<string, unknown> = {};
let appId = "";
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
accountConfig = normalizeAccountConfig(asRecord(qqbot));
appId = normalizeAppId(qqbot?.appId);
} else {
const account = qqbot?.accounts?.[resolvedAccountId];
accountConfig = normalizeAccountConfig(asRecord(account));
appId = normalizeAppId(asRecord(account)?.appId);
}
if (!appId && process.env.QQBOT_APP_ID && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
appId = normalizeAppId(process.env.QQBOT_APP_ID);
}
return {
accountId: resolvedAccountId,
name: readString(accountConfig, "name"),
enabled: accountConfig.enabled !== false,
appId,
systemPrompt: readString(accountConfig, "systemPrompt"),
markdownSupport: accountConfig.markdownSupport !== false,
config: accountConfig,
};
}
// ---- Account config apply ----
export interface ApplyAccountInput {
appId?: string;
clientSecret?: string;
clientSecretFile?: string;
name?: string;
}
/** Apply account config updates into a raw config object. */
export function applyAccountConfig(
cfg: Record<string, unknown>,
accountId: string,
input: ApplyAccountInput,
): Record<string, unknown> {
const next = { ...cfg };
const channels = asRecord(cfg.channels) ?? {};
const existingQQBot = asRecord(channels.qqbot) ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
const allowFrom = (existingQQBot.allowFrom as unknown[]) ?? ["*"];
next.channels = {
...channels,
qqbot: {
...existingQQBot,
enabled: true,
allowFrom,
...(input.appId ? { appId: input.appId } : {}),
...(input.clientSecret
? { clientSecret: input.clientSecret, clientSecretFile: undefined }
: input.clientSecretFile
? { clientSecretFile: input.clientSecretFile, clientSecret: undefined }
: {}),
...(input.name ? { name: input.name } : {}),
},
};
} else {
const accounts = (existingQQBot.accounts ?? {}) as Record<string, Record<string, unknown>>;
const existingAccount = accounts[accountId] ?? {};
const allowFrom = (existingAccount.allowFrom as unknown[]) ?? ["*"];
next.channels = {
...channels,
qqbot: {
...existingQQBot,
enabled: true,
accounts: {
...accounts,
[accountId]: {
...existingAccount,
enabled: true,
allowFrom,
...(input.appId ? { appId: input.appId } : {}),
...(input.clientSecret
? { clientSecret: input.clientSecret, clientSecretFile: undefined }
: input.clientSecretFile
? { clientSecretFile: input.clientSecretFile, clientSecret: undefined }
: {}),
...(input.name ? { name: input.name } : {}),
},
},
},
};
}
return next;
}
// ---- Account status helpers ----
/** Resolved account shape expected by isAccountConfigured / describeAccount. */
export interface AccountSnapshot {
accountId: string;
name?: string;
enabled: boolean;
appId: string;
clientSecret?: string;
secretSource?: string;
config: Record<string, unknown> & {
clientSecret?: unknown;
clientSecretFile?: string;
};
}
/** Check whether a QQBot account has been fully configured. */
export function isAccountConfigured(account: AccountSnapshot | undefined): boolean {
return Boolean(
account?.appId &&
(Boolean(account?.clientSecret) ||
getPlatformAdapter().hasConfiguredSecret(account?.config?.clientSecret) ||
Boolean(account?.config?.clientSecretFile?.trim())),
);
}
/** Build a summary description of an account. */
export function describeAccount(account: AccountSnapshot | undefined) {
return {
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
name: account?.name,
enabled: account?.enabled ?? false,
configured: isAccountConfigured(account),
tokenSource: account?.secretSource,
};
}
/** Normalize allowFrom entries into uppercase strings without the qqbot: prefix. */
export function formatAllowFrom(allowFrom: Array<string | number> | undefined | null): string[] {
return (allowFrom ?? [])
.map((entry) => normalizeStringifiedOptionalString(entry))
.filter((entry): entry is string => Boolean(entry))
.map((entry) => entry.replace(/^qqbot:/i, ""))
.map((entry) => entry.toUpperCase());
}

View File

@@ -0,0 +1,84 @@
/**
* QQBot setup business logic (pure layer).
* QQBot setup 相关纯业务逻辑。
*
* Token parsing, input validation, and setup config application.
* All functions are framework-agnostic and operate on plain objects.
*/
import { applyAccountConfig } from "./resolve.js";
import { DEFAULT_ACCOUNT_ID } from "./resolve.js";
/** Parse an inline "appId:clientSecret" token string. */
export function parseInlineToken(token: string): { appId: string; clientSecret: string } | null {
const colonIdx = token.indexOf(":");
if (colonIdx <= 0 || colonIdx === token.length - 1) {
return null;
}
const appId = token.slice(0, colonIdx).trim();
const clientSecret = token.slice(colonIdx + 1).trim();
if (!appId || !clientSecret) {
return null;
}
return { appId, clientSecret };
}
export interface SetupInput {
token?: string;
tokenFile?: string;
useEnv?: boolean;
name?: string;
}
/** Validate setup input for a QQBot account. Returns an error string or null. */
export function validateSetupInput(accountId: string, input: SetupInput): string | null {
if (!input.token && !input.tokenFile && !input.useEnv) {
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
}
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "QQBot --use-env only supports the default account";
}
if (input.token && !parseInlineToken(input.token)) {
return "QQBot --token must be in appId:clientSecret format";
}
return null;
}
/** Apply setup input to account config. Returns updated config. */
export function applySetupAccountConfig(
cfg: Record<string, unknown>,
accountId: string,
input: SetupInput,
): Record<string, unknown> {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return cfg;
}
let appId = "";
let clientSecret = "";
if (input.token) {
const parsed = parseInlineToken(input.token);
if (!parsed) {
return cfg;
}
appId = parsed.appId;
clientSecret = parsed.clientSecret;
}
if (!appId && !input.tokenFile && !input.useEnv) {
return cfg;
}
return applyAccountConfig(cfg, accountId, {
appId,
clientSecret,
clientSecretFile: input.tokenFile,
name: input.name,
});
}

View File

@@ -0,0 +1,47 @@
/**
* Gateway message decoding utilities.
*
* Extracted from `gateway.ts` — handles the various data formats that
* the QQ Bot WebSocket can deliver (string, Buffer, Buffer[], ArrayBuffer).
*
* Zero external dependencies beyond Node.js built-ins.
*/
/**
* Decode raw WebSocket `data` into a UTF-8 string.
*
* The QQ Bot gateway can send data as a plain string, a single Buffer,
* an array of Buffer chunks, an ArrayBuffer, or a typed array view.
*/
export function decodeGatewayMessageData(data: unknown): string {
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
if (Array.isArray(data) && data.every((chunk) => Buffer.isBuffer(chunk))) {
return Buffer.concat(data).toString("utf8");
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString("utf8");
}
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
}
return "";
}
/**
* Read the optional `message_scene.ext` array from an event payload.
*
* Guild, C2C, and Group events may carry a `message_scene` object
* with an `ext` string array used for ref-index parsing.
*/
export function readOptionalMessageSceneExt(event: Record<string, unknown>): string[] | undefined {
if (!("message_scene" in event)) {
return undefined;
}
const scene = event.message_scene as { ext?: string[] } | undefined;
return scene?.ext;
}

View File

@@ -0,0 +1,95 @@
/**
* QQ Bot WebSocket Gateway protocol constants.
*
* Extracted from `gateway.ts` to share between both plugin versions.
* Zero external dependencies.
*/
/** QQ Bot WebSocket intents grouped by permission level. */
const INTENTS = {
GUILDS: 1 << 0,
GUILD_MEMBERS: 1 << 1,
PUBLIC_GUILD_MESSAGES: 1 << 30,
DIRECT_MESSAGE: 1 << 12,
GROUP_AND_C2C: 1 << 25,
} as const;
/** Full intent mask: groups + DMs + channels. */
export const FULL_INTENTS =
INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C;
/** Exponential backoff delays for reconnection attempts (ms). */
export const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000] as const;
/** Delay after receiving a rate-limit close code (ms). */
export const RATE_LIMIT_DELAY = 60000;
/** Maximum reconnection attempts before giving up. */
export const MAX_RECONNECT_ATTEMPTS = 100;
/** How many quick disconnects before warning about permissions. */
export const MAX_QUICK_DISCONNECT_COUNT = 3;
/** A disconnect within this window (ms) counts as "quick". */
export const QUICK_DISCONNECT_THRESHOLD = 5000;
// ============ Opcode Constants ============
/** Gateway opcodes used by the QQ Bot WebSocket protocol. */
export const GatewayOp = {
/** Server → Client: Dispatch event (type + data). */
DISPATCH: 0,
/** Client → Server: Heartbeat. */
HEARTBEAT: 1,
/** Client → Server: Identify (initial auth). */
IDENTIFY: 2,
/** Client → Server: Resume a dropped session. */
RESUME: 6,
/** Server → Client: Request client to reconnect. */
RECONNECT: 7,
/** Server → Client: Invalid session. */
INVALID_SESSION: 9,
/** Server → Client: Hello (heartbeat interval). */
HELLO: 10,
/** Server → Client: Heartbeat ACK. */
HEARTBEAT_ACK: 11,
} as const;
// ============ Close Codes ============
/** WebSocket close codes used by the QQ Gateway. */
export const GatewayCloseCode = {
/** Normal closure — do not reconnect. */
NORMAL: 1000,
/** Authentication failed — refresh token then reconnect. */
AUTH_FAILED: 4004,
/** Session invalid — clear session, refresh token, reconnect. */
INVALID_SESSION: 4006,
/** Sequence number out of range — clear session, refresh token, reconnect. */
SEQ_OUT_OF_RANGE: 4007,
/** Rate limited — wait before reconnecting. */
RATE_LIMITED: 4008,
/** Session timed out — clear session, refresh token, reconnect. */
SESSION_TIMEOUT: 4009,
/** Server internal error (range start) — clear session, refresh token, reconnect. */
SERVER_ERROR_START: 4900,
/** Server internal error (range end). */
SERVER_ERROR_END: 4913,
/** Insufficient intents — fatal, do not reconnect. */
INSUFFICIENT_INTENTS: 4914,
/** Disallowed intents — fatal, do not reconnect. */
DISALLOWED_INTENTS: 4915,
} as const;
// ============ Dispatch Event Types ============
/** Event type strings dispatched under opcode 0 (DISPATCH). */
export const GatewayEvent = {
READY: "READY",
RESUMED: "RESUMED",
C2C_MESSAGE_CREATE: "C2C_MESSAGE_CREATE",
AT_MESSAGE_CREATE: "AT_MESSAGE_CREATE",
DIRECT_MESSAGE_CREATE: "DIRECT_MESSAGE_CREATE",
GROUP_AT_MESSAGE_CREATE: "GROUP_AT_MESSAGE_CREATE",
INTERACTION_CREATE: "INTERACTION_CREATE",
} as const;

View File

@@ -0,0 +1,155 @@
/**
* Event dispatcher — convert raw WebSocket op=0 events into QueuedMessage objects.
*
* Pure mapping logic with zero side effects (except known-user recording).
* Independently testable.
*/
import { recordKnownUser } from "../session/known-users.js";
import type { InteractionEvent } from "../types.js";
import { parseRefIndices } from "../utils/text-parsing.js";
import { readOptionalMessageSceneExt } from "./codec.js";
import { GatewayEvent } from "./constants.js";
import type { QueuedMessage } from "./message-queue.js";
import type {
C2CMessageEvent,
GuildMessageEvent,
GroupMessageEvent,
EngineLogger,
} from "./types.js";
// ============ Dispatch result ============
export type DispatchResult =
| { action: "ready"; data: unknown; sessionId: string }
| { action: "resumed"; data: unknown }
| { action: "message"; msg: QueuedMessage }
| { action: "interaction"; event: InteractionEvent }
| { action: "ignore" };
// ============ dispatchEvent ============
/**
* Map a raw op=0 event into a structured dispatch result.
*
* Returns "message" for events that should be queued for processing,
* "ready"/"resumed" for session lifecycle events, and "ignore" otherwise.
*/
export function dispatchEvent(
eventType: string,
data: unknown,
accountId: string,
_log?: EngineLogger,
): DispatchResult {
if (eventType === GatewayEvent.READY) {
const d = data as { session_id: string };
return { action: "ready", data, sessionId: d.session_id };
}
if (eventType === GatewayEvent.RESUMED) {
return { action: "resumed", data };
}
if (eventType === GatewayEvent.C2C_MESSAGE_CREATE) {
const ev = data as C2CMessageEvent;
recordKnownUser({
openid: ev.author.user_openid,
type: "c2c",
accountId,
});
const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements);
return {
action: "message",
msg: {
type: "c2c",
senderId: ev.author.user_openid,
content: ev.content,
messageId: ev.id,
timestamp: ev.timestamp,
attachments: ev.attachments,
refMsgIdx: refs.refMsgIdx,
msgIdx: refs.msgIdx,
msgType: ev.message_type,
msgElements: ev.msg_elements,
},
};
}
if (eventType === GatewayEvent.AT_MESSAGE_CREATE) {
const ev = data as GuildMessageEvent;
const refs = parseRefIndices(
readOptionalMessageSceneExt(ev as unknown as Record<string, unknown>),
);
return {
action: "message",
msg: {
type: "guild",
senderId: ev.author.id,
senderName: ev.author.username,
content: ev.content,
messageId: ev.id,
timestamp: ev.timestamp,
channelId: ev.channel_id,
guildId: ev.guild_id,
attachments: ev.attachments,
refMsgIdx: refs.refMsgIdx,
msgIdx: refs.msgIdx,
},
};
}
if (eventType === GatewayEvent.DIRECT_MESSAGE_CREATE) {
const ev = data as GuildMessageEvent;
const refs = parseRefIndices(
readOptionalMessageSceneExt(ev as unknown as Record<string, unknown>),
);
return {
action: "message",
msg: {
type: "dm",
senderId: ev.author.id,
senderName: ev.author.username,
content: ev.content,
messageId: ev.id,
timestamp: ev.timestamp,
guildId: ev.guild_id,
attachments: ev.attachments,
refMsgIdx: refs.refMsgIdx,
msgIdx: refs.msgIdx,
},
};
}
if (eventType === GatewayEvent.GROUP_AT_MESSAGE_CREATE) {
const ev = data as GroupMessageEvent;
recordKnownUser({
openid: ev.author.member_openid,
type: "group",
groupOpenid: ev.group_openid,
accountId,
});
const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements);
return {
action: "message",
msg: {
type: "group",
senderId: ev.author.member_openid,
content: ev.content,
messageId: ev.id,
timestamp: ev.timestamp,
groupOpenid: ev.group_openid,
attachments: ev.attachments,
refMsgIdx: refs.refMsgIdx,
msgIdx: refs.msgIdx,
msgType: ev.message_type,
msgElements: ev.msg_elements,
},
};
}
if (eventType === GatewayEvent.INTERACTION_CREATE) {
return { action: "interaction", event: data as InteractionEvent };
}
return { action: "ignore" };
}

View File

@@ -0,0 +1,371 @@
/**
* GatewayConnection — WebSocket lifecycle, heartbeat, reconnect, and session persistence.
*
* Encapsulates all connection state as class fields (replaces 11 closure variables).
* Event handling and message processing are delegated to injected handlers.
*/
import WebSocket from "ws";
import {
trySlashCommand,
type SlashCommandHandlerContext,
} from "../commands/slash-command-handler.js";
import {
clearTokenCache,
getAccessToken,
getGatewayUrl,
getPluginUserAgent,
startBackgroundTokenRefresh,
stopBackgroundTokenRefresh,
} from "../messaging/sender.js";
import { flushRefIndex } from "../ref/store.js";
import { flushKnownUsers } from "../session/known-users.js";
import { clearSession, loadSession, saveSession } from "../session/session-store.js";
import type { InteractionEvent } from "../types.js";
import { decodeGatewayMessageData } from "./codec.js";
import { FULL_INTENTS, RATE_LIMIT_DELAY, GatewayOp } from "./constants.js";
import { dispatchEvent } from "./event-dispatcher.js";
import { createMessageQueue, type QueuedMessage } from "./message-queue.js";
import { ReconnectState } from "./reconnect.js";
import type { GatewayAccount, EngineLogger, GatewayPluginRuntime, WSPayload } from "./types.js";
// ============ Connection context ============
export interface GatewayConnectionContext {
account: GatewayAccount;
abortSignal: AbortSignal;
cfg: unknown;
log?: EngineLogger;
runtime: GatewayPluginRuntime;
onReady?: (data: unknown) => void;
/** Called when a RESUMED event is received (reconnect success). */
onResumed?: (data: unknown) => void;
onError?: (error: Error) => void;
/** Process a queued message (inbound pipeline → outbound dispatch). */
handleMessage: (event: QueuedMessage) => Promise<void>;
/** Called when an INTERACTION_CREATE event is received (e.g. approval button clicks). */
onInteraction?: (event: InteractionEvent) => void;
}
// ============ GatewayConnection ============
export class GatewayConnection {
// ---- Connection state ----
private isAborted = false;
private currentWs: WebSocket | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private sessionId: string | null = null;
private lastSeq: number | null = null;
private isConnecting = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldRefreshToken = false;
private readonly reconnect: ReconnectState;
private readonly msgQueue;
private readonly ctx: GatewayConnectionContext;
constructor(ctx: GatewayConnectionContext) {
this.ctx = ctx;
this.reconnect = new ReconnectState(ctx.account.accountId, ctx.log);
this.msgQueue = createMessageQueue({
accountId: ctx.account.accountId,
log: ctx.log,
isAborted: () => this.isAborted,
});
}
/** Start the connection loop. Resolves when abortSignal fires. */
async start(): Promise<void> {
this.restoreSession();
this.registerAbortHandler();
await this.connect();
return new Promise<void>((resolve) => {
this.ctx.abortSignal.addEventListener("abort", () => resolve());
});
}
// ============ Session persistence ============
private restoreSession(): void {
const { account, log } = this.ctx;
const saved = loadSession(account.accountId, account.appId);
if (saved) {
this.sessionId = saved.sessionId;
this.lastSeq = saved.lastSeq;
log?.info(`Restored session: sessionId=${this.sessionId}, lastSeq=${this.lastSeq}`);
}
}
private saveCurrentSession(): void {
const { account } = this.ctx;
if (!this.sessionId) {
return;
}
saveSession({
sessionId: this.sessionId,
lastSeq: this.lastSeq,
lastConnectedAt: Date.now(),
intentLevelIndex: 0,
accountId: account.accountId,
savedAt: Date.now(),
appId: account.appId,
});
}
// ============ Abort + cleanup ============
private registerAbortHandler(): void {
const { account, abortSignal, log: _log } = this.ctx;
abortSignal.addEventListener("abort", () => {
this.isAborted = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.cleanup();
stopBackgroundTokenRefresh(account.appId);
flushKnownUsers();
flushRefIndex();
});
}
private cleanup(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (
this.currentWs &&
(this.currentWs.readyState === WebSocket.OPEN ||
this.currentWs.readyState === WebSocket.CONNECTING)
) {
this.currentWs.close();
}
this.currentWs = null;
}
// ============ Reconnect ============
private scheduleReconnect(customDelay?: number): void {
const { account: _account, log } = this.ctx;
if (this.isAborted || this.reconnect.isExhausted()) {
log?.error(`Max reconnect attempts reached or aborted`);
return;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
const delay = this.reconnect.getNextDelay(customDelay);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.isAborted) {
void this.connect();
}
}, delay);
}
// ============ Connect ============
private async connect(): Promise<void> {
const { account, log } = this.ctx;
if (this.isConnecting) {
log?.debug?.(`Already connecting, skip`);
return;
}
this.isConnecting = true;
try {
this.cleanup();
if (this.shouldRefreshToken) {
log?.debug?.(`Refreshing token...`);
clearTokenCache(account.appId);
this.shouldRefreshToken = false;
}
const accessToken = await getAccessToken(account.appId, account.clientSecret);
log?.info(`✅ Access token obtained successfully`);
const gatewayUrl = await getGatewayUrl(accessToken, account.appId);
log?.info(`Connecting to ${gatewayUrl}`);
const ws = new WebSocket(gatewayUrl, {
headers: { "User-Agent": getPluginUserAgent() },
});
this.currentWs = ws;
// ---- Slash command interception ----
const slashCtx: SlashCommandHandlerContext = {
account,
log,
getMessagePeerId: (msg) => this.msgQueue.getMessagePeerId(msg),
getQueueSnapshot: (peerId) => this.msgQueue.getSnapshot(peerId),
};
const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise<void> => {
const result = await trySlashCommand(msg, slashCtx);
if (result === "enqueue") {
this.msgQueue.enqueue(msg);
} else if (result === "urgent") {
const peerId = this.msgQueue.getMessagePeerId(msg);
this.msgQueue.clearUserQueue(peerId);
this.msgQueue.executeImmediate(msg);
}
// "handled" — command executed, nothing to queue.
};
// ---- WebSocket: open ----
ws.on("open", () => {
log?.info(`WebSocket connected`);
this.isConnecting = false;
this.reconnect.onConnected();
this.msgQueue.startProcessor(this.ctx.handleMessage);
startBackgroundTokenRefresh(account.appId, account.clientSecret, { log });
});
// ---- WebSocket: message ----
ws.on("message", async (data) => {
try {
const rawData = decodeGatewayMessageData(data);
const payload = JSON.parse(rawData) as WSPayload;
const { op, d, s, t } = payload;
if (s) {
this.lastSeq = s;
this.saveCurrentSession();
}
switch (op) {
case GatewayOp.HELLO:
this.handleHello(ws, d, accessToken);
break;
case GatewayOp.DISPATCH: {
log?.debug?.(`Dispatch event: t=${t}, d=${JSON.stringify(d)}`);
const result = dispatchEvent(t ?? "", d, account.accountId, log);
if (result.action === "ready") {
this.sessionId = result.sessionId;
this.saveCurrentSession();
this.ctx.onReady?.(result.data);
} else if (result.action === "resumed") {
(this.ctx.onResumed ?? this.ctx.onReady)?.(result.data);
this.saveCurrentSession();
} else if (result.action === "interaction") {
this.ctx.onInteraction?.(result.event);
} else if (result.action === "message") {
void trySlashCommandOrEnqueue(result.msg);
}
break;
}
case GatewayOp.HEARTBEAT_ACK:
break;
case GatewayOp.RECONNECT:
this.cleanup();
this.scheduleReconnect();
break;
case GatewayOp.INVALID_SESSION: {
const canResume = d as boolean;
if (!canResume) {
this.sessionId = null;
this.lastSeq = null;
clearSession(account.accountId);
this.shouldRefreshToken = true;
}
this.cleanup();
this.scheduleReconnect(3000);
break;
}
}
} catch (err) {
log?.error(`Message parse error: ${err instanceof Error ? err.message : String(err)}`);
}
});
// ---- WebSocket: close ----
ws.on("close", (code, reason) => {
log?.info(`WebSocket closed: ${code} ${reason.toString()}`);
this.isConnecting = false;
this.handleClose(code);
});
// ---- WebSocket: error ----
ws.on("error", (err) => {
log?.error(`WebSocket error: ${err.message}`);
this.ctx.onError?.(err);
});
} catch (err) {
this.isConnecting = false;
const errMsg = err instanceof Error ? err.message : String(err);
log?.error(`Connection failed: ${errMsg}`);
if (errMsg.includes("Too many requests") || errMsg.includes("100001")) {
this.scheduleReconnect(RATE_LIMIT_DELAY);
} else {
this.scheduleReconnect();
}
}
}
// ============ Protocol handlers ============
private handleHello(ws: WebSocket, d: unknown, accessToken: string): void {
if (this.sessionId && this.lastSeq !== null) {
ws.send(
JSON.stringify({
op: GatewayOp.RESUME,
d: {
token: `QQBot ${accessToken}`,
session_id: this.sessionId,
seq: this.lastSeq,
},
}),
);
} else {
ws.send(
JSON.stringify({
op: GatewayOp.IDENTIFY,
d: {
token: `QQBot ${accessToken}`,
intents: FULL_INTENTS,
shard: [0, 1],
},
}),
);
}
const interval = (d as { heartbeat_interval: number }).heartbeat_interval;
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
this.heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ op: GatewayOp.HEARTBEAT, d: this.lastSeq }));
}
}, interval);
}
private handleClose(code: number): void {
const { account } = this.ctx;
const action = this.reconnect.handleClose(code, this.isAborted);
if (action.clearSession) {
this.sessionId = null;
this.lastSeq = null;
clearSession(account.accountId);
}
if (action.refreshToken) {
this.shouldRefreshToken = true;
}
this.cleanup();
if (action.fatal) {
return;
}
if (action.shouldReconnect) {
this.scheduleReconnect(action.reconnectDelay);
}
}
}

View File

@@ -0,0 +1,286 @@
/**
* Core gateway entry point — thin shell that wires together:
*
* - GatewayConnection: WebSocket lifecycle, heartbeat, reconnect
* - buildInboundContext: content building, attachments, quote resolution
* - dispatchOutbound: AI dispatch, deliver callbacks, timeouts
*
* The only responsibilities of this file are:
* 1. Register audio adapters
* 2. Initialize API config + refIdx cache hook
* 3. Create the message handler (inbound → outbound pipeline)
* 4. Start GatewayConnection
*/
import path from "node:path";
import { getPlatformAdapter } from "../adapter/index.js";
import { parseApprovalButtonData } from "../approval/index.js";
import { registerOutboundAudioAdapter } from "../messaging/outbound.js";
import {
clearTokenCache,
getAccessToken,
initApiConfig,
onMessageSent,
sendInputNotify as senderSendInputNotify,
createRawInputNotifyFn,
accountToCreds,
acknowledgeInteraction,
} from "../messaging/sender.js";
import { setRefIndex } from "../ref/store.js";
import type { InteractionEvent } from "../types.js";
import {
audioFileToSilkBase64,
convertSilkToWav,
isVoiceAttachment,
isAudioFile,
shouldTranscodeVoice,
waitForFile,
} from "../utils/audio.js";
import { runDiagnostics } from "../utils/diagnostics.js";
import { formatDuration } from "../utils/format.js";
import { runWithRequestContext } from "../utils/request-context.js";
import { GatewayConnection } from "./gateway-connection.js";
import { registerAudioConvertAdapter } from "./inbound-attachments.js";
import { buildInboundContext } from "./inbound-pipeline.js";
import type { QueuedMessage } from "./message-queue.js";
import { dispatchOutbound } from "./outbound-dispatch.js";
import type {
CoreGatewayContext,
GatewayAccount,
EngineLogger,
RefAttachmentSummary,
} from "./types.js";
import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
// Re-export context type for consumers.
export type { CoreGatewayContext } from "./types.js";
// ============ startGateway ============
/**
* Start the Gateway WebSocket connection with automatic reconnect support.
*/
export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
const { account, log, runtime } = ctx;
// ---- 1. Register audio adapters ----
registerAudioConvertAdapter({ convertSilkToWav, isVoiceAttachment, formatDuration });
registerOutboundAudioAdapter({
audioFileToSilkBase64: async (p, f) => (await audioFileToSilkBase64(p, f)) ?? undefined,
isAudioFile,
shouldTranscodeVoice,
waitForFile,
});
// ---- 2. Validate ----
if (!account.appId || !account.clientSecret) {
throw new Error("QQBot not configured (missing appId or clientSecret)");
}
// ---- 3. Diagnostics ----
const diag = await runDiagnostics();
if (diag.warnings.length > 0) {
for (const w of diag.warnings) {
log?.info(w);
}
}
// ---- 4. API config ----
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
log?.debug?.(`API config: markdownSupport=${account.markdownSupport}`);
// ---- 5. Outbound refIdx cache hook ----
onMessageSent(account.appId, (refIdx, meta) => {
log?.info(
`onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`,
);
const attachments: RefAttachmentSummary[] = [];
if (meta.mediaType) {
const localPath = meta.mediaLocalPath;
const filename = localPath ? path.basename(localPath) : undefined;
const attachment: RefAttachmentSummary = {
type: meta.mediaType,
...(localPath ? { localPath } : {}),
...(filename ? { filename } : {}),
...(meta.mediaUrl ? { url: meta.mediaUrl } : {}),
};
if (meta.mediaType === "voice" && meta.ttsText) {
attachment.transcript = meta.ttsText;
attachment.transcriptSource = "tts";
}
attachments.push(attachment);
}
setRefIndex(refIdx, {
content: meta.text ?? "",
senderId: account.accountId,
senderName: account.accountId,
timestamp: Date.now(),
isBot: true,
...(attachments.length > 0 ? { attachments } : {}),
});
});
// ---- 6. Message handler ----
const handleMessage = async (event: QueuedMessage): Promise<void> => {
log?.info(`Processing message from ${event.senderId}: ${event.content}`);
runtime.channel.activity.record({
channel: "qqbot",
accountId: account.accountId,
direction: "inbound",
});
const inbound = await buildInboundContext(event, {
account,
cfg: ctx.cfg,
log,
runtime,
startTyping: (ev) => startTypingForEvent(ev, account, log),
});
if (inbound.blocked) {
log?.info(`Dropped inbound qqbot message: ${inbound.blockReason ?? "blocked by allowFrom"}`);
inbound.typing.keepAlive?.stop();
return;
}
try {
await runWithRequestContext(
{
accountId: account.accountId,
target: inbound.qualifiedTarget,
targetId: inbound.peerId,
chatType: event.type,
},
() => dispatchOutbound(inbound, { runtime, cfg: ctx.cfg, account, log }),
);
} catch (err) {
log?.error(`Message processing failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
inbound.typing.keepAlive?.stop();
}
};
// ---- 7. Interaction handler ----
const handleInteraction = createApprovalInteractionHandler(account, log);
// ---- 8. Start connection ----
const connection = new GatewayConnection({
account,
abortSignal: ctx.abortSignal,
cfg: ctx.cfg,
log,
runtime,
onReady: ctx.onReady,
onResumed: ctx.onResumed,
onError: ctx.onError,
onInteraction: handleInteraction,
handleMessage,
});
await connection.start();
}
// ============ Typing helper ============
/**
* Start typing indicator for a C2C event.
* Returns the refIdx from InputNotify and a TypingKeepAlive handle.
*/
async function startTypingForEvent(
event: QueuedMessage,
account: GatewayAccount,
log?: EngineLogger,
): Promise<{ refIdx?: string; keepAlive: TypingKeepAlive | null }> {
const isC2C = event.type === "c2c" || event.type === "dm";
if (!isC2C) {
return { keepAlive: null };
}
try {
const creds = accountToCreds(account);
const rawNotifyFn = createRawInputNotifyFn(account.appId);
try {
const resp = await senderSendInputNotify({
openid: event.senderId,
creds,
msgId: event.messageId,
inputSecond: TYPING_INPUT_SECOND,
});
const keepAlive = new TypingKeepAlive(
() => getAccessToken(account.appId, account.clientSecret),
() => clearTokenCache(account.appId),
rawNotifyFn,
event.senderId,
event.messageId,
log,
);
keepAlive.start();
return { refIdx: resp.refIdx, keepAlive };
} catch (notifyErr) {
const errMsg = String(notifyErr);
if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) {
clearTokenCache(account.appId);
const resp = await senderSendInputNotify({
openid: event.senderId,
creds,
msgId: event.messageId,
inputSecond: TYPING_INPUT_SECOND,
});
const keepAlive = new TypingKeepAlive(
() => getAccessToken(account.appId, account.clientSecret),
() => clearTokenCache(account.appId),
rawNotifyFn,
event.senderId,
event.messageId,
log,
);
keepAlive.start();
return { refIdx: resp.refIdx, keepAlive };
}
throw notifyErr;
}
} catch (err) {
log?.error(`sendInputNotify error: ${err instanceof Error ? err.message : String(err)}`);
return { keepAlive: null };
}
}
// ============ Interaction handler ============
/**
* Default INTERACTION_CREATE handler — ACK the interaction and resolve
* approval button clicks via the registered PlatformAdapter.
*/
function createApprovalInteractionHandler(
account: GatewayAccount,
log?: EngineLogger,
): (event: InteractionEvent) => void {
return (event) => {
const creds = accountToCreds(account);
// ACK the interaction first to prevent QQ from showing a timeout error.
void acknowledgeInteraction(creds, event.id).catch((err) => {
log?.error(`Interaction ACK failed: ${err instanceof Error ? err.message : String(err)}`);
});
const buttonData = event.data?.resolved?.button_data ?? "";
const parsed = parseApprovalButtonData(buttonData);
if (!parsed) {
return;
}
const adapter = getPlatformAdapter();
if (!adapter.resolveApproval) {
log?.error(`resolveApproval not available on PlatformAdapter`);
return;
}
void adapter.resolveApproval(parsed.approvalId, parsed.decision).then((ok) => {
if (ok) {
log?.info(`Approval resolved: id=${parsed.approvalId}, decision=${parsed.decision}`);
} else {
log?.error(`Approval resolve failed: id=${parsed.approvalId}`);
}
});
};
}

View File

@@ -1,8 +1,35 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { transcribeAudio, resolveSTTConfig } from "./stt.js";
import { convertSilkToWav, isVoiceAttachment, formatDuration } from "./utils/audio-convert.js";
import { downloadFile } from "./utils/file-utils.js";
import { getQQBotMediaDir } from "./utils/platform.js";
import { downloadFile } from "../utils/file-utils.js";
import { getQQBotMediaDir } from "../utils/platform.js";
import { normalizeOptionalString } from "../utils/string-normalize.js";
import { transcribeAudio, resolveSTTConfig } from "../utils/stt.js";
// Re-export formatVoiceText from core/.
export { formatVoiceText } from "../utils/voice-text.js";
// ---- Injected audio-convert dependencies ----
/** Audio conversion interface — implemented by the upper-layer audio-convert module. */
export interface AudioConvertAdapter {
convertSilkToWav(
silkPath: string,
outputDir: string,
): Promise<{ wavPath: string; duration: number } | null>;
isVoiceAttachment(att: { content_type: string; filename?: string }): boolean;
formatDuration(seconds: number): string;
}
let _audioAdapter: AudioConvertAdapter | null = null;
/** Register the audio conversion adapter — called by gateway startup. */
export function registerAudioConvertAdapter(adapter: AudioConvertAdapter): void {
_audioAdapter = adapter;
}
function getAudioAdapter(): AudioConvertAdapter {
if (!_audioAdapter) {
throw new Error("AudioConvertAdapter not registered — call registerAudioConvertAdapter first");
}
return _audioAdapter;
}
export interface RawAttachment {
content_type: string;
@@ -58,9 +85,8 @@ export async function processAttachments(
return EMPTY_RESULT;
}
const { accountId, cfg, log } = ctx;
const { accountId: _accountId, cfg, log } = ctx;
const downloadDir = getQQBotMediaDir("downloads");
const prefix = `[qqbot:${accountId}]`;
const imageUrls: string[] = [];
const imageMediaTypes: string[] = [];
@@ -75,7 +101,7 @@ export async function processAttachments(
// Phase 1: download all attachments in parallel.
const downloadTasks = attachments.map(async (att) => {
const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
const isVoice = isVoiceAttachment(att);
const isVoice = getAudioAdapter().isVoiceAttachment(att);
const wavUrl =
isVoice && att.voice_wav_url
? att.voice_wav_url.startsWith("//")
@@ -91,11 +117,9 @@ export async function processAttachments(
if (wavLocalPath) {
localPath = wavLocalPath;
audioPath = wavLocalPath;
log?.info(
`${prefix} Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`,
);
log?.debug?.(`Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`);
} else {
log?.error(`${prefix} Failed to download voice_wav_url, falling back to original URL`);
log?.error(`Failed to download voice_wav_url, falling back to original URL`);
}
}
@@ -127,10 +151,10 @@ export async function processAttachments(
if (localPath) {
if (att.content_type?.startsWith("image/")) {
log?.info(`${prefix} Downloaded attachment to: ${localPath}`);
log?.debug?.(`Downloaded attachment to: ${localPath}`);
return { localPath, type: "image" as const, contentType: att.content_type, meta };
} else if (isVoice) {
log?.info(`${prefix} Downloaded attachment to: ${localPath}`);
log?.debug?.(`Downloaded attachment to: ${localPath}`);
return processVoiceAttachment(
localPath,
audioPath,
@@ -139,14 +163,13 @@ export async function processAttachments(
cfg,
downloadDir,
log,
prefix,
);
} else {
log?.info(`${prefix} Downloaded attachment to: ${localPath}`);
log?.debug?.(`Downloaded attachment to: ${localPath}`);
return { localPath, type: "other" as const, filename: att.filename, meta };
}
} else {
log?.error(`${prefix} Failed to download: ${attUrl}`);
log?.error(`Failed to download: ${attUrl}`);
if (att.content_type?.startsWith("image/")) {
return {
localPath: null,
@@ -156,7 +179,7 @@ export async function processAttachments(
meta,
};
} else if (isVoice && asrReferText) {
log?.info(`${prefix} Voice attachment download failed, using asr_refer_text fallback`);
log?.info(`Voice attachment download failed, using asr_refer_text fallback`);
return {
localPath: null,
type: "voice-fallback" as const,
@@ -227,15 +250,7 @@ export async function processAttachments(
};
}
/** Format voice transcripts into user-visible text. */
export function formatVoiceText(transcripts: string[]): string {
if (transcripts.length === 0) {
return "";
}
return transcripts.length === 1
? `[Voice message] ${transcripts[0]}`
: transcripts.map((t, i) => `[Voice ${i + 1}] ${t}`).join("\n");
}
// formatVoiceText is now in core/utils/voice-text.ts (re-exported above).
// Internal helpers.
@@ -263,7 +278,6 @@ async function processVoiceAttachment(
cfg: unknown,
downloadDir: string,
log: ProcessContext["log"],
prefix: string,
): Promise<VoiceResult> {
const wavUrl = att.voice_wav_url
? att.voice_wav_url.startsWith("//")
@@ -280,14 +294,12 @@ async function processVoiceAttachment(
const sttCfg = resolveSTTConfig(cfg as Record<string, unknown>);
if (!sttCfg) {
if (asrReferText) {
log?.info(
`${prefix} Voice attachment: ${att.filename} (STT not configured, using asr_refer_text fallback)`,
log?.debug?.(
`Voice attachment: ${att.filename} (STT not configured, using asr_refer_text fallback)`,
);
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
}
log?.info(
`${prefix} Voice attachment: ${att.filename} (STT not configured, skipping transcription)`,
);
log?.debug?.(`Voice attachment: ${att.filename} (STT not configured, skipping transcription)`);
return {
localPath,
type: "voice",
@@ -299,20 +311,20 @@ async function processVoiceAttachment(
// Convert SILK input to WAV before STT when necessary.
if (!audioPath) {
log?.info(`${prefix} Voice attachment: ${att.filename}, converting SILK→WAV...`);
log?.debug?.(`Voice attachment: ${att.filename}, converting SILK→WAV...`);
try {
const wavResult = await convertSilkToWav(localPath, downloadDir);
const wavResult = await getAudioAdapter().convertSilkToWav(localPath, downloadDir);
if (wavResult) {
audioPath = wavResult.wavPath;
log?.info(
`${prefix} Voice converted: ${wavResult.wavPath} (${formatDuration(wavResult.duration)})`,
log?.debug?.(
`Voice converted: ${wavResult.wavPath} (${getAudioAdapter().formatDuration(wavResult.duration)})`,
);
} else {
audioPath = localPath;
}
} catch (convertErr) {
log?.error(
`${prefix} Voice conversion failed: ${
`Voice conversion failed: ${
convertErr instanceof Error ? convertErr.message : JSON.stringify(convertErr)
}`,
);
@@ -339,14 +351,14 @@ async function processVoiceAttachment(
try {
const transcript = await transcribeAudio(audioPath, cfg as Record<string, unknown>);
if (transcript) {
log?.info(`${prefix} STT transcript: ${transcript.slice(0, 100)}...`);
log?.debug?.(`STT transcript: ${transcript.slice(0, 100)}...`);
return { localPath, type: "voice", transcript, transcriptSource: "stt", meta };
}
if (asrReferText) {
log?.info(`${prefix} STT returned empty result, using asr_refer_text fallback`);
log?.debug?.(`STT returned empty result, using asr_refer_text fallback`);
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
}
log?.info(`${prefix} STT returned empty result`);
log?.debug?.(`STT returned empty result`);
return {
localPath,
type: "voice",
@@ -355,9 +367,7 @@ async function processVoiceAttachment(
meta,
};
} catch (sttErr) {
log?.error(
`${prefix} STT failed: ${sttErr instanceof Error ? sttErr.message : JSON.stringify(sttErr)}`,
);
log?.error(`STT failed: ${sttErr instanceof Error ? sttErr.message : JSON.stringify(sttErr)}`);
if (asrReferText) {
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
}

View File

@@ -0,0 +1,119 @@
/**
* InboundContext — the structured result of the inbound pipeline.
*
* Connects the inbound stage (content building, attachment processing,
* quote resolution) with the outbound stage (AI dispatch, deliver callbacks).
*
* All fields are readonly after construction. The outbound dispatcher
* reads from this object but never mutates it.
*/
import type { QQBotAccessDecision, QQBotAccessReasonCode } from "../access/index.js";
import type { QueuedMessage } from "./message-queue.js";
import type {
GatewayAccount,
EngineLogger,
GatewayPluginRuntime,
ProcessedAttachments,
} from "./types.js";
import type { TypingKeepAlive } from "./typing-keepalive.js";
// ============ InboundContext ============
/** Quote (reply-to) metadata resolved during inbound processing. */
export interface ReplyToInfo {
id: string;
body?: string;
sender?: string;
isQuote: boolean;
}
/** Fully resolved inbound context passed to the outbound dispatcher. */
export interface InboundContext {
// ---- Original event ----
event: QueuedMessage;
// ---- Routing ----
route: { sessionKey: string; accountId: string; agentId?: string };
isGroupChat: boolean;
peerId: string;
/** Fully qualified target address: "qqbot:c2c:xxx" / "qqbot:group:xxx" etc. */
qualifiedTarget: string;
fromAddress: string;
// ---- Content ----
/** event.content after parseFaceTags. */
parsedContent: string;
/** parsedContent + voiceText + attachmentInfo — the user-visible text. */
userContent: string;
/** "[Quoted message begins]…[ends]" or empty. */
quotePart: string;
/** Per-message dynamic metadata lines (images, voice, ASR). */
dynamicCtx: string;
/** quotePart + userContent. */
userMessage: string;
/** dynamicCtx + userMessage (or raw content for slash commands). */
agentBody: string;
/** Formatted inbound envelope (Web UI body). */
body: string;
// ---- System prompts ----
systemPrompts: string[];
groupSystemPrompt?: string;
// ---- Attachments ----
attachments: ProcessedAttachments;
localMediaPaths: string[];
localMediaTypes: string[];
remoteMediaUrls: string[];
remoteMediaTypes: string[];
// ---- Voice ----
uniqueVoicePaths: string[];
uniqueVoiceUrls: string[];
uniqueVoiceAsrReferTexts: string[];
hasAsrReferFallback: boolean;
voiceTranscriptSources: string[];
// ---- Reply-to / Quote ----
replyTo?: ReplyToInfo;
// ---- Auth ----
commandAuthorized: boolean;
/**
* Whether the inbound message should be blocked outright (i.e. the bot
* neither routes it to an agent nor replies). Set when the sender is
* not matched by the configured `allowFrom`/`groupAllowFrom` list
* under the active `dmPolicy` / `groupPolicy`.
*/
blocked: boolean;
/** Human-readable reason for `blocked`, for logging only. */
blockReason?: string;
/**
* Structured reason code for `blocked`, suitable for metrics and
* activity indicators.
*/
blockReasonCode?: QQBotAccessReasonCode;
/** The raw access decision produced by the policy engine. */
accessDecision?: QQBotAccessDecision;
// ---- Typing ----
typing: { keepAlive: TypingKeepAlive | null };
/** refIdx returned by the initial InputNotify call. */
inputNotifyRefIdx?: string;
}
// ============ Pipeline dependencies ============
/** Dependencies injected into the inbound pipeline. */
export interface InboundPipelineDeps {
account: GatewayAccount;
cfg: unknown;
log?: EngineLogger;
runtime: GatewayPluginRuntime;
/** Start typing indicator and return the refIdx from InputNotify. */
startTyping: (event: QueuedMessage) => Promise<{
refIdx?: string;
keepAlive: TypingKeepAlive | null;
}>;
}

View File

@@ -0,0 +1,444 @@
/**
* Inbound pipeline — build a fully resolved InboundContext from a raw QueuedMessage.
*
* Responsibilities:
* 1. Route resolution
* 2. Attachment processing (download + STT)
* 3. Content building (parseFaceTags + voiceText + attachmentInfo)
* 4. Quote / reply-to resolution (three-level fallback)
* 5. RefIdx cache write (setRefIndex)
* 6. Body / agentBody / ctxPayload data assembly
*
* No message sending. Independently testable.
*/
import {
normalizeQQBotSenderId,
resolveQQBotAccess,
type QQBotAccessResult,
} from "../access/index.js";
import {
formatMessageReferenceForAgent,
type AttachmentProcessor,
} from "../ref/format-message-ref.js";
import { getRefIndex, setRefIndex, formatRefEntryForAgent } from "../ref/store.js";
import { parseFaceTags, buildAttachmentSummaries, MSG_TYPE_QUOTE } from "../utils/text-parsing.js";
import { formatVoiceText } from "../utils/voice-text.js";
import { processAttachments } from "./inbound-attachments.js";
import type { InboundContext, InboundPipelineDeps } from "./inbound-context.js";
import type { QueuedMessage } from "./message-queue.js";
// ============ buildInboundContext ============
/**
* Process a raw queued message through the full inbound pipeline and return
* a structured {@link InboundContext} ready for outbound dispatch.
*/
export async function buildInboundContext(
event: QueuedMessage,
deps: InboundPipelineDeps,
): Promise<InboundContext> {
const { account, cfg, log, runtime } = deps;
// ---- 1. Route resolution ----
const isGroupChat = event.type === "guild" || event.type === "group";
const peerId =
event.type === "guild"
? (event.channelId ?? "unknown")
: event.type === "group"
? (event.groupOpenid ?? "unknown")
: event.senderId;
const route = runtime.channel.routing.resolveAgentRoute({
cfg,
channel: "qqbot",
accountId: account.accountId,
peer: { kind: isGroupChat ? "group" : "direct", id: peerId },
});
// ---- 1a. Early access control ----
//
// Evaluate the account-level dmPolicy / groupPolicy + allowFrom /
// groupAllowFrom whitelist before any expensive I/O (typing
// indicator, attachment downloads, quote resolution). Semantics are
// aligned with WhatsApp/Telegram/Discord (see `engine/access/`).
//
// When blocked, we return a minimal stub InboundContext and rely on
// the gateway handler to skip dispatch.
const access = resolveQQBotAccess({
isGroup: isGroupChat,
senderId: event.senderId,
allowFrom: account.config?.allowFrom,
groupAllowFrom: account.config?.groupAllowFrom,
dmPolicy: account.config?.dmPolicy,
groupPolicy: account.config?.groupPolicy,
});
const qualifiedTarget = isGroupChat
? event.type === "guild"
? `qqbot:channel:${event.channelId}`
: `qqbot:group:${event.groupOpenid}`
: event.type === "dm"
? `qqbot:dm:${event.guildId}`
: `qqbot:c2c:${event.senderId}`;
const fromAddress = qualifiedTarget;
if (access.decision !== "allow") {
log?.info(
`Blocked qqbot inbound: decision=${access.decision} reasonCode=${access.reasonCode} ` +
`reason=${access.reason} senderId=${normalizeQQBotSenderId(event.senderId)} ` +
`accountId=${account.accountId} isGroup=${isGroupChat}`,
);
return buildBlockedInboundContext({
event,
route,
isGroupChat,
peerId,
qualifiedTarget,
fromAddress,
access,
});
}
// ---- 2. System prompts ----
const systemPrompts: string[] = [];
if (account.systemPrompt) {
systemPrompts.push(account.systemPrompt);
}
// ---- 3. Typing indicator (async, await later) ----
const typingPromise = deps.startTyping(event);
// ---- 4. Attachment processing ----
const processed = await processAttachments(event.attachments, {
accountId: account.accountId,
cfg,
log,
});
const {
attachmentInfo,
imageUrls,
imageMediaTypes,
voiceAttachmentPaths,
voiceAttachmentUrls,
voiceAsrReferTexts,
voiceTranscripts,
voiceTranscriptSources,
attachmentLocalPaths,
} = processed;
// ---- 5. Content building ----
const voiceText = formatVoiceText(voiceTranscripts);
const hasAsrReferFallback = voiceTranscriptSources.includes("asr");
const parsedContent = parseFaceTags(event.content);
const userContent = voiceText
? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
: parsedContent + attachmentInfo;
// ---- 6. Quote / reply-to resolution ----
const replyTo = await resolveQuote(event, account, cfg, log);
// ---- 7. RefIdx cache write ----
const typingResult = await typingPromise;
const inputNotifyRefIdx = typingResult.refIdx;
const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx;
if (currentMsgIdx) {
const attSummaries = buildAttachmentSummaries(event.attachments, attachmentLocalPaths);
if (attSummaries && voiceTranscripts.length > 0) {
let voiceIdx = 0;
for (const att of attSummaries) {
if (att.type === "voice" && voiceIdx < voiceTranscripts.length) {
att.transcript = voiceTranscripts[voiceIdx];
if (voiceIdx < voiceTranscriptSources.length) {
att.transcriptSource = voiceTranscriptSources[voiceIdx] as
| "stt"
| "asr"
| "tts"
| "fallback";
}
voiceIdx++;
}
}
}
setRefIndex(currentMsgIdx, {
content: parsedContent,
senderId: event.senderId,
senderName: event.senderName,
timestamp: new Date(event.timestamp).getTime(),
attachments: attSummaries,
});
}
// ---- 8. Envelope (Web UI body) ----
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
const body = runtime.channel.reply.formatInboundEnvelope({
channel: "qqbot",
from: event.senderName ?? event.senderId,
timestamp: new Date(event.timestamp).getTime(),
body: userContent,
chatType: isGroupChat ? "group" : "direct",
sender: { id: event.senderId, name: event.senderName },
envelope: envelopeOptions,
...(imageUrls.length > 0 ? { imageUrls } : {}),
});
// ---- 9. Voice dedup ----
const uniqueVoicePaths = [...new Set(voiceAttachmentPaths)];
const uniqueVoiceUrls = [...new Set(voiceAttachmentUrls)];
const uniqueVoiceAsrReferTexts = [...new Set(voiceAsrReferTexts)].filter(Boolean);
// ---- 11. Quote part ----
let quotePart = "";
if (replyTo) {
quotePart = replyTo.body
? `[Quoted message begins]\n${replyTo.body}\n[Quoted message ends]\n`
: `[Quoted message begins]\nOriginal content unavailable\n[Quoted message ends]\n`;
}
// ---- 12. Dynamic context ----
const dynLines: string[] = [];
if (imageUrls.length > 0) {
dynLines.push(`- Images: ${imageUrls.join(", ")}`);
}
if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
dynLines.push(`- Voice: ${[...uniqueVoicePaths, ...uniqueVoiceUrls].join(", ")}`);
}
if (uniqueVoiceAsrReferTexts.length > 0) {
dynLines.push(`- ASR: ${uniqueVoiceAsrReferTexts.join(" | ")}`);
}
const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n\n" : "";
// ---- 13. agentBody ----
const userMessage = `${quotePart}${userContent}`;
const agentBody = userContent.startsWith("/") ? userContent : `${dynamicCtx}${userMessage}`;
// ---- 14. GroupSystemPrompt ----
const qqbotSystemInstruction = systemPrompts.length > 0 ? systemPrompts.join("\n") : "";
const groupSystemPrompt = qqbotSystemInstruction || undefined;
// ---- 15. Auth: commandAuthorized semantics ----
//
// `commandAuthorized=true` means the framework is allowed to honour
// `/xxx` directives (e.g. `/exec host=... ask=...`) from this sender.
//
// We treat the sender as authorized when one of the following holds:
// - DM with policy=open (the bot owner implicitly trusts DMs)
// - DM with policy=allowlist and sender matched
// - Group where the sender is explicitly in groupAllowFrom/allowFrom
// (matches the `allowlist (allowlisted)` reason string).
//
// Notably, a group running in `policy=open` does NOT grant command
// authorization to arbitrary group members, aligning with the other
// channel plugins (Telegram/WhatsApp/Discord) which require explicit
// allowlist membership for command-level gating.
const commandAuthorized =
access.reasonCode === "dm_policy_open" ||
access.reasonCode === "dm_policy_allowlisted" ||
(access.reasonCode === "group_policy_allowed" &&
access.effectiveGroupAllowFrom.length > 0 &&
access.groupPolicy === "allowlist");
// ---- 16. Media path classification ----
const localMediaPaths: string[] = [];
const localMediaTypes: string[] = [];
const remoteMediaUrls: string[] = [];
const remoteMediaTypes: string[] = [];
for (let i = 0; i < imageUrls.length; i++) {
const u = imageUrls[i];
const t = imageMediaTypes[i] ?? "image/png";
if (u.startsWith("http://") || u.startsWith("https://")) {
remoteMediaUrls.push(u);
remoteMediaTypes.push(t);
} else {
localMediaPaths.push(u);
localMediaTypes.push(t);
}
}
return {
event,
route,
isGroupChat,
peerId,
qualifiedTarget,
fromAddress,
parsedContent,
userContent,
quotePart,
dynamicCtx,
userMessage,
agentBody,
body,
systemPrompts,
groupSystemPrompt,
attachments: processed,
localMediaPaths,
localMediaTypes,
remoteMediaUrls,
remoteMediaTypes,
uniqueVoicePaths,
uniqueVoiceUrls,
uniqueVoiceAsrReferTexts,
hasAsrReferFallback,
voiceTranscriptSources,
replyTo,
commandAuthorized,
blocked: false,
accessDecision: access.decision,
typing: { keepAlive: typingResult.keepAlive },
inputNotifyRefIdx,
};
}
/**
* Build a stub InboundContext for blocked (unauthorized) messages.
*
* The gateway handler inspects `blocked` and skips outbound dispatch,
* so most fields can be left empty. We still populate routing/peer
* fields so logs and metrics remain meaningful.
*/
function buildBlockedInboundContext(params: {
event: QueuedMessage;
route: { sessionKey: string; accountId: string; agentId?: string };
isGroupChat: boolean;
peerId: string;
qualifiedTarget: string;
fromAddress: string;
access: QQBotAccessResult;
}): InboundContext {
const emptyProcessed: InboundContext["attachments"] = {
attachmentInfo: "",
imageUrls: [],
imageMediaTypes: [],
voiceAttachmentPaths: [],
voiceAttachmentUrls: [],
voiceAsrReferTexts: [],
voiceTranscripts: [],
voiceTranscriptSources: [],
attachmentLocalPaths: [],
};
return {
event: params.event,
route: params.route,
isGroupChat: params.isGroupChat,
peerId: params.peerId,
qualifiedTarget: params.qualifiedTarget,
fromAddress: params.fromAddress,
parsedContent: "",
userContent: "",
quotePart: "",
dynamicCtx: "",
userMessage: "",
agentBody: "",
body: "",
systemPrompts: [],
groupSystemPrompt: undefined,
attachments: emptyProcessed,
localMediaPaths: [],
localMediaTypes: [],
remoteMediaUrls: [],
remoteMediaTypes: [],
uniqueVoicePaths: [],
uniqueVoiceUrls: [],
uniqueVoiceAsrReferTexts: [],
hasAsrReferFallback: false,
voiceTranscriptSources: [],
replyTo: undefined,
commandAuthorized: false,
blocked: true,
blockReason: params.access.reason,
blockReasonCode: params.access.reasonCode,
accessDecision: params.access.decision,
typing: { keepAlive: null },
inputNotifyRefIdx: undefined,
};
}
// ============ Quote resolution (internal) ============
async function resolveQuote(
event: QueuedMessage,
account: InboundPipelineDeps["account"],
cfg: unknown,
log?: InboundPipelineDeps["log"],
): Promise<InboundContext["replyTo"]> {
if (!event.refMsgIdx) {
return undefined;
}
const refEntry = getRefIndex(event.refMsgIdx);
if (refEntry) {
log?.debug?.(
`Quote detected via refMsgIdx cache: refMsgIdx=${event.refMsgIdx}, sender=${refEntry.senderName ?? refEntry.senderId}`,
);
return {
id: event.refMsgIdx,
body: formatRefEntryForAgent(refEntry),
sender: refEntry.senderName ?? refEntry.senderId,
isQuote: true,
};
}
if (event.msgType === MSG_TYPE_QUOTE && event.msgElements?.[0]) {
try {
const refElement = event.msgElements[0];
const refData = {
content: refElement.content ?? "",
attachments: refElement.attachments,
};
const attachmentProcessor: AttachmentProcessor = {
processAttachments: async (atts, refCtx) => {
const result = await processAttachments(
atts as Array<{
content_type: string;
url: string;
filename?: string;
voice_wav_url?: string;
asr_refer_text?: string;
}>,
{
accountId: account.accountId,
cfg: refCtx.cfg,
log: refCtx.log,
},
);
return {
attachmentInfo: result.attachmentInfo,
voiceTranscripts: result.voiceTranscripts,
voiceTranscriptSources: result.voiceTranscriptSources,
attachmentLocalPaths: result.attachmentLocalPaths,
};
},
formatVoiceText: (transcripts) => formatVoiceText(transcripts),
};
const refPeerId =
event.type === "group" && event.groupOpenid ? event.groupOpenid : event.senderId;
const refBody = await formatMessageReferenceForAgent(
refData,
{ appId: account.appId, peerId: refPeerId, cfg: account.config, log },
attachmentProcessor,
);
log?.debug?.(
`Quote detected via msg_elements[0] (cache miss): id=${event.refMsgIdx}, content="${(refBody ?? "").slice(0, 80)}..."`,
);
return {
id: event.refMsgIdx,
body: refBody || undefined,
isQuote: true,
};
} catch (refErr) {
log?.error(`Failed to format quoted message from msg_elements: ${String(refErr)}`);
}
} else {
log?.debug?.(
`Quote detected but no cache and msgType=${event.msgType}: refMsgIdx=${event.refMsgIdx}`,
);
}
return {
id: event.refMsgIdx,
isQuote: true,
};
}

View File

@@ -1,4 +1,14 @@
import type { QueueSnapshot } from "./slash-commands.js";
/**
* Per-user concurrent message queue.
*
* Messages are serialized per user (peer) and processed in parallel across
* users, up to a configurable concurrency limit.
*
* This module is independent of any framework SDK it only needs a logger
* and an abort-state probe supplied via {@link MessageQueueContext}.
*/
import { formatErrorMessage } from "../utils/format.js";
// Message queue limits.
const MESSAGE_QUEUE_SIZE = 1000;
@@ -29,6 +39,23 @@ export interface QueuedMessage {
refMsgIdx?: string;
/** refIdx assigned to this message for future quoting. */
msgIdx?: string;
/** QQ message type (103 = quote). */
msgType?: number;
/** Referenced message elements (for quote messages). */
msgElements?: Array<{
msg_idx?: string;
content?: string;
attachments?: Array<{
content_type: string;
url: string;
filename?: string;
height?: number;
width?: number;
size?: number;
voice_wav_url?: string;
asr_refer_text?: string;
}>;
}>;
}
export interface MessageQueueContext {
@@ -42,6 +69,14 @@ export interface MessageQueueContext {
isAborted: () => boolean;
}
/** Snapshot of the queue state for diagnostics. */
export interface QueueSnapshot {
totalPending: number;
activeUsers: number;
maxConcurrentUsers: number;
senderPending: number;
}
export interface MessageQueue {
enqueue: (msg: QueuedMessage) => void;
startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise<void>) => void;
@@ -58,7 +93,7 @@ export interface MessageQueue {
* Messages are serialized per user and processed in parallel across users.
*/
export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
const { accountId, log } = ctx;
const { accountId: _accountId, log } = ctx;
const userQueues = new Map<string, QueuedMessage[]>();
const activeUsers = new Set<string>();
@@ -81,9 +116,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
return;
}
if (activeUsers.size >= MAX_CONCURRENT_USERS) {
log?.info(
`[qqbot:${accountId}] Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`,
);
log?.info(`Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`);
return;
}
@@ -105,7 +138,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
messagesProcessed++;
}
} catch (err) {
log?.error(`[qqbot:${accountId}] Message processor error for ${peerId}: ${String(err)}`);
log?.error(`Message processor error for ${peerId}: ${formatErrorMessage(err)}`);
}
}
} finally {
@@ -133,20 +166,20 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
if (queue.length >= PER_USER_QUEUE_SIZE) {
const dropped = queue.shift();
log?.error(
`[qqbot:${accountId}] Per-user queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`,
`Per-user queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`,
);
}
totalEnqueued++;
if (totalEnqueued > MESSAGE_QUEUE_SIZE) {
log?.error(
`[qqbot:${accountId}] Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`,
`Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`,
);
}
queue.push(msg);
log?.debug?.(
`[qqbot:${accountId}] Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`,
`Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`,
);
void drainUserQueue(peerId);
@@ -154,8 +187,8 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
const startProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise<void>): void => {
handleMessageFnRef = handleMessageFn;
log?.info(
`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`,
log?.debug?.(
`Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`,
);
};
@@ -187,7 +220,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
const executeImmediate = (msg: QueuedMessage): void => {
if (handleMessageFnRef) {
handleMessageFnRef(msg).catch((err) => {
log?.error(`[qqbot:${accountId}] Immediate execution error: ${err}`);
log?.error(`Immediate execution error: ${err}`);
});
}
};

View File

@@ -0,0 +1,403 @@
/**
* Outbound dispatcher — manage AI reply delivery, tool fallback, and timeouts.
*
* Responsibilities:
* 1. Build ctxPayload and call runtime.dispatchReply
* 2. Tool deliver collection + fallback timeout
* 3. Block deliver pipeline (consumeQuoteRef → media tags → structured payload → plain text)
* 4. Timeout / error handling
*
* Separated from gateway.ts for testability and to keep handleMessage thin.
*/
import {
parseAndSendMediaTags,
sendPlainReply,
type DeliverDeps,
} from "../messaging/outbound-deliver.js";
import {
sendDocument,
sendMedia,
sendPhoto,
sendVoice,
sendVideoMsg,
} from "../messaging/outbound.js";
import {
handleStructuredPayload,
sendErrorToTarget,
sendWithTokenRetry,
type ReplyDispatcherDeps,
} from "../messaging/reply-dispatcher.js";
import { audioFileToSilkBase64 } from "../utils/audio.js";
import type { InboundContext } from "./inbound-context.js";
import type {
GatewayAccount,
EngineLogger,
GatewayPluginRuntime,
OutboundResult,
} from "./types.js";
// ============ Config ============
const RESPONSE_TIMEOUT = 120_000;
const TOOL_ONLY_TIMEOUT = 60_000;
const MAX_TOOL_RENEWALS = 3;
const TOOL_MEDIA_SEND_TIMEOUT = 45_000;
// ============ Dependencies ============
export interface OutboundDispatchDeps {
runtime: GatewayPluginRuntime;
cfg: unknown;
account: GatewayAccount;
log?: EngineLogger;
}
// ============ dispatchOutbound ============
/**
* Dispatch the AI reply for the given inbound context.
*
* Handles tool deliver collection, block deliver pipeline, and timeouts.
* The caller is responsible for stopping typing.keepAlive in `finally`.
*/
export async function dispatchOutbound(
inbound: InboundContext,
deps: OutboundDispatchDeps,
): Promise<void> {
const { runtime, cfg, account, log } = deps;
const { event, qualifiedTarget } = inbound;
const replyTarget = {
type: event.type,
senderId: event.senderId,
messageId: event.messageId,
channelId: event.channelId,
guildId: event.guildId,
groupOpenid: event.groupOpenid,
};
const replyCtx = { target: replyTarget, account, cfg, log };
const sendWithRetry = <T>(sendFn: (token: string) => Promise<T>) =>
sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId);
const sendErrorMessage = (errorText: string) => sendErrorToTarget(replyCtx, errorText);
// ---- Build ctxPayload ----
const ctxPayload = buildCtxPayload(inbound, runtime);
// ---- Deliver state ----
let hasResponse = false;
let hasBlockResponse = false;
let toolDeliverCount = 0;
const toolTexts: string[] = [];
const toolMediaUrls: string[] = [];
let toolFallbackSent = false;
let toolRenewalCount = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let toolOnlyTimeoutId: ReturnType<typeof setTimeout> | null = null;
// ---- Tool fallback ----
const sendToolFallback = async (): Promise<void> => {
if (toolMediaUrls.length > 0) {
for (const mediaUrl of toolMediaUrls) {
const ac = new AbortController();
try {
const result = await Promise.race([
sendMedia({
to: qualifiedTarget,
text: "",
mediaUrl,
accountId: account.accountId,
replyToId: event.messageId,
account,
}).then((r) => {
if (ac.signal.aborted) {
return { channel: "qqbot", error: "suppressed" } as OutboundResult;
}
return r;
}),
new Promise<OutboundResult>((resolve) =>
setTimeout(() => {
ac.abort();
resolve({ channel: "qqbot", error: "timeout" });
}, TOOL_MEDIA_SEND_TIMEOUT),
),
]);
if (result.error) {
log?.error(`Tool fallback error: ${result.error}`);
}
} catch (err) {
log?.error(`Tool fallback failed: ${String(err)}`);
}
}
return;
}
if (toolTexts.length > 0) {
await sendErrorMessage(toolTexts.slice(-3).join("\n---\n").slice(0, 2000));
}
};
// ---- Timeout promise ----
const timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => {
if (!hasResponse) {
reject(new Error("Response timeout"));
}
}, RESPONSE_TIMEOUT);
});
// ---- Deliver deps ----
const deliverDeps: DeliverDeps = {
mediaSender: {
sendPhoto: (target, imageUrl) => sendPhoto(target, imageUrl),
sendVoice: (target, voicePath, uploadFormats, transcodeEnabled) =>
sendVoice(target, voicePath, uploadFormats, transcodeEnabled),
sendVideoMsg: (target, videoPath) => sendVideoMsg(target, videoPath),
sendDocument: (target, filePath) => sendDocument(target, filePath),
sendMedia: (opts) => sendMedia(opts),
},
chunkText: (text, limit) => runtime.channel.text.chunkMarkdownText(text, limit),
};
const replyDeps: ReplyDispatcherDeps = {
tts: {
textToSpeech: (params) => runtime.tts.textToSpeech(params),
audioFileToSilkBase64: async (p) => (await audioFileToSilkBase64(p)) ?? undefined,
},
};
const recordOutbound = () =>
runtime.channel.activity.record({
channel: "qqbot",
accountId: account.accountId,
direction: "outbound",
});
// ---- Dispatch ----
const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(
cfg,
inbound.route.agentId,
);
const dispatchPromise = runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: messagesConfig.responsePrefix,
deliver: async (
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string },
info: { kind: string },
) => {
hasResponse = true;
// ---- Tool deliver ----
if (info.kind === "tool") {
toolDeliverCount++;
const toolText = (payload.text ?? "").trim();
if (toolText) {
toolTexts.push(toolText);
}
if (payload.mediaUrls?.length) {
toolMediaUrls.push(...payload.mediaUrls);
}
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
toolMediaUrls.push(payload.mediaUrl);
}
if (hasBlockResponse && toolMediaUrls.length > 0) {
const urlsToSend = [...toolMediaUrls];
toolMediaUrls.length = 0;
for (const mediaUrl of urlsToSend) {
try {
await sendMedia({
to: qualifiedTarget,
text: "",
mediaUrl,
accountId: account.accountId,
replyToId: event.messageId,
account,
});
} catch {}
}
return;
}
if (toolFallbackSent) {
return;
}
if (toolOnlyTimeoutId) {
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
clearTimeout(toolOnlyTimeoutId);
toolRenewalCount++;
} else {
return;
}
}
toolOnlyTimeoutId = setTimeout(async () => {
if (!hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
try {
await sendToolFallback();
} catch {}
}
}, TOOL_ONLY_TIMEOUT);
return;
}
// ---- Block deliver ----
hasBlockResponse = true;
inbound.typing.keepAlive?.stop();
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
const quoteRef = event.msgIdx;
let quoteRefUsed = false;
const consumeQuoteRef = (): string | undefined => {
if (quoteRef && !quoteRefUsed) {
quoteRefUsed = true;
return quoteRef;
}
return undefined;
};
let replyText = payload.text ?? "";
const deliverEvent = {
type: event.type,
senderId: event.senderId,
messageId: event.messageId,
channelId: event.channelId,
groupOpenid: event.groupOpenid,
msgIdx: event.msgIdx,
};
const deliverActx = { account, qualifiedTarget, log };
// 1. Media tags
const mediaResult = await parseAndSendMediaTags(
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
deliverDeps,
);
if (mediaResult.handled) {
recordOutbound();
return;
}
replyText = mediaResult.normalizedText;
// 2. Structured payload (QQBOT_PAYLOAD:)
const handled = await handleStructuredPayload(
replyCtx,
replyText,
recordOutbound,
replyDeps,
);
if (handled) {
return;
}
// 3. Plain text + images
await sendPlainReply(
payload,
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
toolMediaUrls,
deliverDeps,
);
recordOutbound();
},
onError: async (err: unknown) => {
const errMsg = err instanceof Error ? err.message : String(err);
log?.error(`Dispatch error: ${errMsg}`);
hasResponse = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
},
},
replyOptions: { disableBlockStreaming: account.config.streaming?.mode === "off" },
});
try {
await Promise.race([dispatchPromise, timeoutPromise]);
} catch {
if (timeoutId) {
clearTimeout(timeoutId);
}
} finally {
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
await sendToolFallback();
}
}
}
// ============ ctxPayload builder ============
function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime): unknown {
const { event } = inbound;
return runtime.channel.reply.finalizeInboundContext({
Body: inbound.body,
BodyForAgent: inbound.agentBody,
RawBody: event.content,
CommandBody: event.content,
From: inbound.fromAddress,
To: inbound.fromAddress,
SessionKey: inbound.route.sessionKey,
AccountId: inbound.route.accountId,
ChatType: inbound.isGroupChat ? "group" : "direct",
GroupSystemPrompt: inbound.groupSystemPrompt,
SenderId: event.senderId,
SenderName: event.senderName,
Provider: "qqbot",
Surface: "qqbot",
MessageSid: event.messageId,
Timestamp: new Date(event.timestamp).getTime(),
OriginatingChannel: "qqbot",
OriginatingTo: inbound.fromAddress,
QQChannelId: event.channelId,
QQGuildId: event.guildId,
QQGroupOpenid: event.groupOpenid,
QQVoiceAsrReferAvailable: inbound.hasAsrReferFallback,
QQVoiceTranscriptSources: inbound.voiceTranscriptSources,
QQVoiceAttachmentPaths: inbound.uniqueVoicePaths,
QQVoiceAttachmentUrls: inbound.uniqueVoiceUrls,
QQVoiceAsrReferTexts: inbound.uniqueVoiceAsrReferTexts,
QQVoiceInputStrategy: "prefer_audio_stt_then_asr_fallback",
CommandAuthorized: inbound.commandAuthorized,
...(inbound.localMediaPaths.length > 0
? {
MediaPaths: inbound.localMediaPaths,
MediaPath: inbound.localMediaPaths[0],
MediaTypes: inbound.localMediaTypes,
MediaType: inbound.localMediaTypes[0],
}
: {}),
...(inbound.remoteMediaUrls.length > 0
? { MediaUrls: inbound.remoteMediaUrls, MediaUrl: inbound.remoteMediaUrls[0] }
: {}),
...(inbound.replyTo
? {
ReplyToId: inbound.replyTo.id,
ReplyToBody: inbound.replyTo.body,
ReplyToSender: inbound.replyTo.sender,
ReplyToIsQuote: inbound.replyTo.isQuote,
}
: {}),
});
}

View File

@@ -0,0 +1,199 @@
/**
* WebSocket reconnection state machine and close-code handler.
*
* Encapsulates the reconnect delay scheduling, quick-disconnect detection,
* and close-code interpretation that both plugin versions share.
*
* Zero external dependencies — uses only the constants from `./constants.ts`.
*/
import type { EngineLogger } from "../types.js";
import {
RECONNECT_DELAYS,
RATE_LIMIT_DELAY,
MAX_RECONNECT_ATTEMPTS,
MAX_QUICK_DISCONNECT_COUNT,
QUICK_DISCONNECT_THRESHOLD,
GatewayCloseCode,
} from "./constants.js";
/** Actions the caller should take after processing a close event. */
export interface CloseAction {
/** Whether to schedule a reconnect. */
shouldReconnect: boolean;
/** Custom delay override (ms), or undefined to use the default backoff. */
reconnectDelay?: number;
/** Whether the session is invalidated and should be cleared. */
clearSession: boolean;
/** Whether the token should be refreshed before reconnecting. */
refreshToken: boolean;
/** Whether the bot is fatally blocked (offline/banned) and should stop. */
fatal: boolean;
/** Human-readable description of the close reason. */
reason: string;
}
/**
* Reconnection state machine.
*
* Usage:
* ```ts
* const rs = new ReconnectState('account-1', log);
* // On successful connect:
* rs.onConnected();
* // On close:
* const action = rs.handleClose(code);
* if (action.shouldReconnect) {
* const delay = rs.getNextDelay(action.reconnectDelay);
* setTimeout(connect, delay);
* }
* ```
*/
export class ReconnectState {
private attempts = 0;
private lastConnectTime = 0;
private quickDisconnectCount = 0;
constructor(
private readonly accountId: string,
private readonly log?: EngineLogger,
) {}
/** Call when a WebSocket connection is successfully established. */
onConnected(): void {
this.attempts = 0;
this.lastConnectTime = Date.now();
}
/** Whether reconnection attempts are exhausted. */
isExhausted(): boolean {
return this.attempts >= MAX_RECONNECT_ATTEMPTS;
}
/**
* Compute the next reconnect delay and increment the attempt counter.
*
* @param customDelay Override from `CloseAction.reconnectDelay`.
* @returns Delay in milliseconds.
*/
getNextDelay(customDelay?: number): number {
const delay =
customDelay ?? RECONNECT_DELAYS[Math.min(this.attempts, RECONNECT_DELAYS.length - 1)];
this.attempts++;
this.log?.debug?.(`Reconnecting in ${delay}ms (attempt ${this.attempts})`);
return delay;
}
/**
* Interpret a WebSocket close code and return the appropriate action.
*/
handleClose(code: number, isAborted: boolean): CloseAction {
// Fatal: bot offline or banned.
if (
code === GatewayCloseCode.INSUFFICIENT_INTENTS ||
code === GatewayCloseCode.DISALLOWED_INTENTS
) {
const reason =
code === GatewayCloseCode.INSUFFICIENT_INTENTS ? "offline/sandbox-only" : "banned";
this.log?.error(`Bot is ${reason}. Please contact QQ platform.`);
return {
shouldReconnect: false,
clearSession: false,
refreshToken: false,
fatal: true,
reason,
};
}
// Invalid token.
if (code === GatewayCloseCode.AUTH_FAILED) {
this.log?.info(`Invalid token (4004), will refresh token and reconnect`);
return {
shouldReconnect: !isAborted,
clearSession: false,
refreshToken: true,
fatal: false,
reason: "invalid token (4004)",
};
}
// Rate limited.
if (code === GatewayCloseCode.RATE_LIMITED) {
this.log?.info(`Rate limited (4008), waiting ${RATE_LIMIT_DELAY}ms`);
return {
shouldReconnect: !isAborted,
reconnectDelay: RATE_LIMIT_DELAY,
clearSession: false,
refreshToken: false,
fatal: false,
reason: "rate limited (4008)",
};
}
// Session invalid / seq invalid / session timeout.
if (
code === GatewayCloseCode.INVALID_SESSION ||
code === GatewayCloseCode.SEQ_OUT_OF_RANGE ||
code === GatewayCloseCode.SESSION_TIMEOUT
) {
const codeDesc: Record<number, string> = {
[GatewayCloseCode.INVALID_SESSION]: "session no longer valid",
[GatewayCloseCode.SEQ_OUT_OF_RANGE]: "invalid seq on resume",
[GatewayCloseCode.SESSION_TIMEOUT]: "session timed out",
};
this.log?.info(`Error ${code} (${codeDesc[code]}), will re-identify`);
return {
shouldReconnect: !isAborted,
clearSession: true,
refreshToken: true,
fatal: false,
reason: codeDesc[code],
};
}
// Internal server errors.
if (code >= GatewayCloseCode.SERVER_ERROR_START && code <= GatewayCloseCode.SERVER_ERROR_END) {
this.log?.info(`Internal error (${code}), will re-identify`);
return {
shouldReconnect: !isAborted && code !== GatewayCloseCode.NORMAL,
clearSession: true,
refreshToken: true,
fatal: false,
reason: `internal error (${code})`,
};
}
// Quick disconnect detection.
const connectionDuration = Date.now() - this.lastConnectTime;
if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && this.lastConnectTime > 0) {
this.quickDisconnectCount++;
this.log?.debug?.(
`Quick disconnect detected (${connectionDuration}ms), count: ${this.quickDisconnectCount}`,
);
if (this.quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) {
this.log?.error(`Too many quick disconnects. This may indicate a permission issue.`);
this.quickDisconnectCount = 0;
return {
shouldReconnect: !isAborted && code !== 1000,
reconnectDelay: RATE_LIMIT_DELAY,
clearSession: false,
refreshToken: false,
fatal: false,
reason: "too many quick disconnects",
};
}
} else {
this.quickDisconnectCount = 0;
}
// Default: reconnect with backoff.
return {
shouldReconnect: !isAborted && code !== GatewayCloseCode.NORMAL,
clearSession: false,
refreshToken: false,
fatal: false,
reason: `close code ${code}`,
};
}
}

View File

@@ -0,0 +1,170 @@
/**
* Gateway types.
*
* core/gateway/gateway.ts now imports all dependencies directly (both
* core/ modules and upper-layer files). The only injected dependency
* is `runtime` (PluginRuntime), which is a framework-provided object.
*/
// ============ Logger ============
import type { EngineLogger } from "../types.js";
export type { EngineLogger };
// ============ Account ============
/** Re-export GatewayAccount from engine/types.ts (single source of truth). */
import type { GatewayAccount as _GatewayAccount } from "../types.js";
export type GatewayAccount = _GatewayAccount;
// ============ PluginRuntime subset ============
/**
* Subset of PluginRuntime used by the gateway.
*
* This is NOT a custom adapter — it's the exact same object shape that
* the framework injects. We define it here so core/ doesn't need to
* depend on the plugin-sdk root barrel.
*/
export interface GatewayPluginRuntime {
channel: {
activity: {
record: (params: {
channel: string;
accountId: string;
direction: "inbound" | "outbound";
}) => void;
};
routing: {
resolveAgentRoute: (params: {
cfg: unknown;
channel: string;
accountId: string;
peer: { kind: "group" | "direct"; id: string };
}) => { sessionKey: string; accountId: string; agentId?: string };
};
reply: {
dispatchReplyWithBufferedBlockDispatcher: (params: unknown) => Promise<unknown>;
resolveEffectiveMessagesConfig: (
cfg: unknown,
agentId?: string,
) => { responsePrefix?: string };
finalizeInboundContext: (fields: Record<string, unknown>) => unknown;
formatInboundEnvelope: (params: unknown) => string;
resolveEnvelopeFormatOptions: (cfg: unknown) => unknown;
};
text: {
chunkMarkdownText: (text: string, limit: number) => string[];
};
};
tts: {
textToSpeech: (params: { text: string; cfg: unknown; channel: string }) => Promise<{
success: boolean;
audioPath?: string;
provider?: string;
outputFormat?: string;
error?: string;
}>;
};
}
// ============ Shared result types ============
/** Re-export ProcessedAttachments from inbound-attachments (single source of truth). */
export type { ProcessedAttachments } from "./inbound-attachments.js";
/** Outbound result from media sends. */
export interface OutboundResult {
channel: string;
messageId?: string;
timestamp?: string | number;
error?: string;
}
/** Re-export RefAttachmentSummary for convenience. */
export type { RefAttachmentSummary } from "../ref/types.js";
// ============ WebSocket Event Types ============
/** Raw WebSocket payload structure. */
export interface WSPayload {
op: number;
d: unknown;
s?: number;
t?: string;
}
/** Attachment shape shared by all message event types. */
export interface RawMessageAttachment {
content_type: string;
url: string;
filename?: string;
voice_wav_url?: string;
asr_refer_text?: string;
}
/** Referenced message element (used for quote messages). */
export interface RawMsgElement {
msg_idx?: string;
content?: string;
attachments?: Array<
RawMessageAttachment & {
height?: number;
width?: number;
size?: number;
}
>;
}
export interface C2CMessageEvent {
id: string;
content: string;
timestamp: string;
author: { user_openid: string };
attachments?: RawMessageAttachment[];
message_scene?: { ext?: string[] };
message_type?: number;
msg_elements?: RawMsgElement[];
}
export interface GuildMessageEvent {
id: string;
content: string;
timestamp: string;
author: { id: string; username?: string };
channel_id: string;
guild_id: string;
attachments?: RawMessageAttachment[];
message_scene?: { ext?: string[] };
}
export interface GroupMessageEvent {
id: string;
content: string;
timestamp: string;
author: { member_openid: string };
group_openid: string;
attachments?: RawMessageAttachment[];
message_scene?: { ext?: string[] };
message_type?: number;
msg_elements?: RawMsgElement[];
}
// ============ Gateway Context ============
/** Full gateway startup context. Only `runtime` is injected; everything else is imported directly. */
export interface CoreGatewayContext {
account: GatewayAccount;
abortSignal: AbortSignal;
cfg: unknown;
onReady?: (data: unknown) => void;
/**
* Invoked when a RESUMED event is received after reconnect.
* Falls back to `onReady` when not provided so existing callers
* keep their current behaviour.
*/
onResumed?: (data: unknown) => void;
onError?: (error: Error) => void;
log?: EngineLogger;
/** PluginRuntime injected by the framework — same object in both versions. */
runtime: GatewayPluginRuntime;
}

View File

@@ -1,8 +1,21 @@
/** Periodically refresh C2C typing state while a response is still in progress. */
/**
* Periodically refresh C2C typing state while a response is in progress.
*
* All I/O operations are injected via constructor parameters so this
* module has zero external dependencies and can run in both plugin versions.
*/
import { sendC2CInputNotify } from "./api.js";
import { formatErrorMessage } from "../utils/format.js";
// Refresh every 50s for the QQ API's 60s input-notify window.
/** Function that sends a typing indicator to one user. */
export type SendInputNotifyFn = (
token: string,
openid: string,
msgId: string | undefined,
inputSecond: number,
) => Promise<unknown>;
/** Refresh every 50s for the QQ API's 60s input-notify window. */
export const TYPING_INTERVAL_MS = 50_000;
export const TYPING_INPUT_SECOND = 60;
@@ -13,6 +26,7 @@ export class TypingKeepAlive {
constructor(
private readonly getToken: () => Promise<string>,
private readonly clearCache: () => void,
private readonly sendInputNotify: SendInputNotifyFn,
private readonly openid: string,
private readonly msgId: string | undefined,
private readonly log?: {
@@ -20,7 +34,6 @@ export class TypingKeepAlive {
error: (msg: string) => void;
debug?: (msg: string) => void;
},
private readonly logPrefix = "[qqbot]",
) {}
/** Start periodic keep-alive sends. */
@@ -49,16 +62,16 @@ export class TypingKeepAlive {
private async send(): Promise<void> {
try {
const token = await this.getToken();
await sendC2CInputNotify(token, this.openid, this.msgId, TYPING_INPUT_SECOND);
this.log?.debug?.(`${this.logPrefix} Typing keep-alive sent to ${this.openid}`);
await this.sendInputNotify(token, this.openid, this.msgId, TYPING_INPUT_SECOND);
this.log?.debug?.(`Typing keep-alive sent to ${this.openid}`);
} catch (err) {
try {
this.clearCache();
const token = await this.getToken();
await sendC2CInputNotify(token, this.openid, this.msgId, TYPING_INPUT_SECOND);
await this.sendInputNotify(token, this.openid, this.msgId, TYPING_INPUT_SECOND);
} catch {
this.log?.debug?.(
`${this.logPrefix} Typing keep-alive failed for ${this.openid}: ${String(err)}`,
`Typing keep-alive failed for ${this.openid}: ${formatErrorMessage(err)}`,
);
}
}

View File

@@ -0,0 +1,155 @@
/**
* Message deliver debounce — merge multiple rapid deliver calls into one.
*
* When QQ Bot sends multiple messages in quick succession (e.g. streaming
* partial responses), this module buffers them within a configurable time
* window and merges them into a single outbound message.
*
* This prevents "message bombing" in group chats where rapid-fire messages
* flood the chat and annoy users.
*
* The module is a pure function / class with zero I/O dependencies.
*/
/** Configuration for the deliver debouncer. */
export interface DeliverDebounceConfig {
/** Whether debouncing is enabled. Defaults to true. */
enabled: boolean;
/** Time window in milliseconds. Defaults to 1500ms. */
windowMs?: number;
}
/** Payload passed to deliver callbacks. */
export interface DeliverPayload {
text?: string;
mediaUrls?: string[];
mediaUrl?: string;
}
/** Deliver callback info. */
export interface DeliverInfo {
kind: string;
}
/** The actual deliver function signature. */
export type DeliverFn = (payload: DeliverPayload, info: DeliverInfo) => Promise<void>;
interface PendingEntry {
texts: string[];
mediaUrls: string[];
timer: ReturnType<typeof setTimeout>;
resolve: () => void;
}
/**
* Debouncer that merges rapid-fire deliver calls within a time window.
*
* Usage:
* ```ts
* const debouncer = new DeliverDebouncer({ enabled: true, windowMs: 1500 });
*
* // In the deliver callback:
* await debouncer.deliver(payload, info, originalDeliverFn);
* ```
*/
export class DeliverDebouncer {
private readonly enabled: boolean;
private readonly windowMs: number;
private readonly pending = new Map<string, PendingEntry>();
constructor(config?: DeliverDebounceConfig) {
this.enabled = config?.enabled !== false;
this.windowMs = config?.windowMs ?? 1500;
}
/**
* Buffer a deliver call and flush after the window expires.
*
* @param payload - The deliver payload.
* @param info - Deliver metadata (kind, etc.).
* @param actualDeliver - The real deliver function to call with merged content.
* @param peerId - Peer identifier for per-conversation debouncing.
*/
async deliver(
payload: DeliverPayload,
info: DeliverInfo,
actualDeliver: DeliverFn,
peerId = "default",
): Promise<void> {
// Pass through immediately when debouncing is disabled.
if (!this.enabled) {
return actualDeliver(payload, info);
}
// Media payloads flush any buffered text first, then send immediately.
const hasMedia = (payload.mediaUrls && payload.mediaUrls.length > 0) || !!payload.mediaUrl;
if (hasMedia) {
await this.flush(peerId, actualDeliver, info);
return actualDeliver(payload, info);
}
const text = (payload.text ?? "").trim();
if (!text) {
return;
}
const existing = this.pending.get(peerId);
if (existing) {
// Extend the buffer with the new text.
existing.texts.push(text);
// Reset the timer.
clearTimeout(existing.timer);
existing.timer = setTimeout(() => {
this.flush(peerId, actualDeliver, info).catch(() => {});
}, this.windowMs);
// The caller awaits the same promise as the first buffered call.
return new Promise<void>((resolve) => {
const origResolve = existing.resolve;
existing.resolve = () => {
origResolve();
resolve();
};
});
}
// First message in a new window — start buffering.
return new Promise<void>((resolve) => {
const entry: PendingEntry = {
texts: [text],
mediaUrls: [],
timer: setTimeout(() => {
this.flush(peerId, actualDeliver, info).catch(() => {});
}, this.windowMs),
resolve,
};
this.pending.set(peerId, entry);
});
}
/** Flush buffered content for a peer and invoke the actual deliver. */
private async flush(peerId: string, actualDeliver: DeliverFn, info: DeliverInfo): Promise<void> {
const entry = this.pending.get(peerId);
if (!entry) {
return;
}
this.pending.delete(peerId);
clearTimeout(entry.timer);
const mergedText = entry.texts.join("\n").trim();
if (mergedText) {
await actualDeliver({ text: mergedText }, info);
}
entry.resolve();
}
/** Force-flush all pending entries (e.g. during shutdown). */
async flushAll(actualDeliver: DeliverFn, info: DeliverInfo): Promise<void> {
const peerIds = [...this.pending.keys()];
for (const peerId of peerIds) {
await this.flush(peerId, actualDeliver, info);
}
}
}

View File

@@ -0,0 +1,72 @@
/**
* Group message gating — three-layer access control for group messages.
*
* 1. `ignoreOtherMentions` — skip messages that @other bots, not this one.
* 2. `shouldBlock` — enforce allowFrom whitelist at the group level.
* 3. `mentionGating` — require explicit @bot mention in group chats.
*
* All functions are **pure** (no side effects, no I/O), making them easy to
* test and safe to share between the built-in and standalone versions.
*/
/** Result of the group message gate evaluation. */
export interface GateResult {
/** Whether the message should be blocked (i.e. not processed). */
blocked: boolean;
/** Reason for blocking (for logging). */
reason?: string;
/** Whether the sender is authorized for slash commands. */
commandAuthorized: boolean;
}
/** Configuration relevant to group message gating. */
export interface GroupGateConfig {
/** Normalized allowFrom list (uppercase, `qqbot:` prefix stripped). */
normalizedAllowFrom: string[];
/**
* Whether to ignore messages that mention other bots.
* When true, messages containing @mentions for other bot IDs are silently dropped.
*/
ignoreOtherMentions?: boolean;
}
/**
* Evaluate the group message gate for one inbound message.
*
* @param senderId - The sender's openid (raw, not normalized).
* @param config - Group gating configuration.
* @returns The gate evaluation result.
*/
export function resolveGroupMessageGate(senderId: string, config: GroupGateConfig): GateResult {
const { normalizedAllowFrom } = config;
// Normalize the sender ID for comparison.
const normalizedSenderId = senderId.replace(/^qqbot:/i, "").toUpperCase();
// Open gate: empty allowFrom or wildcard means everyone is allowed.
const allowAll = normalizedAllowFrom.length === 0 || normalizedAllowFrom.some((e) => e === "*");
const commandAuthorized = allowAll || normalizedAllowFrom.includes(normalizedSenderId);
return {
blocked: false,
commandAuthorized,
};
}
/**
* Normalize an allowFrom list by stripping `qqbot:` prefixes and uppercasing.
*
* @param allowFrom - Raw allowFrom config entries.
* @returns Normalized entries for comparison.
*/
export function normalizeAllowFrom(allowFrom: Array<string | number> | undefined | null): string[] {
if (!allowFrom) {
return [];
}
return allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^qqbot:/i, ""))
.map((entry) => entry.toUpperCase());
}

View File

@@ -0,0 +1,82 @@
/**
* Media path decoding utility.
*
* Extracted from `outbound-deliver.ts` — handles the `MEDIA:` prefix stripping,
* tilde expansion, octal escape / UTF-8 byte-sequence decoding, and backslash
* unescaping that media tags require.
*
* Zero external dependencies.
*/
import type { EngineLogger } from "../types.js";
/**
* Normalize a file path by expanding `~` to the home directory and trimming.
*
* This is a minimal re-implementation of `utils/platform.ts#normalizePath`
* so that `core/` remains self-contained.
*/
function normalizePath(p: string): string {
let result = p.trim();
if (result.startsWith("~/") || result === "~") {
const home =
typeof process !== "undefined" ? (process.env.HOME ?? process.env.USERPROFILE) : undefined;
if (home) {
result = result === "~" ? home : `${home}${result.slice(1)}`;
}
}
return result;
}
/**
* Decode a media path by stripping `MEDIA:`, expanding `~`, and unescaping
* octal/UTF-8 byte sequences.
*
* @param raw - Raw path string from a media tag.
* @param log - Optional logger for decode diagnostics.
* @returns The decoded, normalized media path.
*/
export function decodeMediaPath(raw: string, log?: EngineLogger): string {
let mediaPath = raw;
if (mediaPath.startsWith("MEDIA:")) {
mediaPath = mediaPath.slice("MEDIA:".length);
}
mediaPath = normalizePath(mediaPath);
mediaPath = mediaPath.replace(/\\\\/g, "\\");
// Skip octal escape decoding for Windows local paths (e.g. C:\Users\1\file.txt)
// where backslash-digit sequences like \1, \2 ... \7 are directory separators,
// not octal escape sequences.
const isWinLocal = /^[a-zA-Z]:[\\/]/.test(mediaPath) || mediaPath.startsWith("\\\\");
try {
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
if (!isWinLocal && (hasOctal || hasNonASCII)) {
log?.debug?.(`Decoding path with mixed encoding: ${mediaPath}`);
const decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
return String.fromCharCode(parseInt(octal, 8));
});
const bytes: number[] = [];
for (let i = 0; i < decoded.length; i++) {
const code = decoded.charCodeAt(i);
if (code <= 0xff) {
bytes.push(code);
} else {
const charBytes = Buffer.from(decoded[i], "utf8");
bytes.push(...charBytes);
}
}
const buffer = Buffer.from(bytes);
const utf8Decoded = buffer.toString("utf8");
if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
mediaPath = utf8Decoded;
log?.debug?.(`Successfully decoded path: ${mediaPath}`);
}
}
} catch (decodeErr) {
log?.error(`Path decode error: ${String(decodeErr)}`);
}
return mediaPath;
}

View File

@@ -0,0 +1,122 @@
/**
* Media type detection — pure functions for classifying files by MIME or extension.
*
* These replace the inline `isImageFile`, `isVideoFile`, `isAudioFile` helpers
* scattered across `outbound.ts`. Centralizing them here ensures consistent
* detection across both the built-in and standalone versions.
*/
/** Supported media kind for QQ Bot outbound routing. */
export type MediaKind = "image" | "voice" | "video" | "file";
/** Display labels for media kinds. */
export const MEDIA_KIND_LABELS: Record<MediaKind | "media", string> = {
image: "Image",
voice: "Voice",
video: "Video",
file: "File",
media: "Media",
};
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]);
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]);
const AUDIO_EXTENSIONS = new Set([
".mp3",
".wav",
".ogg",
".flac",
".aac",
".m4a",
".wma",
".opus",
".amr",
".silk",
".slk",
".pcm",
]);
/**
* Extract a lowercase file extension from a path or URL, ignoring query and hash.
*/
export function getCleanExtension(filePath: string): string {
const cleanPath = filePath.split("?")[0].split("#")[0];
const lastDot = cleanPath.lastIndexOf(".");
if (lastDot < 0) {
return "";
}
return cleanPath.slice(lastDot).toLowerCase();
}
/** Check whether a file is an image using MIME first and extension as fallback. */
export function isImageFile(filePath: string, mimeType?: string): boolean {
if (mimeType?.startsWith("image/")) {
return true;
}
return IMAGE_EXTENSIONS.has(getCleanExtension(filePath));
}
/** Check whether a file is a video using MIME first and extension as fallback. */
export function isVideoFile(filePath: string, mimeType?: string): boolean {
if (mimeType?.startsWith("video/")) {
return true;
}
return VIDEO_EXTENSIONS.has(getCleanExtension(filePath));
}
/** Check whether a file is audio using MIME first and extension as fallback. */
export function isAudioFile(filePath: string, mimeType?: string): boolean {
if (mimeType) {
if (
mimeType.startsWith("audio/") ||
mimeType === "voice" ||
mimeType.includes("silk") ||
mimeType.includes("amr")
) {
return true;
}
}
return AUDIO_EXTENSIONS.has(getCleanExtension(filePath));
}
/**
* Auto-detect the media kind from a file path and optional MIME type.
*
* Priority: audio → video → image → file (default).
*/
export function detectMediaKind(filePath: string, mimeType?: string): MediaKind {
if (isAudioFile(filePath, mimeType)) {
return "voice";
}
if (isVideoFile(filePath, mimeType)) {
return "video";
}
if (isImageFile(filePath, mimeType)) {
return "image";
}
return "file";
}
/** Return true when the source is a remote HTTP(S) URL. */
export function isHttpSource(source: string): boolean {
return source.startsWith("http://") || source.startsWith("https://");
}
/** Return true when the source is a Base64 data URL. */
export function isDataSource(source: string): boolean {
return source.startsWith("data:");
}
/** Return true when the source is a remote URL or data URL. */
export function isRemoteOrDataSource(source: string): boolean {
return isHttpSource(source) || isDataSource(source);
}
/** Common MIME type mapping for image extensions. */
export const IMAGE_MIME_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
};

View File

@@ -0,0 +1,551 @@
/**
* Reply dispatcher — structured payload handling and text routing.
*
* Uses the unified `sender.ts` business function layer for all message
* sending. TTS is injected via `ReplyDispatcherDeps`.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { GatewayAccount } from "../types.js";
import { MAX_UPLOAD_SIZE, formatFileSize } from "../utils/file-utils.js";
import { formatErrorMessage } from "../utils/format.js";
import {
parseQQBotPayload,
encodePayloadForCron,
isCronReminderPayload,
isMediaPayload,
type MediaPayload,
} from "../utils/payload.js";
import { normalizePath, resolveQQBotPayloadLocalFilePath } from "../utils/platform.js";
import { normalizeLowercaseStringOrEmpty } from "../utils/string-normalize.js";
import { sanitizeFileName } from "../utils/string-normalize.js";
import {
sendText as senderSendText,
sendImage as senderSendImage,
sendVoiceMessage as senderSendVoice,
sendVideoMessage as senderSendVideo,
sendFileMessage as senderSendFile,
withTokenRetry,
buildDeliveryTarget,
accountToCreds,
} from "./sender.js";
// ---- Injected dependencies ----
/** TTS provider interface — injected from the outer layer. */
export interface TTSProvider {
/** Framework TTS: text → audio file path. */
textToSpeech(params: { text: string; cfg: unknown; channel: string }): Promise<{
success: boolean;
audioPath?: string;
provider?: string;
outputFormat?: string;
error?: string;
}>;
/** Convert any audio file to SILK base64. */
audioFileToSilkBase64(audioPath: string): Promise<string | undefined>;
}
/** Dependencies injected into reply-dispatcher functions. */
export interface ReplyDispatcherDeps {
tts: TTSProvider;
}
// ---- Exported types ----
export interface MessageTarget {
type: "c2c" | "guild" | "dm" | "group";
senderId: string;
messageId: string;
channelId?: string;
guildId?: string;
groupOpenid?: string;
}
export interface ReplyContext {
target: MessageTarget;
account: GatewayAccount;
cfg: unknown;
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
}
// ---- Token retry (delegated to sender.ts) ----
/** Send a message and retry once if the token appears to have expired. */
export async function sendWithTokenRetry<T>(
appId: string,
clientSecret: string,
sendFn: (token: string) => Promise<T>,
log?: ReplyContext["log"],
accountId?: string,
): Promise<T> {
return withTokenRetry({ appId, clientSecret }, sendFn, log, accountId);
}
// ---- Text routing ----
/** Route a text message to the correct QQ target type. */
export async function sendTextToTarget(
ctx: ReplyContext,
text: string,
refIdx?: string,
): Promise<void> {
const { target, account } = ctx;
const deliveryTarget = buildDeliveryTarget(target);
const creds = accountToCreds(account);
await withTokenRetry(
creds,
async () => {
await senderSendText(deliveryTarget, text, creds, {
msgId: target.messageId,
messageReference: refIdx,
});
},
ctx.log,
account.accountId,
);
}
/** Best-effort delivery for error text back to the user. */
export async function sendErrorToTarget(ctx: ReplyContext, errorText: string): Promise<void> {
try {
await sendTextToTarget(ctx, errorText);
} catch (sendErr) {
ctx.log?.error(`Failed to send error message: ${String(sendErr)}`);
}
}
// ---- Structured payload handling ----
/**
* Handle a structured payload prefixed with `QQBOT_PAYLOAD:`.
* Returns true when the reply was handled here, otherwise false.
*/
export async function handleStructuredPayload(
ctx: ReplyContext,
replyText: string,
recordActivity: () => void,
deps?: ReplyDispatcherDeps,
): Promise<boolean> {
const { account: _account, log } = ctx;
const payloadResult = parseQQBotPayload(replyText);
if (!payloadResult.isPayload) {
return false;
}
if (payloadResult.error) {
log?.error(`Payload parse error: ${payloadResult.error}`);
return true;
}
if (!payloadResult.payload) {
return true;
}
const parsedPayload = payloadResult.payload;
const unknownPayload = payloadResult.payload as unknown;
log?.info(`Detected structured payload, type: ${parsedPayload.type}`);
if (isCronReminderPayload(parsedPayload)) {
log?.debug?.(`Processing cron_reminder payload`);
const cronMessage = encodePayloadForCron(parsedPayload);
const confirmText = `⏰ Reminder scheduled. It will be sent at the configured time: "${parsedPayload.content}"`;
try {
await sendTextToTarget(ctx, confirmText);
log?.debug?.(`Cron reminder confirmation sent, cronMessage: ${cronMessage}`);
} catch (err) {
log?.error(`Failed to send cron confirmation: ${formatErrorMessage(err)}`);
}
recordActivity();
return true;
}
if (isMediaPayload(parsedPayload)) {
log?.debug?.(`Processing media payload, mediaType: ${parsedPayload.mediaType}`);
if (parsedPayload.mediaType === "image") {
await handleImagePayload(ctx, parsedPayload);
} else if (parsedPayload.mediaType === "audio") {
await handleAudioPayload(ctx, parsedPayload, deps);
} else if (parsedPayload.mediaType === "video") {
await handleVideoPayload(ctx, parsedPayload);
} else if (parsedPayload.mediaType === "file") {
await handleFilePayload(ctx, parsedPayload);
} else {
log?.error(`Unknown media type: ${JSON.stringify(parsedPayload.mediaType)}`);
}
recordActivity();
return true;
}
const payloadType =
typeof unknownPayload === "object" &&
unknownPayload !== null &&
"type" in unknownPayload &&
typeof unknownPayload.type === "string"
? unknownPayload.type
: "unknown";
log?.error(`Unknown payload type: ${payloadType}`);
return true;
}
// ---- Media payload handlers ----
type StructuredPayloadMediaType = "image" | "video" | "file";
function formatMediaTypeLabel(mediaType: StructuredPayloadMediaType): string {
return mediaType[0].toUpperCase() + mediaType.slice(1);
}
function validateStructuredPayloadLocalPath(
ctx: ReplyContext,
payloadPath: string,
mediaType: StructuredPayloadMediaType,
): string | null {
const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath);
if (allowedPath) {
return allowedPath;
}
ctx.log?.error(`Blocked ${mediaType} payload local path outside QQ Bot media storage`);
return null;
}
function isRemoteHttpUrl(p: string): boolean {
return p.startsWith("http://") || p.startsWith("https://");
}
function isInlineImageDataUrl(p: string): boolean {
return /^data:image\/[^;]+;base64,/i.test(p);
}
function resolveStructuredPayloadPath(
ctx: ReplyContext,
payload: MediaPayload,
mediaType: StructuredPayloadMediaType,
): { path: string; isHttpUrl: boolean } | null {
const originalPath = payload.path ?? "";
const normalizedPath = normalizePath(originalPath);
const isHttpUrl = isRemoteHttpUrl(normalizedPath);
const resolvedPath = isHttpUrl
? normalizedPath
: validateStructuredPayloadLocalPath(ctx, originalPath, mediaType);
if (!resolvedPath) {
return null;
}
if (!resolvedPath.trim()) {
ctx.log?.error(
`[qqbot:${ctx.account.accountId}] ${formatMediaTypeLabel(mediaType)} missing path`,
);
return null;
}
return { path: resolvedPath, isHttpUrl };
}
function sanitizeForLog(value: string, maxLen = 200): string {
return value
.replace(/[\r\n\t]/g, " ")
.replaceAll("\0", " ")
.slice(0, maxLen);
}
function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): string {
if (!isHttpUrl) {
return "<local-file>";
}
try {
const url = new URL(pathValue);
url.username = "";
url.password = "";
const urlId = crypto.createHash("sha256").update(url.toString()).digest("hex").slice(0, 12);
return sanitizeForLog(`${url.protocol}//${url.host}#${urlId}`);
} catch {
return "<invalid-url>";
}
}
async function readStructuredPayloadLocalFile(filePath: string): Promise<Buffer> {
const openFlags =
fs.constants.O_RDONLY | ("O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0);
const handle = await fs.promises.open(filePath, openFlags);
try {
const stat = await handle.stat();
if (!stat.isFile()) {
throw new Error("Path is not a regular file");
}
if (stat.size > MAX_UPLOAD_SIZE) {
throw new Error(
`File is too large (${formatFileSize(stat.size)}); QQ Bot API limit is ${formatFileSize(MAX_UPLOAD_SIZE)}`,
);
}
return handle.readFile();
} finally {
await handle.close();
}
}
async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
const { target, account, log } = ctx;
const normalizedPath = normalizePath(payload.path);
let imageUrl: string | null;
if (payload.source === "file") {
imageUrl = validateStructuredPayloadLocalPath(ctx, normalizedPath, "image");
} else if (isRemoteHttpUrl(normalizedPath) || isInlineImageDataUrl(normalizedPath)) {
imageUrl = normalizedPath;
} else {
log?.error(
`Image payload URL must use http(s) or data:image/: ${sanitizeForLog(payload.path)}`,
);
return;
}
if (!imageUrl) {
return;
}
const originalImagePath = payload.source === "file" ? imageUrl : undefined;
if (payload.source === "file") {
try {
const fileBuffer = await readStructuredPayloadLocalFile(imageUrl);
const base64Data = fileBuffer.toString("base64");
const ext = normalizeLowercaseStringOrEmpty(path.extname(imageUrl));
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
};
const mimeType = mimeTypes[ext];
if (!mimeType) {
log?.error(`Unsupported image format: ${ext}`);
return;
}
imageUrl = `data:${mimeType};base64,${base64Data}`;
log?.debug?.(`Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
} catch (readErr) {
log?.error(
`Failed to read local image: ${
readErr instanceof Error ? readErr.message : JSON.stringify(readErr)
}`,
);
return;
}
}
try {
const deliveryTarget = buildDeliveryTarget(target);
const creds = accountToCreds(account);
await withTokenRetry(
creds,
async () => {
if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") {
await senderSendImage(deliveryTarget, imageUrl, creds, {
msgId: target.messageId,
localPath: originalImagePath,
});
} else if (deliveryTarget.type === "dm") {
await senderSendText(deliveryTarget, `![](${payload.path})`, creds, {
msgId: target.messageId,
});
} else {
await senderSendText(deliveryTarget, `![](${payload.path})`, creds, {
msgId: target.messageId,
});
}
},
log,
account.accountId,
);
log?.debug?.(`Sent image via media payload`);
if (payload.caption) {
await sendTextToTarget(ctx, payload.caption);
}
} catch (err) {
log?.error(`Failed to send image: ${formatErrorMessage(err)}`);
}
}
async function handleAudioPayload(
ctx: ReplyContext,
payload: MediaPayload,
deps?: ReplyDispatcherDeps,
): Promise<void> {
const { target, account, cfg, log } = ctx;
if (!deps) {
log?.error(`TTS deps not provided, cannot handle audio payload`);
return;
}
try {
const ttsText = payload.caption || payload.path;
if (!ttsText?.trim()) {
log?.error(`Voice missing text`);
return;
}
log?.debug?.(`TTS: "${ttsText.slice(0, 50)}..."`);
const ttsResult = await deps.tts.textToSpeech({
text: ttsText,
cfg,
channel: "qqbot",
});
if (!ttsResult.success || !ttsResult.audioPath) {
log?.error(`TTS failed: ${ttsResult.error ?? "unknown"}`);
return;
}
const providerLabel = ttsResult.provider ?? "unknown";
log?.debug?.(
`TTS returned: provider=${providerLabel}, format=${ttsResult.outputFormat}, path=${ttsResult.audioPath}`,
);
const silkBase64 = await deps.tts.audioFileToSilkBase64(ttsResult.audioPath);
if (!silkBase64) {
log?.error(`Failed to convert TTS audio to SILK`);
return;
}
const silkPath = ttsResult.audioPath;
log?.debug?.(`TTS done (${providerLabel}), file: ${silkPath}`);
const deliveryTarget = buildDeliveryTarget(target);
const creds = accountToCreds(account);
await withTokenRetry(
creds,
async () => {
if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") {
await senderSendVoice(deliveryTarget, creds, {
voiceBase64: silkBase64,
msgId: target.messageId,
ttsText,
filePath: silkPath,
});
} else {
log?.error(`Voice not supported in ${deliveryTarget.type}, sending text fallback`);
await senderSendText(deliveryTarget, ttsText, creds, { msgId: target.messageId });
}
},
log,
account.accountId,
);
log?.debug?.(`Voice message sent`);
} catch (err) {
log?.error(`TTS/voice send failed: ${formatErrorMessage(err)}`);
}
}
async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
const { target, account, log } = ctx;
try {
const resolved = resolveStructuredPayloadPath(ctx, payload, "video");
if (!resolved) {
return;
}
const videoPath = resolved.path;
const isHttpUrl = resolved.isHttpUrl;
log?.debug?.(`Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`);
const deliveryTarget = buildDeliveryTarget(target);
const creds = accountToCreds(account);
if (deliveryTarget.type !== "c2c" && deliveryTarget.type !== "group") {
log?.error(`Video not supported in ${deliveryTarget.type}`);
return;
}
await withTokenRetry(
creds,
async () => {
if (isHttpUrl) {
await senderSendVideo(deliveryTarget, creds, {
videoUrl: videoPath,
msgId: target.messageId,
});
} else {
const fileBuffer = await readStructuredPayloadLocalFile(videoPath);
const videoBase64 = fileBuffer.toString("base64");
log?.debug?.(
`Read local video (${formatFileSize(fileBuffer.length)}): ${describeMediaTargetForLog(videoPath, false)}`,
);
await senderSendVideo(deliveryTarget, creds, {
videoBase64,
msgId: target.messageId,
localPath: videoPath,
});
}
},
log,
account.accountId,
);
log?.debug?.(`Video message sent`);
if (payload.caption) {
await sendTextToTarget(ctx, payload.caption);
}
} catch (err) {
log?.error(`Video send failed: ${formatErrorMessage(err)}`);
}
}
async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
const { target, account, log } = ctx;
try {
const resolved = resolveStructuredPayloadPath(ctx, payload, "file");
if (!resolved) {
return;
}
const filePath = resolved.path;
const isHttpUrl = resolved.isHttpUrl;
const fileName = sanitizeFileName(path.basename(filePath));
log?.debug?.(
`File send: ${describeMediaTargetForLog(filePath, isHttpUrl)} (${isHttpUrl ? "URL" : "local"})`,
);
const deliveryTarget = buildDeliveryTarget(target);
const creds = accountToCreds(account);
if (deliveryTarget.type !== "c2c" && deliveryTarget.type !== "group") {
log?.error(`File not supported in ${deliveryTarget.type}`);
return;
}
await withTokenRetry(
creds,
async () => {
if (isHttpUrl) {
await senderSendFile(deliveryTarget, creds, {
fileUrl: filePath,
msgId: target.messageId,
fileName,
});
} else {
const fileBuffer = await readStructuredPayloadLocalFile(filePath);
const fileBase64 = fileBuffer.toString("base64");
await senderSendFile(deliveryTarget, creds, {
fileBase64,
msgId: target.messageId,
fileName,
localFilePath: filePath,
});
}
},
log,
account.accountId,
);
log?.debug?.(`File message sent`);
} catch (err) {
log?.error(`File send failed: ${formatErrorMessage(err)}`);
}
}

View File

@@ -0,0 +1,164 @@
/**
* Passive reply limiter — enforce per-message reply count and TTL limits.
*
* QQ Bot restricts how many passive replies can be sent in response to a
* single inbound message (4 per hour by default). This module tracks reply
* counts and determines whether the next reply should be passive or
* fall back to proactive mode.
*
* The module is a **class** with zero I/O dependencies, fully supporting
* multi-account concurrent operation via separate instances.
*/
/** Configuration for the reply limiter. */
export interface ReplyLimiterConfig {
/** Maximum passive replies per message. Defaults to 4. */
limit?: number;
/** TTL in milliseconds for the passive reply window. Defaults to 1 hour. */
ttlMs?: number;
/** Maximum number of tracked messages before eviction. Defaults to 10000. */
maxTrackedMessages?: number;
}
/** Result of a passive-reply limit check. */
export interface ReplyLimitResult {
/** Whether a passive reply is still allowed. */
allowed: boolean;
/** Number of remaining passive replies. */
remaining: number;
/** Whether the caller should fall back to proactive mode. */
shouldFallbackToProactive: boolean;
/** Reason for the fallback. */
fallbackReason?: "expired" | "limit_exceeded";
/** Human-readable diagnostic message. */
message?: string;
}
interface ReplyRecord {
count: number;
firstReplyAt: number;
}
const DEFAULT_LIMIT = 4;
const DEFAULT_TTL_MS = 60 * 60 * 1000;
const DEFAULT_MAX_TRACKED = 10_000;
/**
* Per-account reply limiter with automatic eviction.
*
* Usage:
* ```ts
* const limiter = new ReplyLimiter({ limit: 4, ttlMs: 3600000 });
* const check = limiter.checkLimit(messageId);
* if (check.allowed) {
* await sendPassiveReply(...);
* limiter.record(messageId);
* } else if (check.shouldFallbackToProactive) {
* await sendProactiveMessage(...);
* }
* ```
*/
export class ReplyLimiter {
private readonly limit: number;
private readonly ttlMs: number;
private readonly maxTracked: number;
private readonly tracker = new Map<string, ReplyRecord>();
constructor(config?: ReplyLimiterConfig) {
this.limit = config?.limit ?? DEFAULT_LIMIT;
this.ttlMs = config?.ttlMs ?? DEFAULT_TTL_MS;
this.maxTracked = config?.maxTrackedMessages ?? DEFAULT_MAX_TRACKED;
}
/** Check whether a passive reply is allowed for the given message. */
checkLimit(messageId: string): ReplyLimitResult {
const now = Date.now();
this.evictIfNeeded(now);
const record = this.tracker.get(messageId);
if (!record) {
return {
allowed: true,
remaining: this.limit,
shouldFallbackToProactive: false,
};
}
if (now - record.firstReplyAt > this.ttlMs) {
return {
allowed: false,
remaining: 0,
shouldFallbackToProactive: true,
fallbackReason: "expired",
message: `Message is older than ${this.ttlMs / (60 * 60 * 1000)}h; sending as a proactive message instead`,
};
}
const remaining = this.limit - record.count;
if (remaining <= 0) {
return {
allowed: false,
remaining: 0,
shouldFallbackToProactive: true,
fallbackReason: "limit_exceeded",
message: `Passive reply limit reached (${this.limit} per hour); sending proactively instead`,
};
}
return {
allowed: true,
remaining,
shouldFallbackToProactive: false,
};
}
/** Record one passive reply against a message. */
record(messageId: string): void {
const now = Date.now();
const existing = this.tracker.get(messageId);
if (!existing) {
this.tracker.set(messageId, { count: 1, firstReplyAt: now });
} else if (now - existing.firstReplyAt > this.ttlMs) {
this.tracker.set(messageId, { count: 1, firstReplyAt: now });
} else {
existing.count++;
}
}
/** Return diagnostic stats. */
getStats(): { trackedMessages: number; totalReplies: number } {
let totalReplies = 0;
for (const record of this.tracker.values()) {
totalReplies += record.count;
}
return { trackedMessages: this.tracker.size, totalReplies };
}
/** Return limiter configuration. */
getConfig(): { limit: number; ttlMs: number; ttlHours: number } {
return {
limit: this.limit,
ttlMs: this.ttlMs,
ttlHours: this.ttlMs / (60 * 60 * 1000),
};
}
/** Clear all tracked records. */
clear(): void {
this.tracker.clear();
}
/** Opportunistically evict expired records to keep the tracker bounded. */
private evictIfNeeded(now: number): void {
if (this.tracker.size <= this.maxTracked) {
return;
}
for (const [id, rec] of this.tracker) {
if (now - rec.firstReplyAt > this.ttlMs) {
this.tracker.delete(id);
}
}
}
}

View File

@@ -0,0 +1,700 @@
/**
* Unified message sender — per-account resource management + business function layer.
*
* This module is the **single entry point** for all QQ Bot API operations.
*
* ## Architecture
*
* Each account gets its own isolated resource stack:
*
* ```
* _accountRegistry: Map<appId, AccountContext>
*
* AccountContext {
* logger — per-account prefixed logger
* client — per-account ApiClient
* tokenMgr — per-account TokenManager
* mediaApi — per-account MediaApi
* messageApi — per-account MessageApi
* }
* ```
*
* Upper-layer callers (gateway, outbound, reply-dispatcher, proactive)
* always go through exported functions that resolve the correct
* `AccountContext` by appId.
*/
import os from "node:os";
import { ApiClient } from "../api/api-client.js";
import { MediaApi as MediaApiClass } from "../api/media.js";
import type { Credentials } from "../api/messages.js";
import { MessageApi as MessageApiClass } from "../api/messages.js";
import { getNextMsgSeq } from "../api/routes.js";
import { TokenManager } from "../api/token.js";
import {
MediaFileType,
type ChatScope,
type EngineLogger,
type MessageResponse,
type OutboundMeta,
} from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
import { debugLog, debugError, debugWarn } from "../utils/log.js";
import { sanitizeFileName } from "../utils/string-normalize.js";
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "../utils/upload-cache.js";
// ============ Re-exported types ============
export { ApiError } from "../types.js";
export type { OutboundMeta, MessageResponse, UploadMediaResponse } from "../types.js";
export { MediaFileType } from "../types.js";
// ============ Plugin User-Agent ============
let _pluginVersion = "unknown";
let _openclawVersion = "unknown";
/** Build the User-Agent string from the current plugin and framework versions. */
function buildUserAgent(): string {
return `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()}; OpenClaw/${_openclawVersion})`;
}
/** Return the current User-Agent string. */
export function getPluginUserAgent(): string {
return buildUserAgent();
}
/**
* Initialize sender with the plugin version.
* Must be called once during startup before any API calls.
*/
export function initSender(options: { pluginVersion?: string; openclawVersion?: string }): void {
if (options.pluginVersion) {
_pluginVersion = options.pluginVersion;
}
if (options.openclawVersion) {
_openclawVersion = options.openclawVersion;
}
}
/** Update the OpenClaw framework version in the User-Agent (called after runtime injection). */
export function setOpenClawVersion(version: string): void {
if (version) {
_openclawVersion = version;
}
}
// ============ Per-account resource management ============
/** Complete resource context for a single account. */
interface AccountContext {
logger: EngineLogger;
client: ApiClient;
tokenMgr: TokenManager;
mediaApi: MediaApiClass;
messageApi: MessageApiClass;
markdownSupport: boolean;
}
/** Per-appId account registry — each account owns all its resources. */
const _accountRegistry = new Map<string, AccountContext>();
/** Fallback logger for unregistered accounts (CLI / test scenarios). */
const _fallbackLogger: EngineLogger = {
info: (msg: string) => debugLog(msg),
error: (msg: string) => debugError(msg),
warn: (msg: string) => debugWarn(msg),
debug: (msg: string) => debugLog(msg),
};
/**
* Build a full resource stack for a given logger.
*
* Shared by both `registerAccount` (explicit registration) and
* `resolveAccount` (lazy fallback for unregistered accounts).
*/
function buildAccountContext(logger: EngineLogger, markdownSupport: boolean): AccountContext {
const client = new ApiClient({ logger, userAgent: buildUserAgent });
const tokenMgr = new TokenManager({ logger, userAgent: buildUserAgent });
const mediaApi = new MediaApiClass(client, tokenMgr, {
logger,
uploadCache: {
computeHash: computeFileHash,
get: (hash: string, scope: string, targetId: string, fileType: number) =>
getCachedFileInfo(hash, scope as ChatScope, targetId, fileType),
set: (
hash: string,
scope: string,
targetId: string,
fileType: number,
fileInfo: string,
fileUuid: string,
ttl: number,
) => setCachedFileInfo(hash, scope as ChatScope, targetId, fileType, fileInfo, fileUuid, ttl),
},
sanitizeFileName,
});
const messageApi = new MessageApiClass(client, tokenMgr, {
markdownSupport,
logger,
});
return { logger, client, tokenMgr, mediaApi, messageApi, markdownSupport };
}
/**
* Register an account — atomically sets up all per-appId resources.
*
* Must be called once per account during gateway startup.
* Creates a complete isolated resource stack (ApiClient, TokenManager,
* MediaApi, MessageApi) with the per-account logger.
*/
export function registerAccount(
appId: string,
options: {
logger: EngineLogger;
markdownSupport?: boolean;
},
): void {
const key = appId.trim();
const md = options.markdownSupport === true;
_accountRegistry.set(key, buildAccountContext(options.logger, md));
}
/**
* Initialize per-app API behavior such as markdown support.
*
* If the account was already registered via `registerAccount()`, updates its
* MessageApi with the new markdown setting while preserving the existing
* logger and resource stack. Otherwise creates a new context.
*/
export function initApiConfig(appId: string, options: { markdownSupport?: boolean }): void {
const key = appId.trim();
const md = options.markdownSupport === true;
const existing = _accountRegistry.get(key);
if (existing) {
// Re-create only MessageApi with updated config, reuse existing stack.
existing.messageApi = new MessageApiClass(existing.client, existing.tokenMgr, {
markdownSupport: md,
logger: existing.logger,
});
existing.markdownSupport = md;
} else {
_accountRegistry.set(key, buildAccountContext(_fallbackLogger, md));
}
}
/**
* Resolve the AccountContext for a given appId.
*
* If the account was registered via `registerAccount()`, returns the
* pre-built context. Otherwise lazily creates a fallback context.
*/
function resolveAccount(appId: string): AccountContext {
const key = appId.trim();
let ctx = _accountRegistry.get(key);
if (!ctx) {
ctx = buildAccountContext(_fallbackLogger, false);
_accountRegistry.set(key, ctx);
}
return ctx;
}
// ============ Instance getters (for advanced callers) ============
/** Get the MessageApi instance for the given appId. */
export function getMessageApi(appId: string): MessageApiClass {
return resolveAccount(appId).messageApi;
}
/** Get the MediaApi instance for the given appId. */
export function getMediaApi(appId: string): MediaApiClass {
return resolveAccount(appId).mediaApi;
}
/** Get the TokenManager instance for the given appId. */
export function getTokenManager(appId: string): TokenManager {
return resolveAccount(appId).tokenMgr;
}
/** Get the ApiClient instance for the given appId. */
export function getApiClient(appId: string): ApiClient {
return resolveAccount(appId).client;
}
// ============ Per-appId config ============
type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
/** Register an outbound-message hook scoped to one appId. */
export function onMessageSent(appId: string, callback: OnMessageSentCallback): void {
resolveAccount(appId).messageApi.onMessageSent(callback);
}
/** Return whether markdown is enabled for the given appId. */
export function isMarkdownSupport(appId: string): boolean {
return _accountRegistry.get(appId.trim())?.markdownSupport ?? false;
}
// ============ Token management ============
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
return resolveAccount(appId).tokenMgr.getAccessToken(appId, clientSecret);
}
export function clearTokenCache(appId?: string): void {
if (appId) {
resolveAccount(appId).tokenMgr.clearCache(appId);
} else {
for (const ctx of _accountRegistry.values()) {
ctx.tokenMgr.clearCache();
}
}
}
export function getTokenStatus(appId: string): {
status: "valid" | "expired" | "refreshing" | "none";
expiresAt: number | null;
} {
return resolveAccount(appId).tokenMgr.getStatus(appId);
}
export function startBackgroundTokenRefresh(
appId: string,
clientSecret: string,
options?: {
refreshAheadMs?: number;
randomOffsetMs?: number;
minRefreshIntervalMs?: number;
retryDelayMs?: number;
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
},
): void {
resolveAccount(appId).tokenMgr.startBackgroundRefresh(appId, clientSecret, options);
}
export function stopBackgroundTokenRefresh(appId?: string): void {
if (appId) {
resolveAccount(appId).tokenMgr.stopBackgroundRefresh(appId);
} else {
for (const ctx of _accountRegistry.values()) {
ctx.tokenMgr.stopBackgroundRefresh();
}
}
}
export function isBackgroundTokenRefreshRunning(appId?: string): boolean {
if (appId) {
return resolveAccount(appId).tokenMgr.isBackgroundRefreshRunning(appId);
}
for (const ctx of _accountRegistry.values()) {
if (ctx.tokenMgr.isBackgroundRefreshRunning()) {
return true;
}
}
return false;
}
// ============ Gateway URL ============
export async function getGatewayUrl(accessToken: string, appId: string): Promise<string> {
const data = await resolveAccount(appId).client.request<{ url: string }>(
accessToken,
"GET",
"/gateway",
);
return data.url;
}
// ============ Interaction ============
/** Acknowledge an INTERACTION_CREATE event via PUT /interactions/{id}. */
export async function acknowledgeInteraction(
creds: AccountCreds,
interactionId: string,
code: 0 | 1 | 2 | 3 | 4 | 5 = 0,
): Promise<void> {
const ctx = resolveAccount(creds.appId);
const token = await ctx.tokenMgr.getAccessToken(creds.appId, creds.clientSecret);
await ctx.client.request(token, "PUT", `/interactions/${interactionId}`, { code });
}
// ============ Types ============
/** Delivery target resolved from event context. */
export interface DeliveryTarget {
type: "c2c" | "group" | "channel" | "dm";
id: string;
}
/** Account credentials for API authentication. */
export interface AccountCreds {
appId: string;
clientSecret: string;
}
// ============ Token retry ============
/**
* Execute an API call with automatic token-retry on 401 errors.
*/
export async function withTokenRetry<T>(
creds: AccountCreds,
sendFn: (token: string) => Promise<T>,
log?: EngineLogger,
_accountId?: string,
): Promise<T> {
try {
const token = await getAccessToken(creds.appId, creds.clientSecret);
return await sendFn(token);
} catch (err) {
const errMsg = formatErrorMessage(err);
if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
log?.debug?.(`Token may be expired, refreshing...`);
clearTokenCache(creds.appId);
const newToken = await getAccessToken(creds.appId, creds.clientSecret);
return await sendFn(newToken);
}
throw err;
}
}
// ============ Media hook helper ============
/**
* Notify the MessageApi onMessageSent hook after a media send.
*/
function notifyMediaHook(appId: string, result: MessageResponse, meta: OutboundMeta): void {
const refIdx = result.ext_info?.ref_idx;
if (refIdx) {
resolveAccount(appId).messageApi.notifyMessageSent(refIdx, meta);
}
}
// ============ Text sending ============
/**
* Send a text message to any QQ target type.
*
* Automatically routes to the correct API method based on target type.
* Handles passive (with msgId) and proactive (without msgId) modes.
*/
export async function sendText(
target: DeliveryTarget,
content: string,
creds: AccountCreds,
opts?: { msgId?: string; messageReference?: string },
): Promise<MessageResponse> {
const api = resolveAccount(creds.appId).messageApi;
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
if (target.type === "c2c" || target.type === "group") {
const scope: ChatScope = target.type;
if (opts?.msgId) {
return api.sendMessage(scope, target.id, content, c, {
msgId: opts.msgId,
messageReference: opts.messageReference,
});
}
return api.sendProactiveMessage(scope, target.id, content, c);
}
if (target.type === "dm") {
return api.sendDmMessage({ guildId: target.id, content, creds: c, msgId: opts?.msgId });
}
return api.sendChannelMessage({ channelId: target.id, content, creds: c, msgId: opts?.msgId });
}
/**
* Send text with automatic token-retry.
*/
export async function sendTextWithRetry(
target: DeliveryTarget,
content: string,
creds: AccountCreds,
opts?: { msgId?: string; messageReference?: string },
log?: EngineLogger,
): Promise<MessageResponse> {
return withTokenRetry(
creds,
async () => sendText(target, content, creds, opts),
log,
creds.appId,
);
}
/**
* Send a proactive text message (no msgId).
*/
export async function sendProactiveText(
target: DeliveryTarget,
content: string,
creds: AccountCreds,
): Promise<MessageResponse> {
return sendText(target, content, creds);
}
// ============ Input notify ============
/**
* Send a typing indicator to a C2C user.
*/
export async function sendInputNotify(opts: {
openid: string;
creds: AccountCreds;
msgId?: string;
inputSecond?: number;
}): Promise<{ refIdx?: string }> {
const api = resolveAccount(opts.creds.appId).messageApi;
const c: Credentials = { appId: opts.creds.appId, clientSecret: opts.creds.clientSecret };
return api.sendInputNotify({
openid: opts.openid,
creds: c,
msgId: opts.msgId,
inputSecond: opts.inputSecond,
});
}
/**
* Raw-token input notify — compatible with TypingKeepAlive's callback signature.
*/
export function createRawInputNotifyFn(
appId: string,
): (
token: string,
openid: string,
msgId: string | undefined,
inputSecond: number,
) => Promise<unknown> {
return async (token, openid, msgId, inputSecond) => {
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
return resolveAccount(appId).client.request(token, "POST", `/v2/users/${openid}/messages`, {
msg_type: 6,
input_notify: { input_type: 1, input_second: inputSecond },
msg_seq: msgSeq,
...(msgId ? { msg_id: msgId } : {}),
});
};
}
// ============ Image sending ============
/**
* Upload and send an image message to any C2C/Group target.
*/
export async function sendImage(
target: DeliveryTarget,
imageUrl: string,
creds: AccountCreds,
opts?: { msgId?: string; content?: string; localPath?: string },
): Promise<MessageResponse> {
if (target.type !== "c2c" && target.type !== "group") {
throw new Error(`Image sending not supported for target type: ${target.type}`);
}
const ctx = resolveAccount(creds.appId);
const scope: ChatScope = target.type;
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
const isBase64 = imageUrl.startsWith("data:");
let uploadOpts: { url?: string; fileData?: string };
if (isBase64) {
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) {
throw new Error("Invalid Base64 Data URL format");
}
uploadOpts = { fileData: matches[2] };
} else {
uploadOpts = { url: imageUrl };
}
const uploadResult = await ctx.mediaApi.uploadMedia(
scope,
target.id,
MediaFileType.IMAGE,
c,
uploadOpts,
);
const meta: OutboundMeta = {
text: opts?.content,
mediaType: "image",
...(!isBase64 ? { mediaUrl: imageUrl } : {}),
...(opts?.localPath ? { mediaLocalPath: opts.localPath } : {}),
};
const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
msgId: opts?.msgId,
content: opts?.content,
});
notifyMediaHook(creds.appId, result, meta);
return result;
}
// ============ Voice sending ============
/**
* Upload and send a voice message.
*/
export async function sendVoiceMessage(
target: DeliveryTarget,
creds: AccountCreds,
opts: {
voiceBase64?: string;
voiceUrl?: string;
msgId?: string;
ttsText?: string;
filePath?: string;
},
): Promise<MessageResponse> {
if (target.type !== "c2c" && target.type !== "group") {
throw new Error(`Voice sending not supported for target type: ${target.type}`);
}
const ctx = resolveAccount(creds.appId);
const scope: ChatScope = target.type;
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.VOICE, c, {
url: opts.voiceUrl,
fileData: opts.voiceBase64,
});
const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
msgId: opts.msgId,
});
notifyMediaHook(creds.appId, result, {
mediaType: "voice",
...(opts.ttsText ? { ttsText: opts.ttsText } : {}),
...(opts.filePath ? { mediaLocalPath: opts.filePath } : {}),
});
return result;
}
// ============ Video sending ============
/**
* Upload and send a video message.
*/
export async function sendVideoMessage(
target: DeliveryTarget,
creds: AccountCreds,
opts: {
videoUrl?: string;
videoBase64?: string;
msgId?: string;
content?: string;
localPath?: string;
},
): Promise<MessageResponse> {
if (target.type !== "c2c" && target.type !== "group") {
throw new Error(`Video sending not supported for target type: ${target.type}`);
}
const ctx = resolveAccount(creds.appId);
const scope: ChatScope = target.type;
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.VIDEO, c, {
url: opts.videoUrl,
fileData: opts.videoBase64,
});
const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
msgId: opts.msgId,
content: opts.content,
});
notifyMediaHook(creds.appId, result, {
text: opts.content,
mediaType: "video",
...(opts.videoUrl ? { mediaUrl: opts.videoUrl } : {}),
...(opts.localPath ? { mediaLocalPath: opts.localPath } : {}),
});
return result;
}
// ============ File sending ============
/**
* Upload and send a file message.
*/
export async function sendFileMessage(
target: DeliveryTarget,
creds: AccountCreds,
opts: {
fileBase64?: string;
fileUrl?: string;
msgId?: string;
fileName?: string;
localFilePath?: string;
},
): Promise<MessageResponse> {
if (target.type !== "c2c" && target.type !== "group") {
throw new Error(`File sending not supported for target type: ${target.type}`);
}
const ctx = resolveAccount(creds.appId);
const scope: ChatScope = target.type;
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.FILE, c, {
url: opts.fileUrl,
fileData: opts.fileBase64,
fileName: opts.fileName,
});
const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
msgId: opts.msgId,
});
notifyMediaHook(creds.appId, result, {
mediaType: "file",
mediaUrl: opts.fileUrl,
mediaLocalPath: opts.localFilePath ?? opts.fileName,
});
return result;
}
// ============ Helpers ============
/** Build a DeliveryTarget from event context fields. */
export function buildDeliveryTarget(event: {
type: "c2c" | "guild" | "dm" | "group";
senderId: string;
channelId?: string;
guildId?: string;
groupOpenid?: string;
}): DeliveryTarget {
switch (event.type) {
case "c2c":
return { type: "c2c", id: event.senderId };
case "group":
return { type: "group", id: event.groupOpenid! };
case "dm":
return { type: "dm", id: event.guildId! };
default:
return { type: "channel", id: event.channelId! };
}
}
/** Build AccountCreds from a GatewayAccount. */
export function accountToCreds(account: { appId: string; clientSecret: string }): AccountCreds {
return { appId: account.appId, clientSecret: account.clientSecret };
}
/** Check whether a target type supports rich media (C2C and Group only). */
export function supportsRichMedia(targetType: string): boolean {
return targetType === "c2c" || targetType === "group";
}

View File

@@ -0,0 +1,122 @@
/**
* QQ Bot target address parser — parse "qqbot:c2c:xxx" style addresses
* into structured delivery targets.
*
* All functions are **pure** (no side effects, no I/O), making them easy
* to test and safe to share between the built-in and standalone versions.
*/
/** Supported target types. */
export type TargetType = "c2c" | "group" | "channel";
/** Parsed delivery target. */
export interface ParsedTarget {
type: TargetType;
id: string;
}
/**
* Parse a qqbot target string into a structured delivery target.
*
* Supported formats:
* - `qqbot:c2c:openid` → C2C direct message
* - `qqbot:group:groupid` → Group message
* - `qqbot:channel:channelid` → Channel message
* - `c2c:openid` → C2C (without qqbot: prefix)
* - `group:groupid` → Group (without qqbot: prefix)
* - `channel:channelid` → Channel (without qqbot: prefix)
* - `openid` → C2C (bare openid, default)
*
* @param to - Raw target string.
* @returns Parsed target with type and id.
* @throws {Error} When the target format is invalid.
*/
export function parseTarget(to: string): ParsedTarget {
let id = to.replace(/^qqbot:/i, "");
if (id.startsWith("c2c:")) {
const userId = id.slice(4);
if (!userId) {
throw new Error(`Invalid c2c target format: ${to} - missing user ID`);
}
return { type: "c2c", id: userId };
}
if (id.startsWith("group:")) {
const groupId = id.slice(6);
if (!groupId) {
throw new Error(`Invalid group target format: ${to} - missing group ID`);
}
return { type: "group", id: groupId };
}
if (id.startsWith("channel:")) {
const channelId = id.slice(8);
if (!channelId) {
throw new Error(`Invalid channel target format: ${to} - missing channel ID`);
}
return { type: "channel", id: channelId };
}
if (!id) {
throw new Error(`Invalid target format: ${to} - empty ID after removing qqbot: prefix`);
}
// Default to C2C when no type prefix is present.
return { type: "c2c", id };
}
/**
* Map a parsed target type to a ChatScope for API calls.
*
* Channel and DM targets are not C2C/Group scoped and should be handled
* separately by the caller.
*
* @returns `'c2c'` or `'group'`, or `undefined` for channel targets.
*/
export function targetToChatScope(target: ParsedTarget): "c2c" | "group" | undefined {
if (target.type === "c2c") {
return "c2c";
}
if (target.type === "group") {
return "group";
}
return undefined;
}
/**
* Normalize a QQ Bot target string into the canonical `qqbot:...` form.
*
* Returns `undefined` when the target does not look like a QQ Bot address.
*/
export function normalizeTarget(target: string): string | undefined {
const id = target.replace(/^qqbot:/i, "");
if (id.startsWith("c2c:") || id.startsWith("group:") || id.startsWith("channel:")) {
return `qqbot:${id}`;
}
// 32-char hex openid
if (/^[0-9a-fA-F]{32}$/.test(id)) {
return `qqbot:c2c:${id}`;
}
// UUID-format openid
if (/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) {
return `qqbot:c2c:${id}`;
}
return undefined;
}
/**
* Return true when the string looks like a QQ Bot target ID.
*/
export function looksLikeQQBotTarget(id: string): boolean {
if (/^qqbot:(c2c|group|channel):/i.test(id)) {
return true;
}
if (/^(c2c|group|channel):/i.test(id)) {
return true;
}
if (/^[0-9a-fA-F]{32}$/.test(id)) {
return true;
}
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id);
}

View File

@@ -0,0 +1,142 @@
/**
* Format a message_reference (from msg_elements[0]) into text for model context.
*
* This handles the cache-miss path: when a user quotes a message we haven't
* cached in the ref-index store, we fall back to the msg_elements[0] data
* pushed by the QQ platform.
*
* The heavy lifting (attachment download, STT, etc.) is delegated to an
* injected `AttachmentProcessor` so this module stays framework-agnostic.
*/
import type { EngineLogger } from "../types.js";
import { parseFaceTags, buildAttachmentSummaries } from "../utils/text-parsing.js";
import { formatRefEntryForAgent } from "./format-ref-entry.js";
import type { RefAttachmentSummary } from "./types.js";
// ============ Injected dependency ============
/** Attachment download & voice transcription — injected from the outer layer. */
export interface AttachmentProcessor {
processAttachments(
attachments:
| Array<{
content_type: string;
url: string;
filename?: string;
height?: number;
width?: number;
size?: number;
voice_wav_url?: string;
asr_refer_text?: string;
}>
| undefined,
ctx: { appId: string; peerId?: string; cfg: unknown; log?: EngineLogger },
): Promise<{
attachmentInfo: string;
voiceTranscripts: string[];
voiceTranscriptSources: string[];
attachmentLocalPaths: Array<string | null>;
}>;
formatVoiceText(voiceTranscripts: string[]): string;
}
// ============ Public API ============
/**
* Format a quoted message reference into human-readable text for model context.
*
* This mirrors the independent version's `formatMessageReferenceForAgent` —
* processing attachments (download + STT) and combining them with parsed text.
*
* @param ref - The msg_elements[0] data from the QQ push event.
* @param ctx - Context containing appId, peerId, config, and logger.
* @param processor - Injected attachment processor (download + voice transcription).
*/
export async function formatMessageReferenceForAgent(
ref:
| {
content?: string;
attachments?: Array<{
content_type: string;
url: string;
filename?: string;
height?: number;
width?: number;
size?: number;
voice_wav_url?: string;
asr_refer_text?: string;
}>;
}
| undefined,
ctx: {
appId: string;
peerId?: string;
cfg: unknown;
log?: EngineLogger;
},
processor: AttachmentProcessor,
): Promise<string> {
if (!ref) {
return "";
}
// Process attachments (download images, transcribe voice, etc.)
const processed = await processor.processAttachments(ref.attachments, ctx);
const { attachmentInfo, voiceTranscripts, voiceTranscriptSources, attachmentLocalPaths } =
processed;
// Format voice transcript text
const voiceText = processor.formatVoiceText(voiceTranscripts);
// Parse QQ face tags into readable text
const parsedContent = parseFaceTags(ref.content ?? "");
// Combine text content with voice transcript and attachment info
const userContent = voiceText
? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
: parsedContent + attachmentInfo;
// Build attachment summaries and inject voice transcripts
const attSummaries = buildAttachmentSummaries(
ref.attachments as Array<{
content_type: string;
url: string;
filename?: string;
voice_wav_url?: string;
}>,
attachmentLocalPaths,
);
if (attSummaries && voiceTranscripts.length > 0) {
let voiceIdx = 0;
for (const att of attSummaries) {
if (att.type === "voice" && voiceIdx < voiceTranscripts.length) {
att.transcript = voiceTranscripts[voiceIdx];
if (voiceIdx < voiceTranscriptSources.length) {
att.transcriptSource = voiceTranscriptSources[
voiceIdx
] as RefAttachmentSummary["transcriptSource"];
}
voiceIdx++;
}
}
}
// Format using the same function as the cache-hit path
const refEntry = {
content: userContent.trim(),
senderId: "",
timestamp: Date.now(),
attachments: attSummaries,
};
const formattedAttachments = formatRefEntryForAgent(refEntry);
// If formatRefEntryForAgent already includes the content, use it directly.
// Otherwise combine manually.
if (formattedAttachments !== "[empty message]") {
return formattedAttachments;
}
return userContent.trim() || "";
}

View File

@@ -0,0 +1,53 @@
/**
* Format a ref-index entry into text suitable for model context.
*
* Zero external dependencies — pure string formatting.
*/
import type { RefIndexEntry } from "./types.js";
/** Format a ref-index entry into text suitable for model context. */
export function formatRefEntryForAgent(entry: RefIndexEntry): string {
const parts: string[] = [];
if (entry.content.trim()) {
parts.push(entry.content);
}
if (entry.attachments?.length) {
for (const att of entry.attachments) {
const sourceHint = att.localPath ? ` (${att.localPath})` : att.url ? ` (${att.url})` : "";
switch (att.type) {
case "image":
parts.push(`[image${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
break;
case "voice":
if (att.transcript) {
const sourceMap: Record<string, string> = {
stt: "local STT",
asr: "platform ASR",
tts: "TTS source",
fallback: "fallback text",
};
const sourceTag = att.transcriptSource
? ` - ${sourceMap[att.transcriptSource] || att.transcriptSource}`
: "";
parts.push(`[voice message (content: "${att.transcript}"${sourceTag})${sourceHint}]`);
} else {
parts.push(`[voice message${sourceHint}]`);
}
break;
case "video":
parts.push(`[video${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
break;
case "file":
parts.push(`[file${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
break;
default:
parts.push(`[attachment${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
}
}
}
return parts.join(" ") || "[empty message]";
}

View File

@@ -1,28 +1,20 @@
/**
* Ref-index store JSONL file-based store for message reference index.
*
* Migrated from src/ref-index-store.ts. Dependencies are only Node.js
* built-ins + log + platform (both zero plugin-sdk).
*/
import fs from "node:fs";
import path from "node:path";
import { debugLog, debugError } from "./utils/debug-log.js";
import { getQQBotDataDir } from "./utils/platform.js";
import { formatErrorMessage } from "../utils/format.js";
import { debugLog, debugError } from "../utils/log.js";
import { getQQBotDataDir } from "../utils/platform.js";
import type { RefIndexEntry } from "./types.js";
/** Summary stored for one quoted message. */
export interface RefIndexEntry {
content: string;
senderId: string;
senderName?: string;
timestamp: number;
isBot?: boolean;
attachments?: RefAttachmentSummary[];
}
/** Attachment summary persisted alongside a ref index entry. */
export interface RefAttachmentSummary {
type: "image" | "voice" | "video" | "file" | "unknown";
filename?: string;
contentType?: string;
transcript?: string;
transcriptSource?: "stt" | "asr" | "tts" | "fallback";
localPath?: string;
url?: string;
}
// Re-export types and format function for convenience.
export type { RefIndexEntry, RefAttachmentSummary } from "./types.js";
export { formatRefEntryForAgent } from "./format-ref-entry.js";
const STORAGE_DIR = getQQBotDataDir("data");
const REF_INDEX_FILE = path.join(STORAGE_DIR, "ref-index.jsonl");
@@ -39,12 +31,10 @@ interface RefIndexLine {
let cache: Map<string, RefIndexEntry & { _createdAt: number }> | null = null;
let totalLinesOnDisk = 0;
/** Lazily load the JSONL store into memory. */
function loadFromFile(): Map<string, RefIndexEntry & { _createdAt: number }> {
if (cache !== null) {
return cache;
}
cache = new Map();
totalLinesOnDisk = 0;
@@ -52,7 +42,6 @@ function loadFromFile(): Map<string, RefIndexEntry & { _createdAt: number }> {
if (!fs.existsSync(REF_INDEX_FILE)) {
return cache;
}
const raw = fs.readFileSync(REF_INDEX_FILE, "utf-8");
const lines = raw.split("\n");
const now = Date.now();
@@ -64,99 +53,84 @@ function loadFromFile(): Map<string, RefIndexEntry & { _createdAt: number }> {
continue;
}
totalLinesOnDisk++;
try {
const entry = JSON.parse(trimmed) as RefIndexLine;
if (!entry.k || !entry.v || !entry.t) {
continue;
}
if (now - entry.t > TTL_MS) {
expired++;
continue;
}
cache.set(entry.k, {
...entry.v,
_createdAt: entry.t,
});
cache.set(entry.k, { ...entry.v, _createdAt: entry.t });
} catch {}
}
debugLog(
`[ref-index-store] Loaded ${cache.size} entries from ${totalLinesOnDisk} lines (${expired} expired)`,
);
if (shouldCompact()) {
compactFile();
}
} catch (err) {
debugError(`[ref-index-store] Failed to load: ${String(err)}`);
debugError(`[ref-index-store] Failed to load: ${formatErrorMessage(err)}`);
cache = new Map();
}
return cache;
}
/** Append one record to the JSONL file. */
function appendLine(line: RefIndexLine): void {
try {
ensureDir();
fs.appendFileSync(REF_INDEX_FILE, JSON.stringify(line) + "\n", "utf-8");
totalLinesOnDisk++;
} catch (err) {
debugError(`[ref-index-store] Failed to append: ${String(err)}`);
}
}
function ensureDir(): void {
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
}
function shouldCompact(): boolean {
if (!cache) {
return false;
function appendLine(line: RefIndexLine): void {
try {
ensureDir();
fs.appendFileSync(REF_INDEX_FILE, JSON.stringify(line) + "\n", "utf-8");
totalLinesOnDisk++;
} catch (err) {
debugError(`[ref-index-store] Failed to append: ${formatErrorMessage(err)}`);
}
return totalLinesOnDisk > cache.size * COMPACT_THRESHOLD_RATIO && totalLinesOnDisk > 1000;
}
function shouldCompact(): boolean {
return (
!!cache && totalLinesOnDisk > cache.size * COMPACT_THRESHOLD_RATIO && totalLinesOnDisk > 1000
);
}
function compactFile(): void {
if (!cache) {
return;
}
const before = totalLinesOnDisk;
try {
ensureDir();
const tmpPath = REF_INDEX_FILE + ".tmp";
const lines: string[] = [];
for (const [key, entry] of cache) {
const line: RefIndexLine = {
k: key,
v: {
content: entry.content,
senderId: entry.senderId,
senderName: entry.senderName,
timestamp: entry.timestamp,
isBot: entry.isBot,
attachments: entry.attachments,
},
t: entry._createdAt,
};
lines.push(JSON.stringify(line));
lines.push(
JSON.stringify({
k: key,
v: {
content: entry.content,
senderId: entry.senderId,
senderName: entry.senderName,
timestamp: entry.timestamp,
isBot: entry.isBot,
attachments: entry.attachments,
},
t: entry._createdAt,
}),
);
}
fs.writeFileSync(tmpPath, lines.join("\n") + "\n", "utf-8");
fs.renameSync(tmpPath, REF_INDEX_FILE);
totalLinesOnDisk = cache.size;
debugLog(`[ref-index-store] Compacted: ${before} lines → ${totalLinesOnDisk} lines`);
} catch (err) {
debugError(
`[ref-index-store] Compact failed: ${err instanceof Error ? err.message : JSON.stringify(err)}`,
);
debugError(`[ref-index-store] Compact failed: ${formatErrorMessage(err)}`);
}
}
@@ -164,14 +138,12 @@ function evictIfNeeded(): void {
if (!cache || cache.size < MAX_ENTRIES) {
return;
}
const now = Date.now();
for (const [key, entry] of cache) {
if (now - entry._createdAt > TTL_MS) {
cache.delete(key);
}
}
if (cache.size >= MAX_ENTRIES) {
const sorted = [...cache.entries()].toSorted((a, b) => a[1]._createdAt - b[1]._createdAt);
const toRemove = sorted.slice(0, cache.size - MAX_ENTRIES + 1000);
@@ -186,18 +158,8 @@ function evictIfNeeded(): void {
export function setRefIndex(refIdx: string, entry: RefIndexEntry): void {
const store = loadFromFile();
evictIfNeeded();
const now = Date.now();
store.set(refIdx, {
content: entry.content,
senderId: entry.senderId,
senderName: entry.senderName,
timestamp: entry.timestamp,
isBot: entry.isBot,
attachments: entry.attachments,
_createdAt: now,
});
store.set(refIdx, { ...entry, _createdAt: now });
appendLine({
k: refIdx,
v: {
@@ -210,7 +172,6 @@ export function setRefIndex(refIdx: string, entry: RefIndexEntry): void {
},
t: now,
});
if (shouldCompact()) {
compactFile();
}
@@ -223,12 +184,10 @@ export function getRefIndex(refIdx: string): RefIndexEntry | null {
if (!entry) {
return null;
}
if (Date.now() - entry._createdAt > TTL_MS) {
store.delete(refIdx);
return null;
}
return {
content: entry.content,
senderId: entry.senderId,
@@ -239,52 +198,6 @@ export function getRefIndex(refIdx: string): RefIndexEntry | null {
};
}
/** Format a ref-index entry into text suitable for model context. */
export function formatRefEntryForAgent(entry: RefIndexEntry): string {
const parts: string[] = [];
if (entry.content.trim()) {
parts.push(entry.content);
}
if (entry.attachments?.length) {
for (const att of entry.attachments) {
const sourceHint = att.localPath ? ` (${att.localPath})` : att.url ? ` (${att.url})` : "";
switch (att.type) {
case "image":
parts.push(`[image${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
break;
case "voice":
if (att.transcript) {
const sourceMap = {
stt: "local STT",
asr: "platform ASR",
tts: "TTS source",
fallback: "fallback text",
};
const sourceTag = att.transcriptSource
? ` - ${sourceMap[att.transcriptSource] || att.transcriptSource}`
: "";
parts.push(`[voice message (content: "${att.transcript}"${sourceTag})${sourceHint}]`);
} else {
parts.push(`[voice message${sourceHint}]`);
}
break;
case "video":
parts.push(`[video${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
break;
case "file":
parts.push(`[file${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
break;
default:
parts.push(`[attachment${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
}
}
}
return parts.join(" ") || "[empty message]";
}
/** Compact the store before process exit when needed. */
export function flushRefIndex(): void {
if (cache && shouldCompact()) {
@@ -300,10 +213,5 @@ export function getRefIndexStats(): {
filePath: string;
} {
const store = loadFromFile();
return {
size: store.size,
maxEntries: MAX_ENTRIES,
totalLinesOnDisk,
filePath: REF_INDEX_FILE,
};
return { size: store.size, maxEntries: MAX_ENTRIES, totalLinesOnDisk, filePath: REF_INDEX_FILE };
}

View File

@@ -0,0 +1,27 @@
/**
* Ref-index types shared between both plugin versions.
*
* These types define the structure of quoted-message metadata
* persisted by the ref-index store.
*/
/** Summary stored for one quoted message. */
export interface RefIndexEntry {
content: string;
senderId: string;
senderName?: string;
timestamp: number;
isBot?: boolean;
attachments?: RefAttachmentSummary[];
}
/** Attachment summary persisted alongside a ref index entry. */
export interface RefAttachmentSummary {
type: "image" | "voice" | "video" | "file" | "unknown";
filename?: string;
contentType?: string;
transcript?: string;
transcriptSource?: "stt" | "asr" | "tts" | "fallback";
localPath?: string;
url?: string;
}

View File

@@ -1,11 +1,21 @@
/**
* Known user tracking JSON file-based store.
*
* Migrated from src/known-users.ts. Dependencies are only Node.js
* built-ins + log + platform (both zero plugin-sdk).
*/
import fs from "node:fs";
import path from "node:path";
import { debugLog, debugError } from "./utils/debug-log.js";
import type { ChatScope } from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
import { debugLog, debugError } from "../utils/log.js";
import { getQQBotDataDir } from "../utils/platform.js";
/** Persisted record for a user who has interacted with the bot. */
export interface KnownUser {
openid: string;
type: "c2c" | "group";
type: ChatScope;
nickname?: string;
groupOpenid?: string;
accountId: string;
@@ -14,81 +24,70 @@ export interface KnownUser {
interactionCount: number;
}
import { getQQBotDataDir } from "./utils/platform.js";
const KNOWN_USERS_DIR = getQQBotDataDir("data");
const KNOWN_USERS_FILE = path.join(KNOWN_USERS_DIR, "known-users.json");
let usersCache: Map<string, KnownUser> | null = null;
const SAVE_THROTTLE_MS = 5000;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let isDirty = false;
/** Ensure the data directory exists. */
function ensureDir(): void {
if (!fs.existsSync(KNOWN_USERS_DIR)) {
fs.mkdirSync(KNOWN_USERS_DIR, { recursive: true });
}
}
/** Load persisted users into the in-memory cache. */
function makeUserKey(user: Partial<KnownUser>): string {
const base = `${user.accountId}:${user.type}:${user.openid}`;
return user.type === "group" && user.groupOpenid ? `${base}:${user.groupOpenid}` : base;
}
function loadUsersFromFile(): Map<string, KnownUser> {
if (usersCache !== null) {
return usersCache;
}
usersCache = new Map();
try {
if (fs.existsSync(KNOWN_USERS_FILE)) {
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
const users = JSON.parse(data) as KnownUser[];
for (const user of users) {
const key = makeUserKey(user);
usersCache.set(key, user);
usersCache.set(makeUserKey(user), user);
}
debugLog(`[known-users] Loaded ${usersCache.size} users`);
}
} catch (err) {
debugError(`[known-users] Failed to load users: ${String(err)}`);
debugError(`[known-users] Failed to load users: ${formatErrorMessage(err)}`);
usersCache = new Map();
}
return usersCache;
}
/** Schedule a throttled write to disk. */
function saveUsersToFile(): void {
if (!isDirty) {
if (!isDirty || saveTimer) {
return;
}
if (saveTimer) {
return;
}
saveTimer = setTimeout(() => {
saveTimer = null;
doSaveUsersToFile();
}, SAVE_THROTTLE_MS);
}
/** Perform the actual write to disk. */
function doSaveUsersToFile(): void {
if (!usersCache || !isDirty) {
return;
}
try {
ensureDir();
const users = Array.from(usersCache.values());
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(users, null, 2), "utf-8");
fs.writeFileSync(
KNOWN_USERS_FILE,
JSON.stringify(Array.from(usersCache.values()), null, 2),
"utf-8",
);
isDirty = false;
} catch (err) {
debugError(`[known-users] Failed to save users: ${String(err)}`);
debugError(`[known-users] Failed to save users: ${formatErrorMessage(err)}`);
}
}
@@ -101,19 +100,10 @@ export function flushKnownUsers(): void {
doSaveUsersToFile();
}
/** Build a stable composite key for one user record. */
function makeUserKey(user: Partial<KnownUser>): string {
const base = `${user.accountId}:${user.type}:${user.openid}`;
if (user.type === "group" && user.groupOpenid) {
return `${base}:${user.groupOpenid}`;
}
return base;
}
/** Record a known user whenever a message is received. */
export function recordKnownUser(user: {
openid: string;
type: "c2c" | "group";
type: ChatScope;
nickname?: string;
groupOpenid?: string;
accountId: string;
@@ -121,7 +111,6 @@ export function recordKnownUser(user: {
const cache = loadUsersFromFile();
const key = makeUserKey(user);
const now = Date.now();
const existing = cache.get(key);
if (existing) {
@@ -131,7 +120,7 @@ export function recordKnownUser(user: {
existing.nickname = user.nickname;
}
} else {
const newUser: KnownUser = {
cache.set(key, {
openid: user.openid,
type: user.type,
nickname: user.nickname,
@@ -140,11 +129,9 @@ export function recordKnownUser(user: {
firstSeenAt: now,
lastSeenAt: now,
interactionCount: 1,
};
cache.set(key, newUser);
});
debugLog(`[known-users] New user: ${user.openid} (${user.type})`);
}
isDirty = true;
saveUsersToFile();
}
@@ -153,26 +140,22 @@ export function recordKnownUser(user: {
export function getKnownUser(
accountId: string,
openid: string,
type: "c2c" | "group" = "c2c",
type: ChatScope = "c2c",
groupOpenid?: string,
): KnownUser | undefined {
const cache = loadUsersFromFile();
const key = makeUserKey({ accountId, openid, type, groupOpenid });
return cache.get(key);
return loadUsersFromFile().get(makeUserKey({ accountId, openid, type, groupOpenid }));
}
/** List known users with optional filtering and sorting. */
export function listKnownUsers(options?: {
accountId?: string;
type?: "c2c" | "group";
type?: ChatScope;
activeWithin?: number;
limit?: number;
sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount";
sortOrder?: "asc" | "desc";
}): KnownUser[] {
const cache = loadUsersFromFile();
let users = Array.from(cache.values());
let users = Array.from(loadUsersFromFile().values());
if (options?.accountId) {
users = users.filter((u) => u.accountId === options.accountId);
}
@@ -183,19 +166,16 @@ export function listKnownUsers(options?: {
const cutoff = Date.now() - options.activeWithin;
users = users.filter((u) => u.lastSeenAt >= cutoff);
}
const sortBy = options?.sortBy ?? "lastSeenAt";
const sortOrder = options?.sortOrder ?? "desc";
users.sort((a, b) => {
const aVal = a[sortBy] ?? 0;
const bVal = b[sortBy] ?? 0;
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
const aV = a[sortBy] ?? 0;
const bV = b[sortBy] ?? 0;
return sortOrder === "asc" ? aV - bV : bV - aV;
});
if (options?.limit && options.limit > 0) {
users = users.slice(0, options.limit);
}
return users;
}
@@ -207,11 +187,9 @@ export function getKnownUsersStats(accountId?: string): {
activeIn24h: number;
activeIn7d: number;
} {
let users = listKnownUsers({ accountId });
const users = listKnownUsers({ accountId });
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
const day = 86400000;
return {
totalUsers: users.length,
c2cUsers: users.filter((u) => u.type === "c2c").length,
@@ -225,12 +203,11 @@ export function getKnownUsersStats(accountId?: string): {
export function removeKnownUser(
accountId: string,
openid: string,
type: "c2c" | "group" = "c2c",
type: ChatScope = "c2c",
groupOpenid?: string,
): boolean {
const cache = loadUsersFromFile();
const key = makeUserKey({ accountId, openid, type, groupOpenid });
if (cache.has(key)) {
cache.delete(key);
isDirty = true;
@@ -238,7 +215,6 @@ export function removeKnownUser(
debugLog(`[known-users] Removed user ${openid}`);
return true;
}
return false;
}
@@ -246,7 +222,6 @@ export function removeKnownUser(
export function clearKnownUsers(accountId?: string): number {
const cache = loadUsersFromFile();
let count = 0;
if (accountId) {
for (const [key, user] of cache.entries()) {
if (user.accountId === accountId) {
@@ -258,20 +233,19 @@ export function clearKnownUsers(accountId?: string): number {
count = cache.size;
cache.clear();
}
if (count > 0) {
isDirty = true;
doSaveUsersToFile();
debugLog(`[known-users] Cleared ${count} users`);
}
return count;
}
/** Return all groups in which a user has interacted. */
export function getUserGroups(accountId: string, openid: string): string[] {
const users = listKnownUsers({ accountId, type: "group" });
return users.filter((u) => u.openid === openid && u.groupOpenid).map((u) => u.groupOpenid!);
return listKnownUsers({ accountId, type: "group" })
.filter((u) => u.openid === openid && u.groupOpenid)
.map((u) => u.groupOpenid!);
}
/** Return all recorded members for one group. */

View File

@@ -1,6 +1,15 @@
/**
* Gateway session persistence JSONL file-based store.
*
* Migrated from src/session-store.ts. Dependencies are only Node.js
* built-ins + log + platform (both zero plugin-sdk).
*/
import fs from "node:fs";
import path from "node:path";
import { debugLog, debugError } from "./utils/debug-log.js";
import { formatErrorMessage } from "../utils/format.js";
import { debugLog, debugError } from "../utils/log.js";
import { getQQBotDataDir } from "../utils/platform.js";
/** Persisted gateway session state. */
export interface SessionState {
@@ -13,12 +22,10 @@ export interface SessionState {
appId?: string;
}
import { getQQBotDataDir } from "./utils/platform.js";
const SESSION_DIR = getQQBotDataDir("sessions");
const SESSION_EXPIRE_TIME = 5 * 60 * 1000;
const SAVE_THROTTLE_MS = 1000;
const throttleState = new Map<
string,
{
@@ -28,7 +35,6 @@ const throttleState = new Map<
}
>();
/** Ensure the session directory exists. */
function ensureDir(): void {
if (!fs.existsSync(SESSION_DIR)) {
fs.mkdirSync(SESSION_DIR, { recursive: true });
@@ -44,7 +50,6 @@ function getLegacySessionPath(accountId: string): string {
return path.join(SESSION_DIR, `session-${safeId}.json`);
}
/** Return the session file path for one account. */
function getSessionPath(accountId: string): string {
const encodedId = encodeAccountIdForFileName(accountId);
return path.join(SESSION_DIR, `session-${encodedId}.json`);
@@ -76,15 +81,14 @@ export function loadSession(accountId: string, expectedAppId?: string): SessionS
break;
}
}
if (!filePath) {
return null;
}
const data = fs.readFileSync(filePath, "utf-8");
const state = JSON.parse(data) as SessionState;
const now = Date.now();
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
debugLog(
`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`,
@@ -115,7 +119,9 @@ export function loadSession(accountId: string, expectedAppId?: string): SessionS
);
return state;
} catch (err) {
debugError(`[session-store] Failed to load session for ${accountId}: ${String(err)}`);
debugError(
`[session-store] Failed to load session for ${accountId}: ${formatErrorMessage(err)}`,
);
return null;
}
}
@@ -123,14 +129,9 @@ export function loadSession(accountId: string, expectedAppId?: string): SessionS
/** Save session state with throttling. */
export function saveSession(state: SessionState): void {
const { accountId } = state;
let throttle = throttleState.get(accountId);
if (!throttle) {
throttle = {
pendingState: null,
lastSaveTime: 0,
throttleTimer: null,
};
throttle = { pendingState: null, lastSaveTime: 0, throttleTimer: null };
throttleState.set(accountId, throttle);
}
@@ -141,19 +142,17 @@ export function saveSession(state: SessionState): void {
doSaveSession(state);
throttle.lastSaveTime = now;
throttle.pendingState = null;
if (throttle.throttleTimer) {
clearTimeout(throttle.throttleTimer);
throttle.throttleTimer = null;
}
} else {
throttle.pendingState = state;
if (!throttle.throttleTimer) {
const delay = SAVE_THROTTLE_MS - timeSinceLastSave;
throttle.throttleTimer = setTimeout(() => {
const t = throttleState.get(accountId);
if (t && t.pendingState) {
if (t?.pendingState) {
doSaveSession(t.pendingState);
t.lastSaveTime = Date.now();
t.pendingState = null;
@@ -166,19 +165,12 @@ export function saveSession(state: SessionState): void {
}
}
/** Write one session file to disk immediately. */
function doSaveSession(state: SessionState): void {
const filePath = getSessionPath(state.accountId);
const legacyPath = getLegacySessionPath(state.accountId);
try {
ensureDir();
const stateToSave: SessionState = {
...state,
savedAt: Date.now(),
};
const stateToSave: SessionState = { ...state, savedAt: Date.now() };
fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8");
if (legacyPath !== filePath && fs.existsSync(legacyPath)) {
fs.unlinkSync(legacyPath);
@@ -187,7 +179,9 @@ function doSaveSession(state: SessionState): void {
`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`,
);
} catch (err) {
debugError(`[session-store] Failed to save session for ${state.accountId}: ${String(err)}`);
debugError(
`[session-store] Failed to save session for ${state.accountId}: ${formatErrorMessage(err)}`,
);
}
}
@@ -200,7 +194,6 @@ export function clearSession(accountId: string): void {
}
throttleState.delete(accountId);
}
try {
let cleared = false;
for (const filePath of getCandidateSessionPaths(accountId)) {
@@ -213,25 +206,23 @@ export function clearSession(accountId: string): void {
debugLog(`[session-store] Cleared session for ${accountId}`);
}
} catch (err) {
debugError(`[session-store] Failed to clear session for ${accountId}: ${String(err)}`);
debugError(
`[session-store] Failed to clear session for ${accountId}: ${formatErrorMessage(err)}`,
);
}
}
/** Update only lastSeq on the persisted session. */
export function updateLastSeq(accountId: string, lastSeq: number): void {
const existing = loadSession(accountId);
if (existing && existing.sessionId) {
saveSession({
...existing,
lastSeq,
});
if (existing?.sessionId) {
saveSession({ ...existing, lastSeq });
}
}
/** Load all saved sessions from disk. */
export function getAllSessions(): SessionState[] {
const sessions = new Map<string, SessionState>();
try {
ensureDir();
const files = fs.readdirSync(SESSION_DIR);
@@ -247,28 +238,20 @@ export function getAllSessions(): SessionState[] {
if (!existing || (state.savedAt ?? 0) >= (existing.savedAt ?? 0)) {
sessions.set(state.accountId, state);
}
} catch {
// Ignore malformed session files here.
}
} catch {}
}
}
} catch {
// Ignore missing directories and similar filesystem errors.
}
} catch {}
return [...sessions.values()];
}
/**
* Remove expired session files from disk.
*/
/** Remove expired session files from disk. */
export function cleanupExpiredSessions(): number {
let cleaned = 0;
try {
ensureDir();
const files = fs.readdirSync(SESSION_DIR);
const now = Date.now();
const files = fs.readdirSync(SESSION_DIR);
for (const file of files) {
if (isSessionFileName(file)) {
@@ -282,19 +265,13 @@ export function cleanupExpiredSessions(): number {
debugLog(`[session-store] Cleaned expired session: ${file}`);
}
} catch {
// Remove corrupted session files while ignoring parse errors.
try {
fs.unlinkSync(filePath);
cleaned++;
} catch {
// Ignore cleanup failures.
}
} catch {}
}
}
}
} catch {
// Ignore missing directories and similar filesystem errors.
}
} catch {}
return cleaned;
}

View File

@@ -0,0 +1,244 @@
/**
* QQ Channel API proxy tool core logic.
* QQ 频道 API 代理工具核心逻辑。
*
* Provides an authenticated HTTP proxy for the QQ Open Platform channel
* APIs. The caller (old tools/channel.ts shell) resolves the access
* token and passes it in; this module handles URL building, path
* validation, fetch, and structured response formatting.
*/
import { formatErrorMessage } from "../utils/format.js";
import { debugLog, debugError } from "../utils/log.js";
const API_BASE = "https://api.sgroup.qq.com";
const DEFAULT_TIMEOUT_MS = 30000;
/**
* Channel API call parameters.
* 频道 API 调用参数。
*/
export interface ChannelApiParams {
method: string;
path: string;
body?: Record<string, unknown>;
query?: Record<string, string>;
}
/**
* JSON Schema for AI tool parameters (used by framework registration).
* AI Tool 参数的 JSON Schema 定义(供框架注册使用)。
*/
export const ChannelApiSchema = {
type: "object",
properties: {
method: {
type: "string",
description: "HTTP method. Allowed values: GET, POST, PUT, PATCH, DELETE.",
enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
},
path: {
type: "string",
description:
"API path without the host. Replace placeholders with concrete values. " +
"Examples: /users/@me/guilds, /guilds/{guild_id}/channels, /channels/{channel_id}.",
},
body: {
type: "object",
description:
"JSON request body for POST/PUT/PATCH requests. GET/DELETE usually do not need it.",
},
query: {
type: "object",
description:
"URL query parameters as key/value pairs appended to the path. " +
'For example, { "limit": "100", "after": "0" } becomes ?limit=100&after=0.',
additionalProperties: { type: "string" },
},
},
required: ["method", "path"],
} as const;
/**
* Build the full API URL from base + path + query params.
* 拼接 API 基地址 + 路径 + 查询参数。
*/
export function buildUrl(path: string, query?: Record<string, string>): string {
let url = `${API_BASE}${path}`;
if (query && Object.keys(query).length > 0) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== "") {
params.set(key, value);
}
}
const qs = params.toString();
if (qs) {
url += `?${qs}`;
}
}
return url;
}
/**
* Validate API path format; returns an error string or null if valid.
* 校验 API 路径格式,返回错误描述或 null合法
*/
export function validatePath(path: string): string | null {
if (!path.startsWith("/")) {
return "path must start with /";
}
if (path.includes("..") || path.includes("//")) {
return "path must not contain .. or //";
}
if (!/^\/[a-zA-Z0-9\-._~:@!$&'()*+,;=/%]+$/.test(path) && path !== "/") {
return "path contains unsupported characters";
}
return null;
}
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
/**
* Options provided by the caller when executing a channel API request.
* 执行频道 API 请求时由调用方提供的选项。
*/
export interface ChannelApiExecuteOptions {
accessToken: string;
}
/**
* Execute a channel API proxy request.
* 执行频道 API 代理请求。
*
* The caller provides the access token; this function handles
* URL building, path validation, HTTP fetch, and structured
* response formatting suitable for AI tool output.
*/
export async function executeChannelApi(
params: ChannelApiParams,
options: ChannelApiExecuteOptions,
) {
if (!params.method) {
return json({ error: "method is required" });
}
if (!params.path) {
return json({ error: "path is required" });
}
const method = params.method.toUpperCase();
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) {
return json({
error: `Unsupported HTTP method: ${method}. Allowed values: GET, POST, PUT, PATCH, DELETE`,
});
}
const pathError = validatePath(params.path);
if (pathError) {
return json({ error: pathError });
}
if (
(method === "GET" || method === "DELETE") &&
params.body &&
Object.keys(params.body).length > 0
) {
debugLog(`[qqbot-channel-api] ${method} request with body, body will be ignored`);
}
try {
const url = buildUrl(params.path, params.query);
const headers: Record<string, string> = {
Authorization: `QQBot ${options.accessToken}`,
"Content-Type": "application/json",
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
const fetchOptions: RequestInit = {
method,
headers,
signal: controller.signal,
};
if (params.body && ["POST", "PUT", "PATCH"].includes(method)) {
fetchOptions.body = JSON.stringify(params.body);
}
debugLog(`[qqbot-channel-api] >>> ${method} ${url} (timeout: ${DEFAULT_TIMEOUT_MS}ms)`);
let res: Response;
try {
res = await fetch(url, fetchOptions);
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === "AbortError") {
debugError(`[qqbot-channel-api] <<< Request timeout after ${DEFAULT_TIMEOUT_MS}ms`);
return json({
error: `Request timed out after ${DEFAULT_TIMEOUT_MS}ms`,
path: params.path,
});
}
debugError("[qqbot-channel-api] <<< Network error:", err);
return json({
error: `Network error: ${formatErrorMessage(err)}`,
path: params.path,
});
} finally {
clearTimeout(timeoutId);
}
debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`);
const rawBody = await res.text();
if (!rawBody || rawBody.trim() === "") {
if (res.ok) {
return json({ success: true, status: res.status, path: params.path });
}
return json({
error: `API returned ${res.status} ${res.statusText}`,
status: res.status,
path: params.path,
});
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
parsed = rawBody;
}
if (!res.ok) {
const errMsg =
typeof parsed === "object" && parsed && "message" in parsed
? String((parsed as { message?: unknown }).message)
: `${res.status} ${res.statusText}`;
debugError(`[qqbot-channel-api] Error [${method} ${params.path}]: ${errMsg}`);
return json({
error: errMsg,
status: res.status,
path: params.path,
details: parsed,
});
}
return json({
success: true,
status: res.status,
path: params.path,
data: parsed,
});
} catch (err) {
return json({
error: formatErrorMessage(err),
path: params.path,
});
}
}

View File

@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import {
parseRelativeTime,
isCronExpression,
formatDelay,
generateJobName,
buildReminderPrompt,
executeRemind,
} from "./remind-logic.js";
describe("engine/tools/remind-logic", () => {
describe("parseRelativeTime", () => {
it("parses minutes shorthand", () => {
expect(parseRelativeTime("5m")).toBe(5 * 60_000);
});
it("parses hours shorthand", () => {
expect(parseRelativeTime("1h")).toBe(3_600_000);
});
it("parses combined hours and minutes", () => {
expect(parseRelativeTime("1h30m")).toBe(90 * 60_000);
});
it("parses days", () => {
expect(parseRelativeTime("2d")).toBe(2 * 86_400_000);
});
it("parses seconds", () => {
expect(parseRelativeTime("45s")).toBe(45_000);
});
it("treats plain numbers as minutes", () => {
expect(parseRelativeTime("10")).toBe(10 * 60_000);
});
it("returns null for unparseable input", () => {
expect(parseRelativeTime("never")).toBeNull();
});
it("is case insensitive", () => {
expect(parseRelativeTime("5M")).toBe(5 * 60_000);
});
});
describe("isCronExpression", () => {
it("detects standard 5-field cron", () => {
expect(isCronExpression("0 8 * * *")).toBe(true);
});
it("detects weekday range cron", () => {
expect(isCronExpression("0 9 * * 1-5")).toBe(true);
});
it("rejects short input", () => {
expect(isCronExpression("5m")).toBe(false);
});
it("rejects too many fields", () => {
expect(isCronExpression("0 0 0 0 0 0 0")).toBe(false);
});
});
describe("formatDelay", () => {
it("formats seconds", () => {
expect(formatDelay(45_000)).toBe("45s");
});
it("formats minutes", () => {
expect(formatDelay(300_000)).toBe("5m");
});
it("formats hours", () => {
expect(formatDelay(3_600_000)).toBe("1h");
});
it("formats hours and minutes", () => {
expect(formatDelay(5_400_000)).toBe("1h30m");
});
});
describe("generateJobName", () => {
it("returns short content as-is", () => {
expect(generateJobName("drink water")).toBe("Reminder: drink water");
});
it("truncates long content", () => {
const long = "a very long reminder content that exceeds twenty characters";
const name = generateJobName(long);
expect(name.length).toBeLessThan(40);
expect(name).toContain("…");
});
});
describe("buildReminderPrompt", () => {
it("includes the content in the prompt", () => {
const prompt = buildReminderPrompt("drink water");
expect(prompt).toContain("drink water");
});
});
describe("executeRemind", () => {
it("returns list instruction", () => {
const result = executeRemind({ action: "list" });
expect(result.details).toEqual({
_instruction: expect.any(String),
cronParams: { action: "list" },
});
});
it("returns error when removing without jobId", () => {
const result = executeRemind({ action: "remove" });
expect((result.details as { error: string }).error).toContain("jobId");
});
it("returns error when content is missing for add", () => {
const result = executeRemind({ action: "add", to: "qqbot:c2c:123", time: "5m" });
expect((result.details as { error: string }).error).toContain("content");
});
it("returns error when delay is too short", () => {
const result = executeRemind({
action: "add",
content: "test",
to: "qqbot:c2c:123",
time: "10s",
});
expect((result.details as { error: string }).error).toContain("30 seconds");
});
it("builds once job with delivery envelope for relative time", () => {
const result = executeRemind({
action: "add",
content: "test reminder",
to: "qqbot:c2c:123",
time: "5m",
});
const details = result.details as {
cronParams: {
job: {
schedule: { kind: string };
payload: { kind: string; message: string };
delivery: { mode: string; channel: string; to: string; accountId: string };
};
};
};
expect(details.cronParams.job.schedule.kind).toBe("at");
expect(details.cronParams.job.payload.kind).toBe("agentTurn");
expect(details.cronParams.job.delivery).toEqual({
mode: "announce",
channel: "qqbot",
to: "qqbot:c2c:123",
accountId: "default",
});
});
it("builds cron job with delivery envelope for cron expression", () => {
const result = executeRemind({
action: "add",
content: "test reminder",
to: "qqbot:c2c:123",
time: "0 8 * * *",
});
const details = result.details as {
cronParams: {
job: {
schedule: { kind: string };
delivery: { channel: string; to: string; accountId: string };
};
};
};
expect(details.cronParams.job.schedule.kind).toBe("cron");
expect(details.cronParams.job.delivery.to).toBe("qqbot:c2c:123");
});
it("falls back to ctx.fallbackTo when to is omitted", () => {
const result = executeRemind(
{ action: "add", content: "test", time: "5m" },
{ fallbackTo: "qqbot:c2c:ctx-target", fallbackAccountId: "alt" },
);
const details = result.details as {
cronParams: { job: { delivery: { to: string; accountId: string } } };
};
expect(details.cronParams.job.delivery.to).toBe("qqbot:c2c:ctx-target");
expect(details.cronParams.job.delivery.accountId).toBe("alt");
});
it("prefers AI-supplied to over ctx fallback", () => {
const result = executeRemind(
{ action: "add", content: "test", time: "5m", to: "qqbot:group:ai-chosen" },
{ fallbackTo: "qqbot:c2c:ctx-target", fallbackAccountId: "alt" },
);
const details = result.details as {
cronParams: { job: { delivery: { to: string; accountId: string } } };
};
expect(details.cronParams.job.delivery.to).toBe("qqbot:group:ai-chosen");
expect(details.cronParams.job.delivery.accountId).toBe("alt");
});
it("returns error when neither AI nor ctx provides a target", () => {
const result = executeRemind({ action: "add", content: "test", time: "5m" });
expect((result.details as { error: string }).error).toMatch(/delivery target/i);
});
});
});

View File

@@ -0,0 +1,311 @@
/**
* QQBot reminder tool core logic.
* QQBot 提醒工具核心逻辑。
*
* Pure functions for time parsing, cron detection, job building,
* and remind execution. The framework registration shell
* (bridge/tools/remind.ts) delegates all business logic here and
* supplies request-level context fallbacks (`to`, `accountId`).
*/
/**
* Reminder tool input parameters.
* 提醒工具的输入参数。
*/
export interface RemindParams {
action: "add" | "list" | "remove";
content?: string;
to?: string;
time?: string;
timezone?: string;
name?: string;
jobId?: string;
}
/**
* Context supplied by the bridge layer so the engine can remain free of
* framework / AsyncLocalStorage dependencies. `fallbackTo` and
* `fallbackAccountId` are consulted only when the corresponding AI-supplied
* parameter is missing.
*/
export interface RemindExecuteContext {
fallbackTo?: string;
fallbackAccountId?: string;
}
/**
* JSON Schema for AI tool parameters (used by framework registration).
* AI Tool 参数的 JSON Schema 定义(供框架注册使用)。
*/
export const RemindSchema = {
type: "object",
properties: {
action: {
type: "string",
description:
"Action type. add=create a reminder, list=show reminders, remove=delete a reminder.",
enum: ["add", "list", "remove"],
},
content: {
type: "string",
description:
'Reminder content, for example "drink water" or "join the meeting". Required when action=add.',
},
to: {
type: "string",
description:
"Optional delivery target. The runtime automatically resolves the current " +
"conversation target, so you usually do not need to supply this. " +
"Direct-message format: qqbot:c2c:user_openid. Group format: qqbot:group:group_openid.",
},
time: {
type: "string",
description:
"Time description. Supported formats:\n" +
'1. Relative time, for example "5m", "1h", "1h30m", or "2d"\n' +
'2. Cron expression, for example "0 8 * * *" or "0 9 * * 1-5"\n' +
"Values containing spaces are treated as cron expressions; everything else is treated as a one-shot relative delay.\n" +
"Required when action=add.",
},
timezone: {
type: "string",
description: 'Timezone used for cron reminders. Defaults to "Asia/Shanghai".',
},
name: {
type: "string",
description: "Optional reminder job name. Defaults to the first 20 characters of content.",
},
jobId: {
type: "string",
description: "Job ID to remove. Required when action=remove; fetch it with list first.",
},
},
required: ["action"],
} as const;
/**
* Parse a relative time string into milliseconds.
* 解析相对时间字符串为毫秒数。
*
* Supports: "5m", "1h", "1h30m", "2d", "45s", plain number (as minutes).
*
* @returns Milliseconds or null if unparseable.
*/
export function parseRelativeTime(timeStr: string): number | null {
const s = timeStr.toLowerCase();
if (/^\d+$/.test(s)) {
return parseInt(s, 10) * 60_000;
}
let totalMs = 0;
let matched = false;
const regex = /(\d+(?:\.\d+)?)\s*(d|h|m|s)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(s)) !== null) {
matched = true;
const value = parseFloat(match[1]);
const unit = match[2];
switch (unit) {
case "d":
totalMs += value * 86_400_000;
break;
case "h":
totalMs += value * 3_600_000;
break;
case "m":
totalMs += value * 60_000;
break;
case "s":
totalMs += value * 1_000;
break;
}
}
return matched ? Math.round(totalMs) : null;
}
/**
* Check whether a time string is a cron expression (36 space-separated fields).
* 判断时间字符串是否为 cron 表达式。
*/
export function isCronExpression(timeStr: string): boolean {
const parts = timeStr.trim().split(/\s+/);
if (parts.length < 3 || parts.length > 6) {
return false;
}
return parts.every((p) => /^[0-9*?/,LW#-]/.test(p));
}
/**
* Generate a cron job name from reminder content (first 20 chars).
* 根据提醒内容生成 cron job 名称。
*/
export function generateJobName(content: string): string {
const trimmed = content.trim();
const short = trimmed.length > 20 ? `${trimmed.slice(0, 20)}` : trimmed;
return `Reminder: ${short}`;
}
/** Build the reminder system prompt sent to the AI. */
export function buildReminderPrompt(content: string): string {
return (
`You are a warm reminder assistant. Please remind the user about: ${content}. ` +
`Requirements: (1) do not reply with HEARTBEAT_OK (2) do not explain who you are ` +
`(3) output a direct and caring reminder message (4) you may add a short encouraging line ` +
`(5) keep it within 2-3 sentences (6) use a small amount of emoji.`
);
}
/** Build cron job params for a one-shot delayed reminder. */
export function buildOnceJob(params: RemindParams, delayMs: number, to: string, accountId: string) {
const atMs = Date.now() + delayMs;
const content = params.content!;
const name = params.name || generateJobName(content);
return {
action: "add",
job: {
name,
schedule: { kind: "at", atMs },
sessionTarget: "isolated",
wakeMode: "now",
deleteAfterRun: true,
payload: {
kind: "agentTurn",
message: buildReminderPrompt(content),
},
delivery: {
mode: "announce",
channel: "qqbot",
to,
accountId,
},
},
};
}
/** Build cron job params for a recurring cron reminder. */
export function buildCronJob(params: RemindParams, to: string, accountId: string) {
const content = params.content!;
const name = params.name || generateJobName(content);
const tz = params.timezone || "Asia/Shanghai";
return {
action: "add",
job: {
name,
schedule: { kind: "cron", expr: params.time!.trim(), tz },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: buildReminderPrompt(content),
},
delivery: {
mode: "announce",
channel: "qqbot",
to,
accountId,
},
},
};
}
/** Format a delay in milliseconds as a short string (e.g. "5m", "1h30m"). */
export function formatDelay(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const totalMinutes = Math.round(ms / 60_000);
if (totalMinutes < 60) {
return `${totalMinutes}m`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) {
return `${hours}h`;
}
return `${hours}h${minutes}m`;
}
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
/**
* Execute the reminder tool logic.
* 执行提醒工具逻辑。
*
* Validates params, parses time, and returns a structured result
* containing cron job params that the framework shell passes back
* as the tool output.
*
* When the AI omits `to` / `accountId`, the bridge layer can supply
* `ctx.fallbackTo` / `ctx.fallbackAccountId` (typically resolved from
* the request-scoped AsyncLocalStorage) to fill them in.
*/
export function executeRemind(params: RemindParams, ctx: RemindExecuteContext = {}) {
if (params.action === "list") {
return json({
_instruction: "Use the cron tool immediately with the following parameters.",
cronParams: { action: "list" },
});
}
if (params.action === "remove") {
if (!params.jobId) {
return json({
error: "jobId is required when action=remove. Use action=list first.",
});
}
return json({
_instruction: "Use the cron tool immediately with the following parameters.",
cronParams: { action: "remove", jobId: params.jobId },
});
}
if (!params.content) {
return json({ error: "content is required when action=add" });
}
const resolvedTo = params.to || ctx.fallbackTo;
if (!resolvedTo) {
return json({
error:
"Unable to determine delivery target for action=add. " +
"The reminder can only be scheduled from within an active conversation.",
});
}
if (!params.time) {
return json({ error: "time is required when action=add" });
}
const resolvedAccountId = ctx.fallbackAccountId || "default";
if (isCronExpression(params.time)) {
return json({
_instruction:
"Use the cron tool immediately with the following parameters. " +
"Pass cronParams verbatim — do not modify or omit any field, especially delivery.accountId — then tell the user the reminder has been scheduled.",
cronParams: buildCronJob(params, resolvedTo, resolvedAccountId),
summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${params.timezone || "Asia/Shanghai"})`,
});
}
const delayMs = parseRelativeTime(params.time);
if (delayMs == null) {
return json({
error: `Could not parse time format: ${params.time}. Use values like 5m, 1h, 1h30m, or a cron expression.`,
});
}
if (delayMs < 30_000) {
return json({ error: "Reminder delay must be at least 30 seconds" });
}
return json({
_instruction:
"Use the cron tool immediately with the following parameters. " +
"Pass cronParams verbatim — do not modify or omit any field, especially delivery.accountId — then tell the user the reminder has been scheduled.",
cronParams: buildOnceJob(params, delayMs, resolvedTo, resolvedAccountId),
summary: `⏰ Reminder in ${formatDelay(delayMs)}: "${params.content}"`,
});
}

View File

@@ -0,0 +1,270 @@
/**
* Core API layer public types.
*
* These types are independent of the root `src/types.ts` and only define
* what the `core/api/` modules need. The old `src/types.ts` remains
* untouched for backward compatibility.
*/
// ============ Structured API Error ============
/**
* Structured API error with HTTP status, path, and optional business error code.
*
* Compared to the old `api.ts` which throws plain `Error`, this carries
* machine-readable fields for downstream retry/fallback decisions.
*/
export class ApiError extends Error {
override readonly name = "ApiError";
constructor(
message: string,
/** HTTP status code returned by the QQ Open Platform. */
public readonly httpStatus: number,
/** API path that produced the error (e.g. `/v2/users/{id}/messages`). */
public readonly path: string,
/** Business error code from the response body (`code` or `err_code`). */
public readonly bizCode?: number,
/** Original error message from the response body. */
public readonly bizMessage?: string,
) {
super(message);
}
}
// ============ Logger ============
/**
* Unified logger interface used across all engine/ modules.
*
* Replaces the previously fragmented ApiLogger, GatewayLogger, ReconnectLogger,
* MessageRefLogger, PathLogger, and SenderLogger interfaces.
*
* `info` and `error` are required; `warn` and `debug` are optional because
* some callers (e.g. the framework-injected `ctx.log`) may not provide them.
*/
export interface EngineLogger {
info: (msg: string) => void;
error: (msg: string) => void;
warn?: (msg: string) => void;
debug?: (msg: string) => void;
}
// ============ Chat Scope ============
/** Chat scope used to unify C2C/Group path construction. */
export type ChatScope = "c2c" | "group";
// ============ Message Response ============
/** Standard message send response from the QQ Open Platform. */
export interface MessageResponse {
id: string;
timestamp: number | string;
/** Reference index for future quoting. */
ext_info?: {
ref_idx?: string;
};
}
// ============ Media Types ============
/** QQ Open Platform media file type codes. */
export enum MediaFileType {
IMAGE = 1,
VIDEO = 2,
VOICE = 3,
FILE = 4,
}
/** Media upload response from the QQ Open Platform. */
export interface UploadMediaResponse {
file_uuid: string;
file_info: string;
ttl: number;
id?: string;
}
/** Structured metadata recorded for outbound messages. */
export interface OutboundMeta {
/** Message text content. */
text?: string;
/** Media type tag. */
mediaType?: "image" | "voice" | "video" | "file";
/** Remote URL of the media source. */
mediaUrl?: string;
/** Local file path of the media source. */
mediaLocalPath?: string;
/** Original TTS text (voice messages only). */
ttsText?: string;
}
// ============ API Client Config ============
/** Configuration for the core HTTP client. */
export interface ApiClientConfig {
/** Base URL for the QQ Open Platform REST API. */
baseUrl?: string;
/** Default request timeout in milliseconds. */
defaultTimeoutMs?: number;
/** File upload request timeout in milliseconds. */
fileUploadTimeoutMs?: number;
/** Logger instance. */
logger?: EngineLogger;
/** User-Agent header value, or a getter function for dynamic resolution. */
userAgent?: string | (() => string);
}
// ============ Chunked Upload Types ============
/** Individual upload part metadata. */
export interface UploadPart {
/** Part index (1-based). */
index: number;
/** Pre-signed upload URL. */
presigned_url: string;
}
/** Response from the upload_prepare endpoint. */
export interface UploadPrepareResponse {
/** Upload task identifier. */
upload_id: string;
/** Block size in bytes. */
block_size: number;
/** Pre-signed upload parts. */
parts: UploadPart[];
/** Server-suggested upload concurrency. */
concurrency?: number;
/** Server-suggested retry timeout for upload_part_finish (seconds). */
retry_timeout?: number;
}
/** Complete upload response. */
export interface MediaUploadResponse {
file_uuid: string;
file_info: string;
ttl: number;
}
/** File hash information for upload_prepare. */
export interface UploadPrepareHashes {
/** Whole-file MD5 (hex). */
md5: string;
/** Whole-file SHA1 (hex). */
sha1: string;
/** MD5 of the first 10,002,432 bytes (hex). */
md5_10m: string;
}
// ============ Stream Message Types ============
/** Stream message input state. */
export enum StreamInputState {
GENERATING = "1",
DONE = "10",
}
/** Stream message request body. */
export interface StreamMessageRequest {
input_mode: string;
input_state: string;
content_type: string;
content_raw: string;
event_id?: string;
msg_id?: string;
msg_seq?: number;
index?: number;
stream_msg_id?: string;
}
// ============ Inline Keyboard Types ============
/** Inline keyboard button for approval/interaction flows. */
export interface KeyboardButton {
id: string;
render_data: {
label: string;
visited_label: string;
style: number;
};
action: {
type: number;
permission: { type: number };
data: string;
click_limit?: number;
};
group_id?: string;
}
/**
* Inline keyboard structure attached to messages.
* Sent as the `keyboard` field in the message body:
* `{ "keyboard": { "content": { "rows": [...] } } }`
*/
export interface InlineKeyboard {
content: {
rows: Array<{ buttons: KeyboardButton[] }>;
};
}
// ============ Interaction Event Types ============
/** Button interaction event (INTERACTION_CREATE). */
export interface InteractionEvent {
/** Event ID — used to acknowledge the interaction (PUT /interactions/{id}). */
id: string;
/** Event sub-type: 11=message button, 12=c2c quick menu. */
type: number;
/** Scene identifier: c2c / group / guild. */
scene?: string;
/** Chat type: 0=guild, 1=group, 2=c2c. */
chat_type?: number;
timestamp?: string;
guild_id?: string;
channel_id?: string;
/** C2C user openid (c2c scene only). */
user_openid?: string;
/** Group openid (group scene only). */
group_openid?: string;
/** Group member openid (group scene only). */
group_member_openid?: string;
version: number;
data: {
type: number;
resolved: {
button_data?: string;
button_id?: string;
user_id?: string;
feature_id?: string;
message_id?: string;
};
};
}
// ============ Gateway Account ============
/**
* Resolved account configuration — shared across gateway/ and messaging/ layers.
*
* Lifted here from gateway/types.ts to eliminate the circular type dependency
* where messaging/ had to import from gateway/.
*/
export interface GatewayAccount {
accountId: string;
appId: string;
clientSecret: string;
markdownSupport: boolean;
systemPrompt?: string;
config: Record<string, unknown> & {
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
dmPolicy?: "open" | "allowlist" | "disabled";
groupPolicy?: "open" | "allowlist" | "disabled";
streaming?: { mode?: string };
audioFormatPolicy?: {
uploadDirectFormats?: string[];
transcodeEnabled?: boolean;
};
voiceDirectUploadFormats?: string[];
};
}

View File

@@ -0,0 +1,250 @@
import { describe, expect, it } from "vitest";
import {
pcmToWav,
stripAmrHeader,
isVoiceAttachment,
isAudioFile,
shouldTranscodeVoice,
parseWavFallback,
} from "./audio.js";
describe("engine/utils/audio", () => {
describe("pcmToWav", () => {
it("produces a valid WAV header", () => {
const pcm = new Uint8Array([0, 0, 1, 0]);
const wav = pcmToWav(pcm, 24000);
expect(wav.toString("ascii", 0, 4)).toBe("RIFF");
expect(wav.toString("ascii", 8, 12)).toBe("WAVE");
expect(wav.toString("ascii", 12, 16)).toBe("fmt ");
expect(wav.toString("ascii", 36, 40)).toBe("data");
});
it("sets correct file size in RIFF header", () => {
const pcm = new Uint8Array(100);
const wav = pcmToWav(pcm, 24000);
const riffSize = wav.readUInt32LE(4);
expect(riffSize).toBe(wav.length - 8);
});
it("sets correct sample rate", () => {
const pcm = new Uint8Array(10);
const wav = pcmToWav(pcm, 48000);
expect(wav.readUInt32LE(24)).toBe(48000);
});
it("sets correct channel count", () => {
const pcm = new Uint8Array(10);
const wav = pcmToWav(pcm, 24000, 2);
expect(wav.readUInt16LE(22)).toBe(2);
});
it("embeds PCM data after the 44-byte header", () => {
const pcm = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const wav = pcmToWav(pcm, 24000);
expect(wav[44]).toBe(0x01);
expect(wav[45]).toBe(0x02);
expect(wav[46]).toBe(0x03);
expect(wav[47]).toBe(0x04);
});
it("sets data chunk size matching PCM length", () => {
const pcm = new Uint8Array(256);
const wav = pcmToWav(pcm, 24000);
const dataSize = wav.readUInt32LE(40);
expect(dataSize).toBe(256);
});
});
describe("stripAmrHeader", () => {
it("strips the #!AMR header when present", () => {
const amrHeader = Buffer.from("#!AMR\n");
const payload = Buffer.from([0x01, 0x02, 0x03]);
const buf = Buffer.concat([amrHeader, payload]);
const result = stripAmrHeader(buf);
expect(result).toEqual(payload);
});
it("returns the buffer unchanged when no AMR header", () => {
const buf = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]);
const result = stripAmrHeader(buf);
expect(result).toBe(buf);
});
it("returns the buffer unchanged when too short", () => {
const buf = Buffer.from([0x01, 0x02]);
const result = stripAmrHeader(buf);
expect(result).toBe(buf);
});
});
describe("isVoiceAttachment", () => {
it("detects voice content_type", () => {
expect(isVoiceAttachment({ content_type: "voice" })).toBe(true);
});
it("detects audio/* content_type", () => {
expect(isVoiceAttachment({ content_type: "audio/silk" })).toBe(true);
expect(isVoiceAttachment({ content_type: "audio/amr" })).toBe(true);
});
it("detects voice file extensions", () => {
expect(isVoiceAttachment({ filename: "msg.amr" })).toBe(true);
expect(isVoiceAttachment({ filename: "msg.silk" })).toBe(true);
expect(isVoiceAttachment({ filename: "msg.slk" })).toBe(true);
expect(isVoiceAttachment({ filename: "msg.slac" })).toBe(true);
});
it("rejects non-voice attachments", () => {
expect(isVoiceAttachment({ content_type: "image/png" })).toBe(false);
expect(isVoiceAttachment({ filename: "photo.jpg" })).toBe(false);
});
it("handles missing fields", () => {
expect(isVoiceAttachment({})).toBe(false);
});
});
describe("isAudioFile", () => {
it.each([
".silk",
".slk",
".amr",
".wav",
".mp3",
".ogg",
".opus",
".aac",
".flac",
".m4a",
".wma",
".pcm",
])("recognizes %s as audio", (ext) => {
expect(isAudioFile(`file${ext}`)).toBe(true);
});
it("recognizes audio MIME types", () => {
expect(isAudioFile("file.bin", "audio/mpeg")).toBe(true);
expect(isAudioFile("file.bin", "voice")).toBe(true);
});
it("rejects non-audio files", () => {
expect(isAudioFile("photo.jpg")).toBe(false);
expect(isAudioFile("doc.pdf")).toBe(false);
});
it("is case-insensitive on extensions", () => {
expect(isAudioFile("file.MP3")).toBe(true);
expect(isAudioFile("file.Wav")).toBe(true);
});
});
describe("shouldTranscodeVoice", () => {
it("returns false for QQ native MIME types", () => {
expect(shouldTranscodeVoice("file.bin", "audio/silk")).toBe(false);
expect(shouldTranscodeVoice("file.bin", "audio/amr")).toBe(false);
expect(shouldTranscodeVoice("file.bin", "audio/wav")).toBe(false);
expect(shouldTranscodeVoice("file.bin", "audio/mp3")).toBe(false);
});
it("returns false for QQ native extensions", () => {
expect(shouldTranscodeVoice("voice.silk")).toBe(false);
expect(shouldTranscodeVoice("voice.amr")).toBe(false);
expect(shouldTranscodeVoice("voice.wav")).toBe(false);
expect(shouldTranscodeVoice("voice.mp3")).toBe(false);
});
it("returns true for non-native audio formats", () => {
expect(shouldTranscodeVoice("voice.ogg")).toBe(true);
expect(shouldTranscodeVoice("voice.opus")).toBe(true);
expect(shouldTranscodeVoice("voice.flac")).toBe(true);
expect(shouldTranscodeVoice("voice.aac")).toBe(true);
});
it("returns false for non-audio files", () => {
expect(shouldTranscodeVoice("photo.jpg")).toBe(false);
expect(shouldTranscodeVoice("doc.txt")).toBe(false);
});
});
describe("parseWavFallback", () => {
function buildMinimalWav(pcmData: Buffer, sampleRate = 24000, channels = 1): Buffer {
const bitsPerSample = 16;
const byteRate = sampleRate * channels * (bitsPerSample / 8);
const blockAlign = channels * (bitsPerSample / 8);
const dataSize = pcmData.length;
const buf = Buffer.alloc(44 + dataSize);
buf.write("RIFF", 0);
buf.writeUInt32LE(36 + dataSize, 4);
buf.write("WAVE", 8);
buf.write("fmt ", 12);
buf.writeUInt32LE(16, 16);
buf.writeUInt16LE(1, 20);
buf.writeUInt16LE(channels, 22);
buf.writeUInt32LE(sampleRate, 24);
buf.writeUInt32LE(byteRate, 28);
buf.writeUInt16LE(blockAlign, 32);
buf.writeUInt16LE(bitsPerSample, 34);
buf.write("data", 36);
buf.writeUInt32LE(dataSize, 40);
pcmData.copy(buf, 44);
return buf;
}
it("extracts PCM from a valid mono 24kHz WAV", () => {
const pcm = Buffer.from([0x01, 0x00, 0x02, 0x00]);
const wav = buildMinimalWav(pcm, 24000, 1);
const result = parseWavFallback(wav);
expect(result).not.toBeNull();
expect(result!.length).toBe(4);
expect(result![0]).toBe(0x01);
expect(result![1]).toBe(0x00);
});
it("returns null for buffers shorter than 44 bytes", () => {
expect(parseWavFallback(Buffer.alloc(20))).toBeNull();
});
it("returns null for non-WAV data", () => {
const buf = Buffer.alloc(44);
buf.write("NOT_", 0);
expect(parseWavFallback(buf)).toBeNull();
});
it("returns null for non-PCM audio formats", () => {
const wav = buildMinimalWav(Buffer.alloc(4), 24000, 1);
wav.writeUInt16LE(3, 20); // IEEE float instead of PCM
expect(parseWavFallback(wav)).toBeNull();
});
it("downmixes stereo to mono", () => {
// 2 samples × 2 channels × 2 bytes = 8 bytes
const stereoPcm = Buffer.alloc(8);
const view = new DataView(stereoPcm.buffer);
view.setInt16(0, 100, true); // L sample 0
view.setInt16(2, 200, true); // R sample 0
view.setInt16(4, -100, true); // L sample 1
view.setInt16(6, -200, true); // R sample 1
const wav = buildMinimalWav(stereoPcm, 24000, 2);
const result = parseWavFallback(wav);
expect(result).not.toBeNull();
// mono output: 2 samples × 2 bytes = 4 bytes
expect(result!.length).toBe(4);
const outView = new DataView(result!.buffer, result!.byteOffset);
expect(outView.getInt16(0, true)).toBe(150); // (100+200)/2
expect(outView.getInt16(2, true)).toBe(-150); // (-100+-200)/2
});
it("resamples non-24kHz WAV to 24kHz", () => {
// 4 samples at 48kHz → should produce ~2 samples at 24kHz
const pcm48k = Buffer.alloc(8);
const wav = buildMinimalWav(pcm48k, 48000, 1);
const result = parseWavFallback(wav);
expect(result).not.toBeNull();
expect(result!.length).toBe(4); // 2 samples × 2 bytes
});
});
});

Some files were not shown because too many files have changed in this diff Show More