From 5e72e39c1852a82e8002187b7c9b0c84214e0b08 Mon Sep 17 00:00:00 2001 From: cxy <49286167+cxyhhhhh@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:05:12 +0800 Subject: [PATCH] feat(qqbot): extract self-contained engine/ architecture with QR-code onboarding, approval handling (#67960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 - 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 Co-authored-by: neilhwang Co-authored-by: sliverp <870080352@qq.com> --- CHANGELOG.md | 1 + extensions/qqbot/api.ts | 13 +- extensions/qqbot/index.ts | 186 +- extensions/qqbot/openclaw.plugin.json | 25 - extensions/qqbot/package.json | 1 + extensions/qqbot/runtime-api.ts | 2 +- extensions/qqbot/skills/qqbot-remind/SKILL.md | 38 +- extensions/qqbot/src/api.security.test.ts | 145 -- extensions/qqbot/src/api.ts | 1052 ------------ .../qqbot/src/bridge/approval/capability.ts | 242 +++ .../src/bridge/approval/handler-runtime.ts | 204 +++ extensions/qqbot/src/bridge/bootstrap.ts | 135 ++ extensions/qqbot/src/bridge/channel-entry.ts | 18 + .../commands/framework-context-adapter.ts | 60 + .../bridge/commands/framework-registration.ts | 47 + .../src/bridge/commands/from-parser.test.ts | 86 + .../qqbot/src/bridge/commands/from-parser.ts | 60 + .../src/bridge/commands/result-dispatcher.ts | 76 + .../config-shared.ts} | 120 +- extensions/qqbot/src/bridge/config.ts | 104 ++ extensions/qqbot/src/bridge/gateway.ts | 180 ++ extensions/qqbot/src/bridge/logger.ts | 31 + .../qqbot/src/bridge/plugin-version.test.ts | 146 ++ extensions/qqbot/src/bridge/plugin-version.ts | 102 ++ extensions/qqbot/src/bridge/runtime.ts | 24 + extensions/qqbot/src/bridge/setup/finalize.ts | 151 ++ extensions/qqbot/src/bridge/setup/surface.ts | 34 + extensions/qqbot/src/bridge/tools/channel.ts | 62 + extensions/qqbot/src/bridge/tools/index.ts | 18 + extensions/qqbot/src/bridge/tools/remind.ts | 30 + .../qqbot/src/{ => bridge}/tools/result.ts | 0 extensions/qqbot/src/channel-base.ts | 30 - extensions/qqbot/src/channel.setup.ts | 26 +- extensions/qqbot/src/channel.ts | 258 +-- extensions/qqbot/src/config-record-shared.ts | 4 - extensions/qqbot/src/config-schema.ts | 45 +- extensions/qqbot/src/config.test.ts | 119 +- extensions/qqbot/src/config.ts | 227 --- .../src/engine/access/access-control.test.ts | 171 ++ .../qqbot/src/engine/access/access-control.ts | 208 +++ extensions/qqbot/src/engine/access/index.ts | 22 + .../src/engine/access/resolve-policy.test.ts | 59 + .../qqbot/src/engine/access/resolve-policy.ts | 57 + .../src/engine/access/sender-match.test.ts | 60 + .../qqbot/src/engine/access/sender-match.ts | 55 + extensions/qqbot/src/engine/access/types.ts | 52 + extensions/qqbot/src/engine/adapter/index.ts | 106 ++ extensions/qqbot/src/engine/adapter/types.ts | 38 + extensions/qqbot/src/engine/api/api-client.ts | 196 +++ extensions/qqbot/src/engine/api/media.ts | 178 ++ extensions/qqbot/src/engine/api/messages.ts | 267 +++ extensions/qqbot/src/engine/api/retry.ts | 219 +++ extensions/qqbot/src/engine/api/routes.ts | 95 + extensions/qqbot/src/engine/api/token.ts | 271 +++ .../qqbot/src/engine/approval/index.test.ts | 22 + extensions/qqbot/src/engine/approval/index.ts | 238 +++ .../engine/commands/slash-command-handler.ts | 139 ++ .../engine/commands/slash-commands-impl.ts | 987 +++++++++++ .../src/engine/commands/slash-commands.ts | 186 ++ .../qqbot/src/engine/config/allow-from.ts | 27 + .../engine/config/credential-backup.test.ts | 88 + .../src/engine/config/credential-backup.ts | 103 ++ .../qqbot/src/engine/config/credentials.ts | 120 ++ .../qqbot/src/engine/config/resolve.test.ts | 152 ++ extensions/qqbot/src/engine/config/resolve.ts | 283 +++ .../qqbot/src/engine/config/setup-logic.ts | 84 + extensions/qqbot/src/engine/gateway/codec.ts | 47 + .../qqbot/src/engine/gateway/constants.ts | 95 + .../src/engine/gateway/event-dispatcher.ts | 155 ++ .../src/engine/gateway/gateway-connection.ts | 371 ++++ .../qqbot/src/engine/gateway/gateway.ts | 286 +++ .../gateway}/inbound-attachments.ts | 98 +- .../src/engine/gateway/inbound-context.ts | 119 ++ .../src/engine/gateway/inbound-pipeline.ts | 444 +++++ .../src/{ => engine/gateway}/message-queue.ts | 57 +- .../src/engine/gateway/outbound-dispatch.ts | 403 +++++ .../qqbot/src/engine/gateway/reconnect.ts | 199 +++ extensions/qqbot/src/engine/gateway/types.ts | 170 ++ .../{ => engine/gateway}/typing-keepalive.ts | 29 +- .../src/engine/group/deliver-debounce.ts | 155 ++ .../qqbot/src/engine/group/message-gating.ts | 72 + .../src/engine/messaging/decode-media-path.ts | 82 + .../src/engine/messaging/media-type-detect.ts | 122 ++ .../messaging}/outbound-deliver.ts | 732 ++++---- .../src/{ => engine/messaging}/outbound.ts | 962 +++-------- .../src/engine/messaging/reply-dispatcher.ts | 551 ++++++ .../src/engine/messaging/reply-limiter.ts | 164 ++ .../qqbot/src/engine/messaging/sender.ts | 700 ++++++++ .../src/engine/messaging/target-parser.ts | 122 ++ .../src/engine/ref/format-message-ref.ts | 142 ++ .../qqbot/src/engine/ref/format-ref-entry.ts | 53 + .../ref/store.ts} | 184 +- extensions/qqbot/src/engine/ref/types.ts | 27 + .../src/{ => engine/session}/known-users.ts | 110 +- .../src/{ => engine/session}/session-store.ts | 87 +- .../qqbot/src/engine/tools/channel-api.ts | 244 +++ .../src/engine/tools/remind-logic.test.ts | 205 +++ .../qqbot/src/engine/tools/remind-logic.ts | 311 ++++ extensions/qqbot/src/engine/types.ts | 270 +++ .../qqbot/src/engine/utils/audio.test.ts | 250 +++ .../utils/audio.ts} | 458 +---- .../qqbot/src/engine/utils/data-paths.ts | 38 + .../qqbot/src/engine/utils/diagnostics.ts | 109 ++ .../{ => engine}/utils/file-utils-runtime.ts | 0 .../src/{ => engine}/utils/file-utils.test.ts | 20 +- .../src/{ => engine}/utils/file-utils.ts | 32 +- .../qqbot/src/engine/utils/format.test.ts | 68 + extensions/qqbot/src/engine/utils/format.ts | 70 + .../src/{ => engine}/utils/image-size.test.ts | 58 +- .../src/{ => engine}/utils/image-size.ts | 15 +- extensions/qqbot/src/engine/utils/log.ts | 32 + .../src/{ => engine}/utils/media-tags.test.ts | 0 .../src/{ => engine}/utils/media-tags.ts | 48 +- .../qqbot/src/{ => engine}/utils/payload.ts | 66 +- .../src/{ => engine}/utils/platform.test.ts | 16 + .../qqbot/src/{ => engine}/utils/platform.ts | 471 ++--- .../qqbot/src/engine/utils/request-context.ts | 75 + .../src/engine/utils/string-normalize.ts | 137 ++ .../qqbot/src/{ => engine/utils}/stt.ts | 16 +- .../qqbot/src/engine/utils/text-chunk.ts | 39 + .../{ => engine}/utils/text-parsing.test.ts | 0 .../qqbot/src/engine/utils/text-parsing.ts | 155 ++ .../src/{ => engine}/utils/upload-cache.ts | 7 +- .../qqbot/src/engine/utils/voice-text.ts | 15 + extensions/qqbot/src/exec-approvals.ts | 226 +++ extensions/qqbot/src/gateway.ts | 1530 ----------------- extensions/qqbot/src/outbound-deliver.test.ts | 228 --- .../qqbot/src/outbound.security.test.ts | 398 ----- extensions/qqbot/src/proactive.test.ts | 64 - extensions/qqbot/src/proactive.ts | 330 ---- extensions/qqbot/src/reply-dispatcher.test.ts | 74 - extensions/qqbot/src/reply-dispatcher.ts | 714 -------- extensions/qqbot/src/runtime.ts | 9 - extensions/qqbot/src/session-store.test.ts | 94 - extensions/qqbot/src/setup-surface.ts | 177 -- extensions/qqbot/src/setup.test.ts | 146 -- extensions/qqbot/src/slash-commands.test.ts | 180 -- extensions/qqbot/src/slash-commands.ts | 649 ------- extensions/qqbot/src/text-utils.ts | 14 - extensions/qqbot/src/tools/channel.ts | 256 --- extensions/qqbot/src/tools/remind.ts | 254 --- extensions/qqbot/src/types.ts | 46 + extensions/qqbot/src/types/silk-wasm.d.ts | 12 - extensions/qqbot/src/utils/debug-log.ts | 26 - extensions/qqbot/src/utils/text-parsing.ts | 95 - package.json | 1 + pnpm-lock.yaml | 14 + scripts/check-no-raw-channel-fetch.mjs | 9 +- src/canvas-host/a2ui/a2ui.bundle.js | 1402 +++++---------- 149 files changed, 15184 insertions(+), 10312 deletions(-) delete mode 100644 extensions/qqbot/src/api.security.test.ts delete mode 100644 extensions/qqbot/src/api.ts create mode 100644 extensions/qqbot/src/bridge/approval/capability.ts create mode 100644 extensions/qqbot/src/bridge/approval/handler-runtime.ts create mode 100644 extensions/qqbot/src/bridge/bootstrap.ts create mode 100644 extensions/qqbot/src/bridge/channel-entry.ts create mode 100644 extensions/qqbot/src/bridge/commands/framework-context-adapter.ts create mode 100644 extensions/qqbot/src/bridge/commands/framework-registration.ts create mode 100644 extensions/qqbot/src/bridge/commands/from-parser.test.ts create mode 100644 extensions/qqbot/src/bridge/commands/from-parser.ts create mode 100644 extensions/qqbot/src/bridge/commands/result-dispatcher.ts rename extensions/qqbot/src/{channel-config-shared.ts => bridge/config-shared.ts} (52%) create mode 100644 extensions/qqbot/src/bridge/config.ts create mode 100644 extensions/qqbot/src/bridge/gateway.ts create mode 100644 extensions/qqbot/src/bridge/logger.ts create mode 100644 extensions/qqbot/src/bridge/plugin-version.test.ts create mode 100644 extensions/qqbot/src/bridge/plugin-version.ts create mode 100644 extensions/qqbot/src/bridge/runtime.ts create mode 100644 extensions/qqbot/src/bridge/setup/finalize.ts create mode 100644 extensions/qqbot/src/bridge/setup/surface.ts create mode 100644 extensions/qqbot/src/bridge/tools/channel.ts create mode 100644 extensions/qqbot/src/bridge/tools/index.ts create mode 100644 extensions/qqbot/src/bridge/tools/remind.ts rename extensions/qqbot/src/{ => bridge}/tools/result.ts (100%) delete mode 100644 extensions/qqbot/src/channel-base.ts delete mode 100644 extensions/qqbot/src/config-record-shared.ts delete mode 100644 extensions/qqbot/src/config.ts create mode 100644 extensions/qqbot/src/engine/access/access-control.test.ts create mode 100644 extensions/qqbot/src/engine/access/access-control.ts create mode 100644 extensions/qqbot/src/engine/access/index.ts create mode 100644 extensions/qqbot/src/engine/access/resolve-policy.test.ts create mode 100644 extensions/qqbot/src/engine/access/resolve-policy.ts create mode 100644 extensions/qqbot/src/engine/access/sender-match.test.ts create mode 100644 extensions/qqbot/src/engine/access/sender-match.ts create mode 100644 extensions/qqbot/src/engine/access/types.ts create mode 100644 extensions/qqbot/src/engine/adapter/index.ts create mode 100644 extensions/qqbot/src/engine/adapter/types.ts create mode 100644 extensions/qqbot/src/engine/api/api-client.ts create mode 100644 extensions/qqbot/src/engine/api/media.ts create mode 100644 extensions/qqbot/src/engine/api/messages.ts create mode 100644 extensions/qqbot/src/engine/api/retry.ts create mode 100644 extensions/qqbot/src/engine/api/routes.ts create mode 100644 extensions/qqbot/src/engine/api/token.ts create mode 100644 extensions/qqbot/src/engine/approval/index.test.ts create mode 100644 extensions/qqbot/src/engine/approval/index.ts create mode 100644 extensions/qqbot/src/engine/commands/slash-command-handler.ts create mode 100644 extensions/qqbot/src/engine/commands/slash-commands-impl.ts create mode 100644 extensions/qqbot/src/engine/commands/slash-commands.ts create mode 100644 extensions/qqbot/src/engine/config/allow-from.ts create mode 100644 extensions/qqbot/src/engine/config/credential-backup.test.ts create mode 100644 extensions/qqbot/src/engine/config/credential-backup.ts create mode 100644 extensions/qqbot/src/engine/config/credentials.ts create mode 100644 extensions/qqbot/src/engine/config/resolve.test.ts create mode 100644 extensions/qqbot/src/engine/config/resolve.ts create mode 100644 extensions/qqbot/src/engine/config/setup-logic.ts create mode 100644 extensions/qqbot/src/engine/gateway/codec.ts create mode 100644 extensions/qqbot/src/engine/gateway/constants.ts create mode 100644 extensions/qqbot/src/engine/gateway/event-dispatcher.ts create mode 100644 extensions/qqbot/src/engine/gateway/gateway-connection.ts create mode 100644 extensions/qqbot/src/engine/gateway/gateway.ts rename extensions/qqbot/src/{ => engine/gateway}/inbound-attachments.ts (77%) create mode 100644 extensions/qqbot/src/engine/gateway/inbound-context.ts create mode 100644 extensions/qqbot/src/engine/gateway/inbound-pipeline.ts rename extensions/qqbot/src/{ => engine/gateway}/message-queue.ts (74%) create mode 100644 extensions/qqbot/src/engine/gateway/outbound-dispatch.ts create mode 100644 extensions/qqbot/src/engine/gateway/reconnect.ts create mode 100644 extensions/qqbot/src/engine/gateway/types.ts rename extensions/qqbot/src/{ => engine/gateway}/typing-keepalive.ts (58%) create mode 100644 extensions/qqbot/src/engine/group/deliver-debounce.ts create mode 100644 extensions/qqbot/src/engine/group/message-gating.ts create mode 100644 extensions/qqbot/src/engine/messaging/decode-media-path.ts create mode 100644 extensions/qqbot/src/engine/messaging/media-type-detect.ts rename extensions/qqbot/src/{ => engine/messaging}/outbound-deliver.ts (53%) rename extensions/qqbot/src/{ => engine/messaging}/outbound.ts (55%) create mode 100644 extensions/qqbot/src/engine/messaging/reply-dispatcher.ts create mode 100644 extensions/qqbot/src/engine/messaging/reply-limiter.ts create mode 100644 extensions/qqbot/src/engine/messaging/sender.ts create mode 100644 extensions/qqbot/src/engine/messaging/target-parser.ts create mode 100644 extensions/qqbot/src/engine/ref/format-message-ref.ts create mode 100644 extensions/qqbot/src/engine/ref/format-ref-entry.ts rename extensions/qqbot/src/{ref-index-store.ts => engine/ref/store.ts} (54%) create mode 100644 extensions/qqbot/src/engine/ref/types.ts rename extensions/qqbot/src/{ => engine/session}/known-users.ts (77%) rename extensions/qqbot/src/{ => engine/session}/session-store.ts (84%) create mode 100644 extensions/qqbot/src/engine/tools/channel-api.ts create mode 100644 extensions/qqbot/src/engine/tools/remind-logic.test.ts create mode 100644 extensions/qqbot/src/engine/tools/remind-logic.ts create mode 100644 extensions/qqbot/src/engine/types.ts create mode 100644 extensions/qqbot/src/engine/utils/audio.test.ts rename extensions/qqbot/src/{utils/audio-convert.ts => engine/utils/audio.ts} (54%) create mode 100644 extensions/qqbot/src/engine/utils/data-paths.ts create mode 100644 extensions/qqbot/src/engine/utils/diagnostics.ts rename extensions/qqbot/src/{ => engine}/utils/file-utils-runtime.ts (100%) rename extensions/qqbot/src/{ => engine}/utils/file-utils.test.ts (76%) rename extensions/qqbot/src/{ => engine}/utils/file-utils.ts (84%) create mode 100644 extensions/qqbot/src/engine/utils/format.test.ts create mode 100644 extensions/qqbot/src/engine/utils/format.ts rename extensions/qqbot/src/{ => engine}/utils/image-size.test.ts (64%) rename extensions/qqbot/src/{ => engine}/utils/image-size.ts (94%) create mode 100644 extensions/qqbot/src/engine/utils/log.ts rename extensions/qqbot/src/{ => engine}/utils/media-tags.test.ts (100%) rename extensions/qqbot/src/{ => engine}/utils/media-tags.ts (76%) rename extensions/qqbot/src/{ => engine}/utils/payload.ts (75%) rename extensions/qqbot/src/{ => engine}/utils/platform.test.ts (81%) rename extensions/qqbot/src/{ => engine}/utils/platform.ts (58%) create mode 100644 extensions/qqbot/src/engine/utils/request-context.ts create mode 100644 extensions/qqbot/src/engine/utils/string-normalize.ts rename extensions/qqbot/src/{ => engine/utils}/stt.ts (85%) create mode 100644 extensions/qqbot/src/engine/utils/text-chunk.ts rename extensions/qqbot/src/{ => engine}/utils/text-parsing.test.ts (100%) create mode 100644 extensions/qqbot/src/engine/utils/text-parsing.ts rename extensions/qqbot/src/{ => engine}/utils/upload-cache.ts (95%) create mode 100644 extensions/qqbot/src/engine/utils/voice-text.ts create mode 100644 extensions/qqbot/src/exec-approvals.ts delete mode 100644 extensions/qqbot/src/gateway.ts delete mode 100644 extensions/qqbot/src/outbound-deliver.test.ts delete mode 100644 extensions/qqbot/src/outbound.security.test.ts delete mode 100644 extensions/qqbot/src/proactive.test.ts delete mode 100644 extensions/qqbot/src/proactive.ts delete mode 100644 extensions/qqbot/src/reply-dispatcher.test.ts delete mode 100644 extensions/qqbot/src/reply-dispatcher.ts delete mode 100644 extensions/qqbot/src/runtime.ts delete mode 100644 extensions/qqbot/src/session-store.test.ts delete mode 100644 extensions/qqbot/src/setup-surface.ts delete mode 100644 extensions/qqbot/src/setup.test.ts delete mode 100644 extensions/qqbot/src/slash-commands.test.ts delete mode 100644 extensions/qqbot/src/slash-commands.ts delete mode 100644 extensions/qqbot/src/text-utils.ts delete mode 100644 extensions/qqbot/src/tools/channel.ts delete mode 100644 extensions/qqbot/src/tools/remind.ts delete mode 100644 extensions/qqbot/src/types/silk-wasm.d.ts delete mode 100644 extensions/qqbot/src/utils/debug-log.ts delete mode 100644 extensions/qqbot/src/utils/text-parsing.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b60ad10f638..5b643466bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/qqbot/api.ts b/extensions/qqbot/api.ts index 6d412084628..9fed28c161e 100644 --- a/extensions/qqbot/api.ts +++ b/extensions/qqbot/api.ts @@ -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"; diff --git a/extensions/qqbot/index.ts b/extensions/qqbot/index.ts index 0521489b789..5ec615f92f7 100644 --- a/extensions/qqbot/index.ts +++ b/extensions/qqbot/index.ts @@ -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) => Promise; -}; - -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 - >(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::" e.g. "qqbot:c2c:" - 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, }); diff --git a/extensions/qqbot/openclaw.plugin.json b/extensions/qqbot/openclaw.plugin.json index cfb5ff1be49..8dc5dba5202 100644 --- a/extensions/qqbot/openclaw.plugin.json +++ b/extensions/qqbot/openclaw.plugin.json @@ -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" }, diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json index 922cfc987a5..6d49788ddb7 100644 --- a/extensions/qqbot/package.json +++ b/extensions/qqbot/package.json @@ -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", diff --git a/extensions/qqbot/runtime-api.ts b/extensions/qqbot/runtime-api.ts index c864cbfdbff..004f9a255a1 100644 --- a/extensions/qqbot/runtime-api.ts +++ b/extensions/qqbot/runtime-api.ts @@ -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"; diff --git a/extensions/qqbot/skills/qqbot-remind/SKILL.md b/extensions/qqbot/skills/qqbot-remind/SKILL.md index f5a38b1edcf..751208ee56e 100644 --- a/extensions/qqbot/skills/qqbot-remind/SKILL.md +++ b/extensions/qqbot/skills/qqbot-remind/SKILL.md @@ -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`。 --- diff --git a/extensions/qqbot/src/api.security.test.ts b/extensions/qqbot/src/api.security.test.ts deleted file mode 100644 index 78dcc58110c..00000000000 --- a/extensions/qqbot/src/api.security.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/extensions/qqbot/src/api.ts b/extensions/qqbot/src/api.ts deleted file mode 100644 index 1bbe1e62eb6..00000000000 --- a/extensions/qqbot/src/api.ts +++ /dev/null @@ -1,1052 +0,0 @@ -import { createRequire } from "node:module"; -import os from "node:os"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared"; -import { - fetchWithSsrFGuard, - resolvePinnedHostnameWithPolicy, -} from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { debugLog, debugError } from "./utils/debug-log.js"; -import { sanitizeFileName } from "./utils/platform.js"; -import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js"; - -const API_BASE = "https://api.sgroup.qq.com"; -const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"; - -// Plugin User-Agent format: QQBotPlugin/{version} (Node/{nodeVersion}; {os}) -const _require = createRequire(import.meta.url); -const _pluginVersion = readPluginPackageVersion({ require: _require }); -export const PLUGIN_USER_AGENT = `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()})`; - -// ========================================================================= -// Per-appId runtime config (avoids multi-account global state conflicts) -// ========================================================================= -const markdownSupportMap = new Map(); - -/** Structured metadata recorded for outbound messages. */ -export interface OutboundMeta { - text?: string; - mediaType?: "image" | "voice" | "video" | "file"; - mediaUrl?: string; - mediaLocalPath?: string; - ttsText?: string; -} - -type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void; -const onMessageSentHookMap = new Map(); - -/** Register an outbound-message hook scoped to one appId. */ -export function onMessageSent(appId: string, callback: OnMessageSentCallback): void { - onMessageSentHookMap.set(normalizeOptionalString(appId) ?? "", callback); -} - -/** Initialize per-app API behavior such as markdown support. */ -export function initApiConfig(appId: string, options: { markdownSupport?: boolean }): void { - markdownSupportMap.set(normalizeOptionalString(appId) ?? "", options.markdownSupport === true); -} - -/** Return whether markdown is enabled for the given appId. */ -export function isMarkdownSupport(appId: string): boolean { - return markdownSupportMap.get(normalizeOptionalString(appId) ?? "") ?? false; -} - -// Keep token state per appId to avoid multi-account cross-talk. -const tokenCacheMap = new Map(); -const tokenFetchPromises = new Map>(); - -/** - * Resolve an access token with caching and singleflight semantics. - */ -export async function getAccessToken(appId: string, clientSecret: string): Promise { - const normalizedAppId = normalizeOptionalString(appId) ?? ""; - const cachedToken = tokenCacheMap.get(normalizedAppId); - - // Refresh slightly ahead of expiry without making short-lived tokens unusable. - const REFRESH_AHEAD_MS = cachedToken - ? Math.min(5 * 60 * 1000, (cachedToken.expiresAt - Date.now()) / 3) - : 0; - if (cachedToken && Date.now() < cachedToken.expiresAt - REFRESH_AHEAD_MS) { - return cachedToken.token; - } - - let fetchPromise = tokenFetchPromises.get(normalizedAppId); - if (fetchPromise) { - debugLog( - `[qqbot-api:${normalizedAppId}] Token fetch in progress, waiting for existing request...`, - ); - return fetchPromise; - } - - fetchPromise = (async () => { - try { - return await doFetchToken(normalizedAppId, clientSecret); - } finally { - tokenFetchPromises.delete(normalizedAppId); - } - })(); - - tokenFetchPromises.set(normalizedAppId, fetchPromise); - return fetchPromise; -} - -/** Perform the token fetch request. */ -async function doFetchToken(appId: string, clientSecret: string): Promise { - const requestBody = { appId, clientSecret }; - const requestHeaders = { "Content-Type": "application/json", "User-Agent": PLUGIN_USER_AGENT }; - - debugLog(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL}`); - - let response: Response; - let release = async () => {}; - try { - const guarded = await fetchWithSsrFGuard({ - url: TOKEN_URL, - init: { - method: "POST", - headers: requestHeaders, - body: JSON.stringify(requestBody), - }, - auditContext: "qqbot.token", - }); - response = guarded.response; - release = guarded.release; - } catch (err) { - debugError(`[qqbot-api:${appId}] <<< Network error:`, err); - throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, { - cause: err, - }); - } - - try { - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - const tokenTraceId = response.headers.get("x-tps-trace-id") ?? ""; - debugLog( - `[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}${tokenTraceId ? ` | TraceId: ${tokenTraceId}` : ""}`, - ); - - let data: { access_token?: string; expires_in?: number }; - let rawBody: string; - try { - rawBody = await response.text(); - // Redact the token before logging the raw response body. - const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"'); - debugLog(`[qqbot-api:${appId}] <<< Body:`, logBody); - data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number }; - } catch (err) { - debugError(`[qqbot-api:${appId}] <<< Parse error:`, 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; - - tokenCacheMap.set(appId, { - token: data.access_token, - expiresAt, - appId, - }); - - debugLog(`[qqbot-api:${appId}] Token cached, expires at: ${new Date(expiresAt).toISOString()}`); - return data.access_token; - } finally { - await release(); - } -} - -/** Clear one token cache or all token caches. */ -export function clearTokenCache(appId?: string): void { - if (appId) { - const normalizedAppId = normalizeOptionalString(appId) ?? ""; - tokenCacheMap.delete(normalizedAppId); - debugLog(`[qqbot-api:${normalizedAppId}] Token cache cleared manually.`); - } else { - tokenCacheMap.clear(); - debugLog(`[qqbot-api] All token caches cleared.`); - } -} - -/** Return token-cache status for diagnostics. */ -export function getTokenStatus(appId: string): { - status: "valid" | "expired" | "refreshing" | "none"; - expiresAt: number | null; -} { - if (tokenFetchPromises.has(appId)) { - return { status: "refreshing", expiresAt: tokenCacheMap.get(appId)?.expiresAt ?? null }; - } - const cached = tokenCacheMap.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 }; -} - -/** Generate a message sequence in the 0..65535 range. */ -export function getNextMsgSeq(_msgId: string): number { - const timePart = Date.now() % 100000000; - const random = Math.floor(Math.random() * 65536); - return (timePart ^ random) % 65536; -} - -const DEFAULT_API_TIMEOUT = 30000; -const FILE_UPLOAD_TIMEOUT = 120000; - -/** Shared API request wrapper. */ -export async function apiRequest( - accessToken: string, - method: string, - path: string, - body?: unknown, - timeoutMs?: number, -): Promise { - const url = `${API_BASE}${path}`; - const headers: Record = { - Authorization: `QQBot ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": PLUGIN_USER_AGENT, - }; - - const isFileUpload = path.includes("/files"); - const timeout = timeoutMs ?? (isFileUpload ? FILE_UPLOAD_TIMEOUT : DEFAULT_API_TIMEOUT); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, timeout); - - const options: RequestInit = { - method, - headers, - signal: controller.signal, - }; - - if (body) { - options.body = JSON.stringify(body); - } - - debugLog(`[qqbot-api] >>> ${method} ${url} (timeout: ${timeout}ms)`); - if (body) { - const logBody = { ...body } as Record; - if (typeof logBody.file_data === "string") { - logBody.file_data = ``; - } - debugLog(`[qqbot-api] >>> Body:`, JSON.stringify(logBody)); - } - - let res: Response; - let release = async () => {}; - try { - const guarded = await fetchWithSsrFGuard({ - url, - init: options, - auditContext: `qqbot.api${path}`, - }); - res = guarded.response; - release = guarded.release; - } catch (err) { - clearTimeout(timeoutId); - if (err instanceof Error && err.name === "AbortError") { - debugError(`[qqbot-api] <<< Request timeout after ${timeout}ms`); - throw new Error(`Request timeout[${path}]: exceeded ${timeout}ms`, { cause: err }); - } - debugError(`[qqbot-api] <<< Network error:`, err); - throw new Error(`Network error [${path}]: ${formatErrorMessage(err)}`, { cause: err }); - } finally { - clearTimeout(timeoutId); - } - - const responseHeaders: Record = {}; - res.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - const traceId = res.headers.get("x-tps-trace-id") ?? ""; - debugLog( - `[qqbot-api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`, - ); - - try { - let data: T; - const rawBody = await res.text(); - debugLog(`[qqbot-api] <<< Body:`, rawBody); - data = JSON.parse(rawBody) as T; - - if (!res.ok) { - const error = data as { message?: string; code?: number }; - throw new Error(`API Error [${path}]: ${error.message ?? JSON.stringify(data)}`); - } - - return data; - } catch (err) { - throw new Error(`Failed to parse response[${path}]: ${formatErrorMessage(err)}`, { - cause: err, - }); - } finally { - await release(); - } -} - -// Upload retry with exponential backoff. - -const UPLOAD_MAX_RETRIES = 2; -const UPLOAD_BASE_DELAY_MS = 1000; - -async function apiRequestWithRetry( - accessToken: string, - method: string, - path: string, - body?: unknown, - maxRetries = UPLOAD_MAX_RETRIES, -): Promise { - let lastError: Error | null = null; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await apiRequest(accessToken, method, path, body); - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - - const errMsg = lastError.message; - if ( - errMsg.includes("400") || - errMsg.includes("401") || - errMsg.includes("Invalid") || - errMsg.includes("upload timeout") || - errMsg.includes("timeout") || - errMsg.includes("Timeout") - ) { - throw lastError; - } - - if (attempt < maxRetries) { - const delay = UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt); - debugLog( - `[qqbot-api] Upload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${errMsg.slice(0, 100)}`, - ); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - } - - throw lastError!; -} - -export async function getGatewayUrl(accessToken: string): Promise { - const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway"); - return data.url; -} - -// Message sending. - -export interface MessageResponse { - id: string; - timestamp: number | string; - ext_info?: { - ref_idx?: string; - }; -} - -/** - * Send a message and invoke the refIdx hook when QQ returns one. - */ -async function sendAndNotify( - appId: string, - accessToken: string, - method: string, - path: string, - body: unknown, - meta: OutboundMeta, -): Promise { - const result = await apiRequest(accessToken, method, path, body); - const hook = onMessageSentHookMap.get(normalizeOptionalString(appId) ?? ""); - if (result.ext_info?.ref_idx && hook) { - try { - hook(result.ext_info.ref_idx, meta); - } catch (err) { - debugError(`[qqbot-api:${appId}] onMessageSent hook error: ${String(err)}`); - } - } - return result; -} - -function buildMessageBody( - appId: string, - content: string, - msgId: string | undefined, - msgSeq: number, - messageReference?: string, -): Record { - const md = isMarkdownSupport(appId); - const body: Record = md - ? { - markdown: { content }, - msg_type: 2, - msg_seq: msgSeq, - } - : { - content, - msg_type: 0, - msg_seq: msgSeq, - }; - - if (msgId) { - body.msg_id = msgId; - } - if (messageReference && !md) { - body.message_reference = { message_id: messageReference }; - } - return body; -} - -export async function sendC2CMessage( - appId: string, - accessToken: string, - openid: string, - content: string, - msgId?: string, - messageReference?: string, -): Promise { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - const body = buildMessageBody(appId, content, msgId, msgSeq, messageReference); - return sendAndNotify(appId, accessToken, "POST", `/v2/users/${openid}/messages`, body, { - text: content, - }); -} - -export async function sendC2CInputNotify( - accessToken: string, - openid: string, - msgId?: string, - inputSecond: number = 60, -): Promise<{ refIdx?: string }> { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - const body = { - msg_type: 6, - input_notify: { - input_type: 1, - input_second: inputSecond, - }, - msg_seq: msgSeq, - ...(msgId ? { msg_id: msgId } : {}), - }; - const response = await apiRequest<{ ext_info?: { ref_idx?: string } }>( - accessToken, - "POST", - `/v2/users/${openid}/messages`, - body, - ); - return { refIdx: response.ext_info?.ref_idx }; -} - -export async function sendChannelMessage( - accessToken: string, - channelId: string, - content: string, - msgId?: string, -): Promise { - return apiRequest(accessToken, "POST", `/channels/${channelId}/messages`, { - content, - ...(msgId ? { msg_id: msgId } : {}), - }); -} - -/** Send a direct-message payload inside a guild DM session. */ -export async function sendDmMessage( - accessToken: string, - guildId: string, - content: string, - msgId?: string, -): Promise<{ id: string; timestamp: string }> { - return apiRequest(accessToken, "POST", `/dms/${guildId}/messages`, { - content, - ...(msgId ? { msg_id: msgId } : {}), - }); -} - -export async function sendGroupMessage( - appId: string, - accessToken: string, - groupOpenid: string, - content: string, - msgId?: string, -): Promise { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - const body = buildMessageBody(appId, content, msgId, msgSeq); - return sendAndNotify(appId, accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { - text: content, - }); -} - -function buildProactiveMessageBody(appId: string, content: string): Record { - if (!content || content.trim().length === 0) { - throw new Error("Proactive message content must not be empty (markdown.content is empty)"); - } - if (isMarkdownSupport(appId)) { - return { markdown: { content }, msg_type: 2 }; - } else { - return { content, msg_type: 0 }; - } -} - -export async function sendProactiveC2CMessage( - appId: string, - accessToken: string, - openid: string, - content: string, -): Promise { - const body = buildProactiveMessageBody(appId, content); - return sendAndNotify(appId, accessToken, "POST", `/v2/users/${openid}/messages`, body, { - text: content, - }); -} - -export async function sendProactiveGroupMessage( - appId: string, - accessToken: string, - groupOpenid: string, - content: string, -): Promise { - const body = buildProactiveMessageBody(appId, content); - return sendAndNotify(appId, accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { - text: content, - }); -} - -// Rich media message support. - -export enum MediaFileType { - IMAGE = 1, - VIDEO = 2, - VOICE = 3, - FILE = 4, -} - -export interface UploadMediaResponse { - file_uuid: string; - file_info: string; - ttl: number; - id?: string; -} - -async function assertDirectUploadUrlAllowed(url: string): Promise { - let parsed: URL; - try { - parsed = new URL(url); - } catch (err) { - throw new Error(`Invalid media URL: ${formatErrorMessage(err)}`, { cause: err }); - } - - if (parsed.protocol !== "https:") { - throw new Error("Direct-upload media URL must use HTTPS"); - } - - await resolvePinnedHostnameWithPolicy(parsed.hostname); - return parsed.toString(); -} - -export async function uploadC2CMedia( - accessToken: string, - openid: string, - fileType: MediaFileType, - url?: string, - fileData?: string, - srvSendMsg = false, - fileName?: string, -): Promise { - if (!url && !fileData) { - throw new Error("uploadC2CMedia: url or fileData is required"); - } - - if (fileData) { - const contentHash = computeFileHash(fileData); - const cachedInfo = getCachedFileInfo(contentHash, "c2c", openid, fileType); - if (cachedInfo) { - return { file_uuid: "", file_info: cachedInfo, ttl: 0 }; - } - } - - const body: Record = { file_type: fileType, srv_send_msg: srvSendMsg }; - if (url) { - body.url = await assertDirectUploadUrlAllowed(url); - } else if (fileData) { - body.file_data = fileData; - } - if (fileType === MediaFileType.FILE && fileName) { - body.file_name = sanitizeFileName(fileName); - } - - const result = await apiRequestWithRetry( - accessToken, - "POST", - `/v2/users/${openid}/files`, - body, - ); - - if (fileData && result.file_info && result.ttl > 0) { - const contentHash = computeFileHash(fileData); - setCachedFileInfo( - contentHash, - "c2c", - openid, - fileType, - result.file_info, - result.file_uuid, - result.ttl, - ); - } - return result; -} - -export async function uploadGroupMedia( - accessToken: string, - groupOpenid: string, - fileType: MediaFileType, - url?: string, - fileData?: string, - srvSendMsg = false, - fileName?: string, -): Promise { - if (!url && !fileData) { - throw new Error("uploadGroupMedia: url or fileData is required"); - } - - if (fileData) { - const contentHash = computeFileHash(fileData); - const cachedInfo = getCachedFileInfo(contentHash, "group", groupOpenid, fileType); - if (cachedInfo) { - return { file_uuid: "", file_info: cachedInfo, ttl: 0 }; - } - } - - const body: Record = { file_type: fileType, srv_send_msg: srvSendMsg }; - if (url) { - body.url = await assertDirectUploadUrlAllowed(url); - } else if (fileData) { - body.file_data = fileData; - } - if (fileType === MediaFileType.FILE && fileName) { - body.file_name = sanitizeFileName(fileName); - } - - const result = await apiRequestWithRetry( - accessToken, - "POST", - `/v2/groups/${groupOpenid}/files`, - body, - ); - - if (fileData && result.file_info && result.ttl > 0) { - const contentHash = computeFileHash(fileData); - setCachedFileInfo( - contentHash, - "group", - groupOpenid, - fileType, - result.file_info, - result.file_uuid, - result.ttl, - ); - } - return result; -} - -export async function sendC2CMediaMessage( - appId: string, - accessToken: string, - openid: string, - fileInfo: string, - msgId?: string, - content?: string, - meta?: OutboundMeta, -): Promise { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - return sendAndNotify( - appId, - accessToken, - "POST", - `/v2/users/${openid}/messages`, - { - msg_type: 7, - media: { file_info: fileInfo }, - msg_seq: msgSeq, - ...(content ? { content } : {}), - ...(msgId ? { msg_id: msgId } : {}), - }, - meta ?? { text: content }, - ); -} - -export async function sendGroupMediaMessage( - accessToken: string, - groupOpenid: string, - fileInfo: string, - msgId?: string, - content?: string, -): Promise<{ id: string; timestamp: string }> { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, { - msg_type: 7, - media: { file_info: fileInfo }, - msg_seq: msgSeq, - ...(content ? { content } : {}), - ...(msgId ? { msg_id: msgId } : {}), - }); -} - -export async function sendC2CImageMessage( - appId: string, - accessToken: string, - openid: string, - imageUrl: string, - msgId?: string, - content?: string, - localPath?: string, -): Promise { - let uploadResult: UploadMediaResponse; - const isBase64 = imageUrl.startsWith("data:"); - if (isBase64) { - const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/); - if (!matches) { - throw new Error("Invalid Base64 Data URL format"); - } - uploadResult = await uploadC2CMedia( - accessToken, - openid, - MediaFileType.IMAGE, - undefined, - matches[2], - false, - ); - } else { - uploadResult = await uploadC2CMedia( - accessToken, - openid, - MediaFileType.IMAGE, - imageUrl, - undefined, - false, - ); - } - const meta: OutboundMeta = { - text: content, - mediaType: "image", - ...(!isBase64 ? { mediaUrl: imageUrl } : {}), - ...(localPath ? { mediaLocalPath: localPath } : {}), - }; - return sendC2CMediaMessage( - appId, - accessToken, - openid, - uploadResult.file_info, - msgId, - content, - meta, - ); -} - -export async function sendGroupImageMessage( - appId: string, - accessToken: string, - groupOpenid: string, - imageUrl: string, - msgId?: string, - content?: string, -): Promise<{ id: string; timestamp: string }> { - let uploadResult: UploadMediaResponse; - const isBase64 = imageUrl.startsWith("data:"); - if (isBase64) { - const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/); - if (!matches) { - throw new Error("Invalid Base64 Data URL format"); - } - uploadResult = await uploadGroupMedia( - accessToken, - groupOpenid, - MediaFileType.IMAGE, - undefined, - matches[2], - false, - ); - } else { - uploadResult = await uploadGroupMedia( - accessToken, - groupOpenid, - MediaFileType.IMAGE, - imageUrl, - undefined, - false, - ); - } - return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content); -} - -export async function sendC2CVoiceMessage( - appId: string, - accessToken: string, - openid: string, - voiceBase64?: string, - voiceUrl?: string, - msgId?: string, - ttsText?: string, - filePath?: string, -): Promise { - const uploadResult = await uploadC2CMedia( - accessToken, - openid, - MediaFileType.VOICE, - voiceUrl, - voiceBase64, - false, - ); - return sendC2CMediaMessage(appId, accessToken, openid, uploadResult.file_info, msgId, undefined, { - mediaType: "voice", - ...(ttsText ? { ttsText } : {}), - ...(filePath ? { mediaLocalPath: filePath } : {}), - }); -} - -export async function sendGroupVoiceMessage( - appId: string, - accessToken: string, - groupOpenid: string, - voiceBase64?: string, - voiceUrl?: string, - msgId?: string, -): Promise<{ id: string; timestamp: string }> { - const uploadResult = await uploadGroupMedia( - accessToken, - groupOpenid, - MediaFileType.VOICE, - voiceUrl, - voiceBase64, - false, - ); - return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId); -} - -export async function sendC2CFileMessage( - appId: string, - accessToken: string, - openid: string, - fileBase64?: string, - fileUrl?: string, - msgId?: string, - fileName?: string, - localFilePath?: string, -): Promise { - const uploadResult = await uploadC2CMedia( - accessToken, - openid, - MediaFileType.FILE, - fileUrl, - fileBase64, - false, - fileName, - ); - return sendC2CMediaMessage(appId, accessToken, openid, uploadResult.file_info, msgId, undefined, { - mediaType: "file", - mediaUrl: fileUrl, - mediaLocalPath: localFilePath ?? fileName, - }); -} - -export async function sendGroupFileMessage( - appId: string, - accessToken: string, - groupOpenid: string, - fileBase64?: string, - fileUrl?: string, - msgId?: string, - fileName?: string, -): Promise<{ id: string; timestamp: string }> { - const uploadResult = await uploadGroupMedia( - accessToken, - groupOpenid, - MediaFileType.FILE, - fileUrl, - fileBase64, - false, - fileName, - ); - return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId); -} - -export async function sendC2CVideoMessage( - appId: string, - accessToken: string, - openid: string, - videoUrl?: string, - videoBase64?: string, - msgId?: string, - content?: string, - localPath?: string, -): Promise { - const uploadResult = await uploadC2CMedia( - accessToken, - openid, - MediaFileType.VIDEO, - videoUrl, - videoBase64, - false, - ); - return sendC2CMediaMessage(appId, accessToken, openid, uploadResult.file_info, msgId, content, { - text: content, - mediaType: "video", - ...(videoUrl ? { mediaUrl: videoUrl } : {}), - ...(localPath ? { mediaLocalPath: localPath } : {}), - }); -} - -export async function sendGroupVideoMessage( - appId: string, - accessToken: string, - groupOpenid: string, - videoUrl?: string, - videoBase64?: string, - msgId?: string, - content?: string, -): Promise<{ id: string; timestamp: string }> { - const uploadResult = await uploadGroupMedia( - accessToken, - groupOpenid, - MediaFileType.VIDEO, - videoUrl, - videoBase64, - false, - ); - return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content); -} - -// Background token refresh, isolated per appId. - -interface BackgroundTokenRefreshOptions { - refreshAheadMs?: number; - randomOffsetMs?: number; - minRefreshIntervalMs?: number; - retryDelayMs?: number; - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; -} - -const backgroundRefreshControllers = new Map(); - -export function startBackgroundTokenRefresh( - appId: string, - clientSecret: string, - options?: BackgroundTokenRefreshOptions, -): void { - if (backgroundRefreshControllers.has(appId)) { - debugLog(`[qqbot-api:${appId}] Background token refresh already running`); - return; - } - - const { - refreshAheadMs = 5 * 60 * 1000, - randomOffsetMs = 30 * 1000, - minRefreshIntervalMs = 60 * 1000, - retryDelayMs = 5 * 1000, - log, - } = options ?? {}; - - const controller = new AbortController(); - backgroundRefreshControllers.set(appId, controller); - const signal = controller.signal; - - const refreshLoop = async () => { - log?.info?.(`[qqbot-api:${appId}] Background token refresh started`); - - while (!signal.aborted) { - try { - await getAccessToken(appId, clientSecret); - const cached = tokenCacheMap.get(appId); - - if (cached) { - const expiresIn = cached.expiresAt - Date.now(); - const randomOffset = Math.random() * randomOffsetMs; - const refreshIn = Math.max( - expiresIn - refreshAheadMs - randomOffset, - minRefreshIntervalMs, - ); - - log?.debug?.( - `[qqbot-api:${appId}] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`, - ); - await sleep(refreshIn, signal); - } else { - log?.debug?.(`[qqbot-api:${appId}] No cached token, retrying soon`); - await sleep(minRefreshIntervalMs, signal); - } - } catch (err) { - if (signal.aborted) { - break; - } - log?.error?.(`[qqbot-api:${appId}] Background token refresh failed: ${String(err)}`); - await sleep(retryDelayMs, signal); - } - } - - backgroundRefreshControllers.delete(appId); - log?.info?.(`[qqbot-api:${appId}] Background token refresh stopped`); - }; - - refreshLoop().catch((err) => { - backgroundRefreshControllers.delete(appId); - log?.error?.(`[qqbot-api:${appId}] Background token refresh crashed: ${err}`); - }); -} - -/** - * Stop background token refresh. - * @param appId Optional appId to stop a single account instead of all refresh loops. - */ -export function stopBackgroundTokenRefresh(appId?: string): void { - if (appId) { - const controller = backgroundRefreshControllers.get(appId); - if (controller) { - controller.abort(); - backgroundRefreshControllers.delete(appId); - } - } else { - for (const controller of backgroundRefreshControllers.values()) { - controller.abort(); - } - backgroundRefreshControllers.clear(); - } -} - -export function isBackgroundTokenRefreshRunning(appId?: string): boolean { - if (appId) { - return backgroundRefreshControllers.has(appId); - } - return backgroundRefreshControllers.size > 0; -} - -async function sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(resolve, ms); - if (signal) { - if (signal.aborted) { - clearTimeout(timer); - reject(new Error("Aborted")); - return; - } - const onAbort = () => { - clearTimeout(timer); - reject(new Error("Aborted")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - } - }); -} diff --git a/extensions/qqbot/src/bridge/approval/capability.ts b/extensions/qqbot/src/bridge/approval/capability.ts new file mode 100644 index 00000000000..c1817d2a604 --- /dev/null +++ b/extensions/qqbot/src/bridge/approval/capability.ts @@ -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; +} diff --git a/extensions/qqbot/src/bridge/approval/handler-runtime.ts b/extensions/qqbot/src/bridge/approval/handler-runtime.ts new file mode 100644 index 00000000000..31bda7f2e2c --- /dev/null +++ b/extensions/qqbot/src/bridge/approval/handler-runtime.ts @@ -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; diff --git a/extensions/qqbot/src/bridge/bootstrap.ts b/extensions/qqbot/src/bridge/bootstrap.ts new file mode 100644 index 00000000000..1e9e225d2a7 --- /dev/null +++ b/extensions/qqbot/src/bridge/bootstrap.ts @@ -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 { + // Built-in version delegates SSRF validation to fetchRemoteMedia's ssrfPolicy. + }, + + async resolveSecret(value): Promise { + if (typeof value === "string") { + return value || undefined; + } + return undefined; + }, + + async downloadFile(url: string, destDir: string, filename?: string): Promise { + 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 { + 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 { + 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(); diff --git a/extensions/qqbot/src/bridge/channel-entry.ts b/extensions/qqbot/src/bridge/channel-entry.ts new file mode 100644 index 00000000000..dd1df5f8147 --- /dev/null +++ b/extensions/qqbot/src/bridge/channel-entry.ts @@ -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); +} diff --git a/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts b/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts new file mode 100644 index 00000000000..18b87fa07f7 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts @@ -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, + commandAuthorized: true, + queueSnapshot: { ...DEFAULT_QUEUE_SNAPSHOT }, + }; +} diff --git a/extensions/qqbot/src/bridge/commands/framework-registration.ts b/extensions/qqbot/src/bridge/commands/framework-registration.ts new file mode 100644 index 00000000000..62db4135039 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/framework-registration.ts @@ -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, + }); + }, + }); + } +} diff --git a/extensions/qqbot/src/bridge/commands/from-parser.test.ts b/extensions/qqbot/src/bridge/commands/from-parser.test.ts new file mode 100644 index 00000000000..0e9e5f0f4d5 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/from-parser.test.ts @@ -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", + }); + }); +}); diff --git a/extensions/qqbot/src/bridge/commands/from-parser.ts b/extensions/qqbot/src/bridge/commands/from-parser.ts new file mode 100644 index 00000000000..d0765183342 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/from-parser.ts @@ -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::` (case-insensitive + * prefix). We split that string once and map `` 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 = { + c2c: "c2c", + dm: "dm", + group: "group", + channel: "guild", +}; + +const TARGET_TYPE_MAP: Record = { + 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, + }; +} diff --git a/extensions/qqbot/src/bridge/commands/result-dispatcher.ts b/extensions/qqbot/src/bridge/commands/result-dispatcher.ts new file mode 100644 index 00000000000..0cdca024f21 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/result-dispatcher.ts @@ -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 { + 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 }; +} diff --git a/extensions/qqbot/src/channel-config-shared.ts b/extensions/qqbot/src/bridge/config-shared.ts similarity index 52% rename from extensions/qqbot/src/channel-config-shared.ts rename to extensions/qqbot/src/bridge/config-shared.ts index 6170a57006b..483c6d820c6 100644 --- a/extensions/qqbot/src/channel-config-shared.ts +++ b/extensions/qqbot/src/bridge/config-shared.ts @@ -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, + 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 | 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 = { diff --git a/extensions/qqbot/src/bridge/config.ts b/extensions/qqbot/src/bridge/config.ts new file mode 100644 index 00000000000..d8318322067 --- /dev/null +++ b/extensions/qqbot/src/bridge/config.ts @@ -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; + defaultAccount?: string; +} + + +/** List all configured QQBot account IDs. */ +export function listQQBotAccountIds(cfg: OpenClawConfig): string[] { + return listAccountIds(cfg as unknown as Record); +} + +/** Resolve the default QQBot account ID. */ +export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string { + return resolveDefaultAccountId(cfg as unknown as Record); +} + +/** 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; + 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, + accountId, + input, + ) as OpenClawConfig; +} diff --git a/extensions/qqbot/src/bridge/gateway.ts b/extensions/qqbot/src/bridge/gateway.ts new file mode 100644 index 00000000000..47302b9e361 --- /dev/null +++ b/extensions/qqbot/src/bridge/gateway.ts @@ -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; + writeConfigFile: (cfg: unknown) => Promise; + }, + }; +}); + +// 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 { + // 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}`), + }; +} diff --git a/extensions/qqbot/src/bridge/logger.ts b/extensions/qqbot/src/bridge/logger.ts new file mode 100644 index 00000000000..dbf40e3cdd1 --- /dev/null +++ b/extensions/qqbot/src/bridge/logger.ts @@ -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), + } + ); +} diff --git a/extensions/qqbot/src/bridge/plugin-version.test.ts b/extensions/qqbot/src/bridge/plugin-version.test.ts new file mode 100644 index 00000000000..d7f5c0cb4f9 --- /dev/null +++ b/extensions/qqbot/src/bridge/plugin-version.test.ts @@ -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"); + }); +}); diff --git a/extensions/qqbot/src/bridge/plugin-version.ts b/extensions/qqbot/src/bridge/plugin-version.ts new file mode 100644 index 00000000000..cb04fc82923 --- /dev/null +++ b/extensions/qqbot/src/bridge/plugin-version.ts @@ -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; +} diff --git a/extensions/qqbot/src/bridge/runtime.ts b/extensions/qqbot/src/bridge/runtime.ts new file mode 100644 index 00000000000..831a82e0585 --- /dev/null +++ b/extensions/qqbot/src/bridge/runtime.ts @@ -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({ + 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; +} diff --git a/extensions/qqbot/src/bridge/setup/finalize.ts b/extensions/qqbot/src/bridge/setup/finalize.ts new file mode 100644 index 00000000000..ad2d1f66247 --- /dev/null +++ b/extensions/qqbot/src/bridge/setup/finalize.ts @@ -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>[0]["prompter"]; +type SetupRuntime = Parameters>[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 { + return isQQBotAccountConfigured(cfg, accountId); +} + +async function linkViaQrCode(params: { + cfg: OpenClawConfig; + accountId: string; + prompter: SetupPrompter; + runtime: SetupRuntime; +}): Promise { + 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 { + 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 }; +} diff --git a/extensions/qqbot/src/bridge/setup/surface.ts b/extensions/qqbot/src/bridge/setup/surface.ts new file mode 100644 index 00000000000..aafdbf12ae3 --- /dev/null +++ b/extensions/qqbot/src/bridge/setup/surface.ts @@ -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), +}; diff --git a/extensions/qqbot/src/bridge/tools/channel.ts b/extensions/qqbot/src/bridge/tools/channel.ts new file mode 100644 index 00000000000..da3f25714e2 --- /dev/null +++ b/extensions/qqbot/src/bridge/tools/channel.ts @@ -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" }, + ); +} diff --git a/extensions/qqbot/src/bridge/tools/index.ts b/extensions/qqbot/src/bridge/tools/index.ts new file mode 100644 index 00000000000..d074c651eb1 --- /dev/null +++ b/extensions/qqbot/src/bridge/tools/index.ts @@ -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); +} diff --git a/extensions/qqbot/src/bridge/tools/remind.ts b/extensions/qqbot/src/bridge/tools/remind.ts new file mode 100644 index 00000000000..518046248c2 --- /dev/null +++ b/extensions/qqbot/src/bridge/tools/remind.ts @@ -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" }, + ); +} diff --git a/extensions/qqbot/src/tools/result.ts b/extensions/qqbot/src/bridge/tools/result.ts similarity index 100% rename from extensions/qqbot/src/tools/result.ts rename to extensions/qqbot/src/bridge/tools/result.ts diff --git a/extensions/qqbot/src/channel-base.ts b/extensions/qqbot/src/channel-base.ts deleted file mode 100644 index 940624c8602..00000000000 --- a/extensions/qqbot/src/channel-base.ts +++ /dev/null @@ -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> & { - id: "qqbot"; -}; diff --git a/extensions/qqbot/src/channel.setup.ts b/extensions/qqbot/src/channel.setup.ts index e90641627d5..01871afbb39 100644 --- a/extensions/qqbot/src/channel.setup.ts +++ b/extensions/qqbot/src/channel.setup.ts @@ -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 = { - ...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, + }, }; diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts index 0eeed94ee09..7bd2fe5e0b2 100644 --- a/extensions/qqbot/src/channel.ts +++ b/extensions/qqbot/src/channel.ts @@ -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 | undefined; -let _outboundModulePromise: Promise | undefined; - -function loadGatewayModule(): Promise { - _gatewayModulePromise ??= import("./gateway.js"); +let _gatewayModulePromise: Promise | undefined; +function loadGatewayModule(): Promise { + _gatewayModulePromise ??= import("./bridge/gateway.js"); return _gatewayModulePromise; } -function loadOutboundModule(): Promise { - _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 = { - ...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 = { 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 = { }; }, 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 = { }, 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; + }; + 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 = { 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 = { 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 = { }); }, 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, + accountId, + ); - if (nextQQBot) { - const qqbot = nextQQBot as Record; - 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> | undefined; - if (accounts && accountId in accounts) { - const entry = accounts[accountId] as Record | 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; }; - 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); diff --git a/extensions/qqbot/src/config-record-shared.ts b/extensions/qqbot/src/config-record-shared.ts deleted file mode 100644 index e91d30432be..00000000000 --- a/extensions/qqbot/src/config-record-shared.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { asOptionalObjectRecord, readStringField } from "openclaw/plugin-sdk/text-runtime"; - -export const asRecord = asOptionalObjectRecord; -export const readString = readStringField; diff --git a/extensions/qqbot/src/config-schema.ts b/extensions/qqbot/src/config-schema.ts index 6929f98c733..d6335e878e8 100644 --- a/extensions/qqbot/src/config-schema.ts +++ b/extensions/qqbot/src/config-schema.ts @@ -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(), diff --git a/extensions/qqbot/src/config.test.ts b/extensions/qqbot/src/config.test.ts index f6859b852ce..1a895dbd1bb 100644 --- a/extensions/qqbot/src/config.test.ts +++ b/extensions/qqbot/src/config.test.ts @@ -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 }; + + 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 }; + + 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({}); }); }); diff --git a/extensions/qqbot/src/config.ts b/extensions/qqbot/src/config.ts deleted file mode 100644 index 7e18728aa6b..00000000000 --- a/extensions/qqbot/src/config.ts +++ /dev/null @@ -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; - 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 { - 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(); - 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 | 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 | undefined), - enabled: true, - accounts: { - ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts, - [accountId]: { - ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId], - enabled: true, - allowFrom, - ...accountConfigPatch, - }, - }, - }, - }; - } - - return next; -} diff --git a/extensions/qqbot/src/engine/access/access-control.test.ts b/extensions/qqbot/src/engine/access/access-control.test.ts new file mode 100644 index 00000000000..9fd7f7db9c5 --- /dev/null +++ b/extensions/qqbot/src/engine/access/access-control.test.ts @@ -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"); + }); + }); +}); diff --git a/extensions/qqbot/src/engine/access/access-control.ts b/extensions/qqbot/src/engine/access/access-control.ts new file mode 100644 index 00000000000..0997e1e61a1 --- /dev/null +++ b/extensions/qqbot/src/engine/access/access-control.ts @@ -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, + }; +} diff --git a/extensions/qqbot/src/engine/access/index.ts b/extensions/qqbot/src/engine/access/index.ts new file mode 100644 index 00000000000..4f6b88e8d60 --- /dev/null +++ b/extensions/qqbot/src/engine/access/index.ts @@ -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"; diff --git a/extensions/qqbot/src/engine/access/resolve-policy.test.ts b/extensions/qqbot/src/engine/access/resolve-policy.test.ts new file mode 100644 index 00000000000..643babf4ed1 --- /dev/null +++ b/extensions/qqbot/src/engine/access/resolve-policy.test.ts @@ -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", + }); + }); + }); +}); diff --git a/extensions/qqbot/src/engine/access/resolve-policy.ts b/extensions/qqbot/src/engine/access/resolve-policy.ts new file mode 100644 index 00000000000..5d77ec6947c --- /dev/null +++ b/extensions/qqbot/src/engine/access/resolve-policy.ts @@ -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 | null; + groupAllowFrom?: Array | null; + dmPolicy?: QQBotDmPolicy | null; + groupPolicy?: QQBotGroupPolicy | null; +} + +function hasRealRestriction(list: Array | 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 }; +} diff --git a/extensions/qqbot/src/engine/access/sender-match.test.ts b/extensions/qqbot/src/engine/access/sender-match.test.ts new file mode 100644 index 00000000000..3c39ee0d8d4 --- /dev/null +++ b/extensions/qqbot/src/engine/access/sender-match.test.ts @@ -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); + }); +}); diff --git a/extensions/qqbot/src/engine/access/sender-match.ts b/extensions/qqbot/src/engine/access/sender-match.ts new file mode 100644 index 00000000000..9b0b5092572 --- /dev/null +++ b/extensions/qqbot/src/engine/access/sender-match.ts @@ -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 | 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); + }; +} diff --git a/extensions/qqbot/src/engine/access/types.ts b/extensions/qqbot/src/engine/access/types.ts new file mode 100644 index 00000000000..038ac5fb029 --- /dev/null +++ b/extensions/qqbot/src/engine/access/types.ts @@ -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; +} diff --git a/extensions/qqbot/src/engine/adapter/index.ts b/extensions/qqbot/src/engine/adapter/index.ts new file mode 100644 index 00000000000..c817a54c2b0 --- /dev/null +++ b/extensions/qqbot/src/engine/adapter/index.ts @@ -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; + + /** Resolve a secret value (SecretInput or plain string) to a plain string. */ + resolveSecret(value: string | SecretInputRef | undefined): Promise; + + /** Download a remote file to a local directory. Returns the local file path. */ + downloadFile(url: string, destDir: string, filename?: string): Promise; + + /** + * Fetch remote media with SSRF protection. + * Replaces direct usage of `fetchRemoteMedia` from `plugin-sdk/media-runtime`. + */ + fetchMedia(options: FetchMediaOptions): Promise; + + /** 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; +} + +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; +} diff --git a/extensions/qqbot/src/engine/adapter/types.ts b/extensions/qqbot/src/engine/adapter/types.ts new file mode 100644 index 00000000000..61e62f008be --- /dev/null +++ b/extensions/qqbot/src/engine/adapter/types.ts @@ -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; +} diff --git a/extensions/qqbot/src/engine/api/api-client.ts b/extensions/qqbot/src/engine/api/api-client.ts new file mode 100644 index 00000000000..2cbe9bd33fa --- /dev/null +++ b/extensions/qqbot/src/engine/api/api-client.ts @@ -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( + accessToken: string, + method: string, + path: string, + body?: unknown, + options?: RequestOptions, + ): Promise { + const url = `${this.baseUrl}${path}`; + + const headers: Record = { + 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) }; + for (const key of options?.redactBodyKeys ?? ["file_data"]) { + if (typeof logBody[key] === "string") { + logBody[key] = ``; + } + } + 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); + } + } +} diff --git a/extensions/qqbot/src/engine/api/media.ts b/extensions/qqbot/src/engine/api/media.ts new file mode 100644 index 00000000000..6b37f58db45 --- /dev/null +++ b/extensions/qqbot/src/engine/api/media.ts @@ -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 { + 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 = { + 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(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 { + 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(token, "POST", path, { + msg_type: 7, + media: { file_info: fileInfo }, + msg_seq: msgSeq, + ...(opts?.content ? { content: opts.content } : {}), + ...(opts?.msgId ? { msg_id: opts.msgId } : {}), + }); + } +} diff --git a/extensions/qqbot/src/engine/api/messages.ts b/extensions/qqbot/src/engine/api/messages.ts new file mode 100644 index 00000000000..f42f3a8fc60 --- /dev/null +++ b/extensions/qqbot/src/engine/api/messages.ts @@ -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 { + 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 { + 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 { + const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret); + return this.client.request(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 { + const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret); + return this.client.request(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 { + 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 { + 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 { + const result = await this.client.request(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 { + const body: Record = 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 { + 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"; diff --git a/extensions/qqbot/src/engine/api/retry.ts b/extensions/qqbot/src/engine/api/retry.ts new file mode 100644 index 00000000000..4b466fb03e1 --- /dev/null +++ b/extensions/qqbot/src/engine/api/retry.ts @@ -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( + fn: () => Promise, + policy: RetryPolicy, + persistentPolicy?: PersistentRetryPolicy, + logger?: EngineLogger, +): Promise { + 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( + fn: () => Promise, + policy: PersistentRetryPolicy, + logger?: EngineLogger, +): Promise { + 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 { + 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 = 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 = new Set([40093001]); + +/** upload_prepare error code indicating daily limit exceeded. */ +export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002; diff --git a/extensions/qqbot/src/engine/api/routes.ts b/extensions/qqbot/src/engine/api/routes.ts new file mode 100644 index 00000000000..6eaac4931f3 --- /dev/null +++ b/extensions/qqbot/src/engine/api/routes.ts @@ -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; +} diff --git a/extensions/qqbot/src/engine/api/token.ts b/extensions/qqbot/src/engine/api/token.ts new file mode 100644 index 00000000000..468cda3d46e --- /dev/null +++ b/extensions/qqbot/src/engine/api/token.ts @@ -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(); + private readonly fetchPromises = new Map>(); + private readonly refreshControllers = new Map(); + 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 { + 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 { + 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 { + 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 }); + }); + } +} diff --git a/extensions/qqbot/src/engine/approval/index.test.ts b/extensions/qqbot/src/engine/approval/index.test.ts new file mode 100644 index 00000000000..9b6ab01c8b4 --- /dev/null +++ b/extensions/qqbot/src/engine/approval/index.test.ts @@ -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"]); + }); +}); diff --git a/extensions/qqbot/src/engine/approval/index.ts b/extensions/qqbot/src/engine/approval/index.ts new file mode 100644 index 00000000000..de7ba594101 --- /dev/null +++ b/extensions/qqbot/src/engine/approval/index.ts @@ -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::` + * 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, + }; +} diff --git a/extensions/qqbot/src/engine/commands/slash-command-handler.ts b/extensions/qqbot/src/engine/commands/slash-command-handler.ts new file mode 100644 index 00000000000..19b4a2831da --- /dev/null +++ b/extensions/qqbot/src/engine/commands/slash-command-handler.ts @@ -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"; + } +} diff --git a/extensions/qqbot/src/engine/commands/slash-commands-impl.ts b/extensions/qqbot/src/engine/commands/slash-commands-impl.ts new file mode 100644 index 00000000000..055583ad490 --- /dev/null +++ b/extensions/qqbot/src/engine/commands/slash-commands-impl.ts @@ -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; +}): 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(` ${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(); + + 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([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(); + 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(); + + 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 无法再找回相关文件。`, + `‼️ 点击指令确认删除`, + ``, + ); + + 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; + writeConfigFile: (cfg: unknown) => Promise; + }; + }) + | null = null; + +/** Register the runtime getter — called by the outer layer during startup. */ +export function registerApproveRuntimeGetter( + getter: () => { + config: { + loadConfig: () => Record; + writeConfigFile: (cfg: unknown) => Promise; + }; + }, +): 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>; + 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; + const exec = (tools.exec ?? {}) as Record; + 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; + const exec = (tools.exec ?? {}) as Record; + 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 [ + `🔐 命令执行审批配置`, + ``, + ` 开启审批(白名单模式)`, + ` 关闭审批`, + ` 严格模式`, + ` 恢复默认`, + ` 查看当前配置`, + ].join("\n"); + } + + // status: 查看当前配置 + if (arg === "status") { + const { security, ask } = loadExecConfig(); + return [ + formatStatus(security, ask), + ``, + ` 开启审批`, + ` 关闭审批`, + ` 严格模式`, + ` 恢复默认`, + ].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; + const exec = (tools.exec ?? {}) as Record; + 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 { + 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): 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 !== "*"; + }); +} diff --git a/extensions/qqbot/src/engine/commands/slash-commands.ts b/extensions/qqbot/src/engine/commands/slash-commands.ts new file mode 100644 index 00000000000..022ca25929c --- /dev/null +++ b/extensions/qqbot/src/engine/commands/slash-commands.ts @@ -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; + /** 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; +} + +/** Framework command definition for commands that require authorization. */ +export interface QQBotFrameworkCommand { + name: string; + description: string; + usage?: string; + handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise; +} + +// ============ 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(); + private readonly frameworkCommands = new Map(); + + /** 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 { + return this.commands; + } + + /** Return all registered commands (both maps) for help listing. */ + getAllCommands(): Map { + const all = new Map(); + 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 { + 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); + } +} diff --git a/extensions/qqbot/src/engine/config/allow-from.ts b/extensions/qqbot/src/engine/config/allow-from.ts new file mode 100644 index 00000000000..79af4c72bda --- /dev/null +++ b/extensions/qqbot/src/engine/config/allow-from.ts @@ -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()); +} diff --git a/extensions/qqbot/src/engine/config/credential-backup.test.ts b/extensions/qqbot/src/engine/config/credential-backup.test.ts new file mode 100644 index 00000000000..49baa8765ba --- /dev/null +++ b/extensions/qqbot/src/engine/config/credential-backup.test.ts @@ -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); + }); +}); diff --git a/extensions/qqbot/src/engine/config/credential-backup.ts b/extensions/qqbot/src/engine/config/credential-backup.ts new file mode 100644 index 00000000000..c2ea60d4cc3 --- /dev/null +++ b/extensions/qqbot/src/engine/config/credential-backup.ts @@ -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-.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; +} diff --git a/extensions/qqbot/src/engine/config/credentials.ts b/extensions/qqbot/src/engine/config/credentials.ts new file mode 100644 index 00000000000..94fea6fa15e --- /dev/null +++ b/extensions/qqbot/src/engine/config/credentials.ts @@ -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) + * 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; + 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, + 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; + 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> | undefined; + if (accounts && accountId in accounts) { + const entry = accounts[accountId] as Record | 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, + accountId: string, + field: CredentialField, +): Record { + const next = { ...cfg }; + const channels = asRecord(cfg.channels); + const qqbot = { ...asRecord(channels?.qqbot) }; + + const clearField = (entry: Record) => { + 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> | undefined) }; + if (accounts[accountId]) { + const entry = { ...accounts[accountId] }; + clearField(entry); + accounts[accountId] = entry; + qqbot.accounts = accounts; + } + } + + next.channels = { ...channels, qqbot }; + return next; +} diff --git a/extensions/qqbot/src/engine/config/resolve.test.ts b/extensions/qqbot/src/engine/config/resolve.test.ts new file mode 100644 index 00000000000..42fd08ce1e2 --- /dev/null +++ b/extensions/qqbot/src/engine/config/resolve.test.ts @@ -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, + }); + }); +}); diff --git a/extensions/qqbot/src/engine/config/resolve.ts b/extensions/qqbot/src/engine/config/resolve.ts new file mode 100644 index 00000000000..15db5865e95 --- /dev/null +++ b/extensions/qqbot/src/engine/config/resolve.ts @@ -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>; + 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; +} + +function normalizeAppId(raw: unknown): string { + if (typeof raw === "string") { + return raw.trim(); + } + if (typeof raw === "number") { + return String(raw); + } + return ""; +} + +function normalizeAccountConfig( + account: Record | undefined, +): Record { + if (!account) { + return {}; + } + const audioPolicy = asRecord(account.audioFormatPolicy); + return { + ...account, + ...(audioPolicy ? { audioFormatPolicy: { ...audioPolicy } } : {}), + }; +} + +function readQQBotSection(cfg: Record): 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[] { + const ids = new Set(); + 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 { + 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, + accountId?: string | null, +): ResolvedAccountBase { + const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg); + const qqbot = readQQBotSection(cfg); + + let accountConfig: Record = {}; + 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, + accountId: string, + input: ApplyAccountInput, +): Record { + 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>; + 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 & { + 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 | 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()); +} diff --git a/extensions/qqbot/src/engine/config/setup-logic.ts b/extensions/qqbot/src/engine/config/setup-logic.ts new file mode 100644 index 00000000000..44a03254ca2 --- /dev/null +++ b/extensions/qqbot/src/engine/config/setup-logic.ts @@ -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, + accountId: string, + input: SetupInput, +): Record { + 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, + }); +} diff --git a/extensions/qqbot/src/engine/gateway/codec.ts b/extensions/qqbot/src/engine/gateway/codec.ts new file mode 100644 index 00000000000..d2a2c492d2b --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/codec.ts @@ -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[] | undefined { + if (!("message_scene" in event)) { + return undefined; + } + const scene = event.message_scene as { ext?: string[] } | undefined; + return scene?.ext; +} diff --git a/extensions/qqbot/src/engine/gateway/constants.ts b/extensions/qqbot/src/engine/gateway/constants.ts new file mode 100644 index 00000000000..e2f669fb9e4 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/constants.ts @@ -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; diff --git a/extensions/qqbot/src/engine/gateway/event-dispatcher.ts b/extensions/qqbot/src/engine/gateway/event-dispatcher.ts new file mode 100644 index 00000000000..2af25ef3636 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/event-dispatcher.ts @@ -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), + ); + 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), + ); + 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" }; +} diff --git a/extensions/qqbot/src/engine/gateway/gateway-connection.ts b/extensions/qqbot/src/engine/gateway/gateway-connection.ts new file mode 100644 index 00000000000..8060e03b58c --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/gateway-connection.ts @@ -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; + /** 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 | null = null; + private sessionId: string | null = null; + private lastSeq: number | null = null; + private isConnecting = false; + private reconnectTimer: ReturnType | 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 { + this.restoreSession(); + this.registerAbortHandler(); + await this.connect(); + return new Promise((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 { + 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 => { + 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); + } + } +} diff --git a/extensions/qqbot/src/engine/gateway/gateway.ts b/extensions/qqbot/src/engine/gateway/gateway.ts new file mode 100644 index 00000000000..a567509f341 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/gateway.ts @@ -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 { + 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 => { + 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}`); + } + }); + }; +} diff --git a/extensions/qqbot/src/inbound-attachments.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts similarity index 77% rename from extensions/qqbot/src/inbound-attachments.ts rename to extensions/qqbot/src/engine/gateway/inbound-attachments.ts index ab06282e751..2b39777489b 100644 --- a/extensions/qqbot/src/inbound-attachments.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts @@ -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 { const wavUrl = att.voice_wav_url ? att.voice_wav_url.startsWith("//") @@ -280,14 +294,12 @@ async function processVoiceAttachment( const sttCfg = resolveSTTConfig(cfg as Record); 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); 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 }; } diff --git a/extensions/qqbot/src/engine/gateway/inbound-context.ts b/extensions/qqbot/src/engine/gateway/inbound-context.ts new file mode 100644 index 00000000000..7e4358e6eb2 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/inbound-context.ts @@ -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; + }>; +} diff --git a/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts b/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts new file mode 100644 index 00000000000..b3c65d4599f --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/extensions/qqbot/src/message-queue.ts b/extensions/qqbot/src/engine/gateway/message-queue.ts similarity index 74% rename from extensions/qqbot/src/message-queue.ts rename to extensions/qqbot/src/engine/gateway/message-queue.ts index 098463ec8bf..c3de035387b 100644 --- a/extensions/qqbot/src/message-queue.ts +++ b/extensions/qqbot/src/engine/gateway/message-queue.ts @@ -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; @@ -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(); const activeUsers = new Set(); @@ -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 => { 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}`); }); } }; diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts new file mode 100644 index 00000000000..4909de7cef8 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts @@ -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 { + 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 = (sendFn: (token: string) => Promise) => + 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 | null = null; + let toolOnlyTimeoutId: ReturnType | null = null; + + // ---- Tool fallback ---- + const sendToolFallback = async (): Promise => { + 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((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((_, 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, + } + : {}), + }); +} diff --git a/extensions/qqbot/src/engine/gateway/reconnect.ts b/extensions/qqbot/src/engine/gateway/reconnect.ts new file mode 100644 index 00000000000..32d4fd50c0d --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/reconnect.ts @@ -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 = { + [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}`, + }; + } +} diff --git a/extensions/qqbot/src/engine/gateway/types.ts b/extensions/qqbot/src/engine/gateway/types.ts new file mode 100644 index 00000000000..a6662dc6118 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/types.ts @@ -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; + resolveEffectiveMessagesConfig: ( + cfg: unknown, + agentId?: string, + ) => { responsePrefix?: string }; + finalizeInboundContext: (fields: Record) => 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; +} diff --git a/extensions/qqbot/src/typing-keepalive.ts b/extensions/qqbot/src/engine/gateway/typing-keepalive.ts similarity index 58% rename from extensions/qqbot/src/typing-keepalive.ts rename to extensions/qqbot/src/engine/gateway/typing-keepalive.ts index 59d85cad761..c385651803b 100644 --- a/extensions/qqbot/src/typing-keepalive.ts +++ b/extensions/qqbot/src/engine/gateway/typing-keepalive.ts @@ -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; + +/** 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, 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 { 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)}`, ); } } diff --git a/extensions/qqbot/src/engine/group/deliver-debounce.ts b/extensions/qqbot/src/engine/group/deliver-debounce.ts new file mode 100644 index 00000000000..a3eb5c6edc3 --- /dev/null +++ b/extensions/qqbot/src/engine/group/deliver-debounce.ts @@ -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; + +interface PendingEntry { + texts: string[]; + mediaUrls: string[]; + timer: ReturnType; + 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(); + + 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 { + // 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((resolve) => { + const origResolve = existing.resolve; + existing.resolve = () => { + origResolve(); + resolve(); + }; + }); + } + + // First message in a new window — start buffering. + return new Promise((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 { + 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 { + const peerIds = [...this.pending.keys()]; + for (const peerId of peerIds) { + await this.flush(peerId, actualDeliver, info); + } + } +} diff --git a/extensions/qqbot/src/engine/group/message-gating.ts b/extensions/qqbot/src/engine/group/message-gating.ts new file mode 100644 index 00000000000..5379075ddde --- /dev/null +++ b/extensions/qqbot/src/engine/group/message-gating.ts @@ -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 | 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()); +} diff --git a/extensions/qqbot/src/engine/messaging/decode-media-path.ts b/extensions/qqbot/src/engine/messaging/decode-media-path.ts new file mode 100644 index 00000000000..bf5fa8cb452 --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/decode-media-path.ts @@ -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; +} diff --git a/extensions/qqbot/src/engine/messaging/media-type-detect.ts b/extensions/qqbot/src/engine/messaging/media-type-detect.ts new file mode 100644 index 00000000000..f7cc80f54a3 --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/media-type-detect.ts @@ -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 = { + 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 = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", +}; diff --git a/extensions/qqbot/src/outbound-deliver.ts b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts similarity index 53% rename from extensions/qqbot/src/outbound-deliver.ts rename to extensions/qqbot/src/engine/messaging/outbound-deliver.ts index ed2a43e7d63..db218e0362f 100644 --- a/extensions/qqbot/src/outbound-deliver.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts @@ -1,40 +1,79 @@ /** - * Outbound delivery helpers. + * Outbound delivery helpers — core/ version. * - * The gateway deliver callback uses two pipelines: - * 1. `parseAndSendMediaTags` handles `` tags in order. - * 2. `sendPlainReply` handles plain replies, including markdown images and mixed text/media. + * Uses the unified `sender.ts` business function layer for all text and + * image sending. Media sends (photo/voice/video/file) are injected via + * `DeliverDeps.mediaSender`. */ +import type { GatewayAccount } from "../types.js"; +import { formatErrorMessage } from "../utils/format.js"; +import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "../utils/image-size.js"; +import { normalizeMediaTags } from "../utils/media-tags.js"; +import { isLocalPath as isLocalFilePath } from "../utils/platform.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; +} from "../utils/string-normalize.js"; +import { filterInternalMarkers } from "../utils/text-parsing.js"; +import { decodeMediaPath } from "./decode-media-path.js"; import { - sendC2CMessage, - sendDmMessage, - sendGroupMessage, - sendChannelMessage, - sendC2CImageMessage, - sendGroupImageMessage, -} from "./api.js"; -import { - sendPhoto, - sendVoice, - sendVideoMsg, - sendDocument, - sendMedia as sendMediaAuto, - type MediaTargetContext, -} from "./outbound.js"; -import { getQQBotRuntime } from "./runtime.js"; -import { chunkText, TEXT_CHUNK_LIMIT } from "./text-utils.js"; -import type { ResolvedQQBotAccount } from "./types.js"; -import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js"; -import { normalizeMediaTags } from "./utils/media-tags.js"; -import { normalizePath, isLocalPath as isLocalFilePath } from "./utils/platform.js"; -import { filterInternalMarkers } from "./utils/text-parsing.js"; + sendText as senderSendText, + sendImage as senderSendImage, + withTokenRetry, + buildDeliveryTarget, + accountToCreds, +} from "./sender.js"; -// Type definitions. +// ---- Injected dependency interfaces ---- + +/** Media target context — describes where to send media. */ +export interface MediaTargetContext { + targetType: "c2c" | "group" | "channel" | "dm"; + targetId: string; + account: GatewayAccount; + replyToId?: string; +} + +/** Media send result. */ +export interface MediaSendResult { + channel?: string; + error?: string; + messageId?: string; +} + +/** Media sender interface — implemented by the upper-layer outbound.ts module. */ +export interface MediaSender { + sendPhoto(target: MediaTargetContext, imageUrl: string): Promise; + sendVoice( + target: MediaTargetContext, + voicePath: string, + uploadFormats?: string[], + transcodeEnabled?: boolean, + ): Promise; + sendVideoMsg(target: MediaTargetContext, videoPath: string): Promise; + sendDocument(target: MediaTargetContext, filePath: string): Promise; + sendMedia(opts: { + to: string; + text: string; + mediaUrl: string; + accountId: string; + replyToId: string; + account: GatewayAccount; + }): Promise; +} + +/** Delivery dependencies — injected when calling parseAndSendMediaTags / sendPlainReply. */ +export interface DeliverDeps { + mediaSender: MediaSender; + /** Text chunker — delegates to `runtime.channel.text.chunkMarkdownText`. */ + chunkText: (text: string, limit: number) => string[]; +} + +// ---- Exported types ---- + +/** Maximum text length for a single QQ Bot message. */ +export const TEXT_CHUNK_LIMIT = 5000; export interface DeliverEventContext { type: "c2c" | "guild" | "dm" | "group"; @@ -47,7 +86,7 @@ export interface DeliverEventContext { } export interface DeliverAccountContext { - account: ResolvedQQBotAccount; + account: GatewayAccount; qualifiedTarget: string; log?: { info: (msg: string) => void; @@ -62,34 +101,11 @@ export type SendWithRetryFn = (sendFn: (token: string) => Promise) => Prom /** Consume a quote ref exactly once. */ export type ConsumeQuoteRefFn = () => string | undefined; -type ReplyModeParams = { - textWithoutImages: string; - imageUrls: string[]; - mdMatches: RegExpMatchArray[]; - bareUrlMatches: RegExpMatchArray[]; - event: DeliverEventContext; - actx: DeliverAccountContext; - sendWithRetry: SendWithRetryFn; - consumeQuoteRef: ConsumeQuoteRefFn; -}; +// ---- Internal helpers ---- -function resolveReplyModeRuntime(params: ReplyModeParams) { - const { event, actx, sendWithRetry, consumeQuoteRef } = params; - const { account, log } = actx; - return { - event, - account, - log, - sendWithRetry, - consumeQuoteRef, - prefix: `[qqbot:${account.accountId}]`, - }; -} - -function resolveQQBotMediaTargetContext( +function resolveMediaTargetContext( event: DeliverEventContext, - account: ResolvedQQBotAccount, - prefix: string, + account: GatewayAccount, ): MediaTargetContext { return { targetType: @@ -110,15 +126,15 @@ function resolveQQBotMediaTargetContext( : event.channelId!, account, replyToId: event.messageId, - logPrefix: prefix, }; } -async function sendQQBotAutoMediaBatch(params: { +async function autoMediaBatch(params: { qualifiedTarget: string; - account: ResolvedQQBotAccount; + account: GatewayAccount; replyToId: string; mediaUrls: string[]; + mediaSender: MediaSender; log?: DeliverAccountContext["log"]; onResultError: (mediaUrl: string, error: string) => string; onThrownError: (mediaUrl: string, error: string) => string; @@ -126,7 +142,7 @@ async function sendQQBotAutoMediaBatch(params: { }): Promise { for (const mediaUrl of params.mediaUrls) { try { - const result = await sendMediaAuto({ + const result = await params.mediaSender.sendMedia({ to: params.qualifiedTarget, text: "", mediaUrl, @@ -143,12 +159,170 @@ async function sendQQBotAutoMediaBatch(params: { params.log?.info(successMessage); } } catch (err) { - params.log?.error(params.onThrownError(mediaUrl, String(err))); + params.log?.error(params.onThrownError(mediaUrl, formatErrorMessage(err))); } } } -// Media-tag parsing and delivery. +// ---- Text chunk sending ---- + +async function sendTextChunkToTarget(params: { + account: GatewayAccount; + event: DeliverEventContext; + token: string; + text: string; + consumeQuoteRef: ConsumeQuoteRefFn; + allowDm: boolean; +}): Promise { + const { account, event, text, consumeQuoteRef, allowDm } = params; + const ref = consumeQuoteRef(); + const target = buildDeliveryTarget(event); + if (target.type === "dm" && !allowDm) { + return undefined; + } + const creds = accountToCreds(account); + return await senderSendText(target, text, creds, { + msgId: event.messageId, + messageReference: ref, + }); +} + +async function sendTextChunks( + text: string, + event: DeliverEventContext, + actx: DeliverAccountContext, + sendWithRetry: SendWithRetryFn, + consumeQuoteRef: ConsumeQuoteRefFn, + deps: DeliverDeps, +): Promise { + const { account, log } = actx; + const chunks = deps.chunkText(text, TEXT_CHUNK_LIMIT); + await sendTextChunksWithRetry({ + account, + event, + chunks, + sendWithRetry, + consumeQuoteRef, + allowDm: true, + log, + onSuccess: (chunk) => + `Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`, + onError: (err) => `Failed to send text chunk: ${formatErrorMessage(err)}`, + }); +} + +async function sendTextChunksWithRetry(params: { + account: GatewayAccount; + event: DeliverEventContext; + chunks: string[]; + sendWithRetry: SendWithRetryFn; + consumeQuoteRef: ConsumeQuoteRefFn; + allowDm: boolean; + log?: DeliverAccountContext["log"]; + onSuccess: (chunk: string) => string; + onError: (err: unknown) => string; +}): Promise { + const { account, event, chunks, sendWithRetry, consumeQuoteRef, allowDm, log } = params; + for (const chunk of chunks) { + try { + await sendWithRetry((token) => + sendTextChunkToTarget({ + account, + event, + token, + text: chunk, + consumeQuoteRef, + allowDm, + }), + ); + log?.info(params.onSuccess(chunk)); + } catch (err) { + log?.error(params.onError(err)); + } + } +} + +// ---- Result logging helpers ---- + +async function sendWithResultLogging(params: { + run: () => Promise; + log?: DeliverAccountContext["log"]; + onSuccess?: () => string | undefined; + onError: (error: string) => string; +}): Promise { + try { + const result = await params.run(); + if (result.error) { + params.log?.error(params.onError(result.error)); + return; + } + const successMessage = params.onSuccess?.(); + if (successMessage) { + params.log?.info(successMessage); + } + } catch (err) { + params.log?.error(params.onError(formatErrorMessage(err))); + } +} + +async function sendPhotoWithLogging(params: { + target: MediaTargetContext; + imageUrl: string; + mediaSender: MediaSender; + log?: DeliverAccountContext["log"]; + onSuccess?: (imageUrl: string) => string | undefined; + onError: (error: string) => string; +}): Promise { + await sendWithResultLogging({ + run: async () => await params.mediaSender.sendPhoto(params.target, params.imageUrl), + log: params.log, + onSuccess: params.onSuccess ? () => params.onSuccess?.(params.imageUrl) : undefined, + onError: params.onError, + }); +} + +/** Send voice with a 45s timeout guard. */ +async function sendVoiceWithTimeout( + target: MediaTargetContext, + voicePath: string, + account: GatewayAccount, + mediaSender: MediaSender, + log: DeliverAccountContext["log"], +): Promise { + const uploadFormats = + account.config?.audioFormatPolicy?.uploadDirectFormats ?? + account.config?.voiceDirectUploadFormats; + const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false; + const voiceTimeout = 45_000; + const ac = new AbortController(); + try { + const result = await Promise.race([ + mediaSender.sendVoice(target, voicePath, uploadFormats, transcodeEnabled).then((r) => { + if (ac.signal.aborted) { + log?.debug?.(`sendVoice completed after timeout, suppressing late delivery`); + return { + channel: "qqbot", + error: "Voice send completed after timeout (suppressed)", + } as typeof r; + } + return r; + }), + new Promise<{ channel: string; error: string }>((resolve) => + setTimeout(() => { + ac.abort(); + resolve({ channel: "qqbot", error: "Voice send timed out and was skipped" }); + }, voiceTimeout), + ), + ]); + if (result.error) { + log?.error(`sendVoice error: ${result.error}`); + } + } catch (err) { + log?.error(`sendVoice unexpected error: ${formatErrorMessage(err)}`); + } +} + +// ============ Public API ============ /** * Parse media tags from the reply text and send them in order. @@ -162,11 +336,10 @@ export async function parseAndSendMediaTags( actx: DeliverAccountContext, sendWithRetry: SendWithRetryFn, consumeQuoteRef: ConsumeQuoteRefFn, + deps: DeliverDeps, ): Promise<{ handled: boolean; normalizedText: string }> { const { account, log } = actx; - const prefix = `[qqbot:${account.accountId}]`; - // Normalize common malformed tags produced by smaller models. const text = normalizeMediaTags(replyText); const mediaTagRegex = @@ -185,13 +358,12 @@ export async function parseAndSendMediaTags( }, {} as Record, ); - log?.info( - `${prefix} Detected media tags: ${Object.entries(tagCounts) + log?.debug?.( + `Detected media tags: ${Object.entries(tagCounts) .map(([k, v]) => `${v} <${k}>`) .join(", ")}`, ); - // Build a sequential send queue. type QueueItem = { type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string; @@ -213,7 +385,7 @@ export async function parseAndSendMediaTags( } const tagName = normalizeLowercaseStringOrEmpty(match[1]); - let mediaPath = decodeMediaPath(normalizeOptionalString(match[2]) ?? "", log, prefix); + const mediaPath = decodeMediaPath(normalizeOptionalString(match[2]) ?? "", log); if (mediaPath) { const typeMap: Record = { @@ -224,7 +396,7 @@ export async function parseAndSendMediaTags( }; const itemType = typeMap[tagName] ?? "image"; sendQueue.push({ type: itemType, content: mediaPath }); - log?.info(`${prefix} Found ${itemType} in <${tagName}>: ${mediaPath}`); + log?.debug?.(`Found ${itemType} in <${tagName}>: ${mediaPath}`); } lastIndex = match.index + match[0].length; @@ -238,39 +410,39 @@ export async function parseAndSendMediaTags( sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) }); } - log?.info(`${prefix} Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`); + log?.debug?.(`Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`); - // Send queue items in order. - const mediaTarget = resolveQQBotMediaTargetContext(event, account, prefix); + const mediaTarget = resolveMediaTargetContext(event, account); for (const item of sendQueue) { if (item.type === "text") { - await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef); + await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef, deps); } else if (item.type === "image") { - await sendQQBotPhotoWithLogging({ + await sendPhotoWithLogging({ target: mediaTarget, imageUrl: item.content, + mediaSender: deps.mediaSender, log, - onError: (error) => `${prefix} sendPhoto error: ${error}`, + onError: (error) => `sendPhoto error: ${error}`, }); } else if (item.type === "voice") { - await sendVoiceWithTimeout(mediaTarget, item.content, account, log, prefix); + await sendVoiceWithTimeout(mediaTarget, item.content, account, deps.mediaSender, log); } else if (item.type === "video") { - await sendQQBotResultWithLogging({ - run: async () => await sendVideoMsg(mediaTarget, item.content), + await sendWithResultLogging({ + run: async () => await deps.mediaSender.sendVideoMsg(mediaTarget, item.content), log, - onError: (error) => `${prefix} sendVideoMsg error: ${error}`, + onError: (error) => `sendVideoMsg error: ${error}`, }); } else if (item.type === "file") { - await sendQQBotResultWithLogging({ - run: async () => await sendDocument(mediaTarget, item.content), + await sendWithResultLogging({ + run: async () => await deps.mediaSender.sendDocument(mediaTarget, item.content), log, - onError: (error) => `${prefix} sendDocument error: ${error}`, + onError: (error) => `sendDocument error: ${error}`, }); } else if (item.type === "media") { - await sendQQBotResultWithLogging({ + await sendWithResultLogging({ run: async () => - await sendMediaAuto({ + await deps.mediaSender.sendMedia({ to: actx.qualifiedTarget, text: "", mediaUrl: item.content, @@ -279,7 +451,7 @@ export async function parseAndSendMediaTags( account, }), log, - onError: (error) => `${prefix} sendMedia(auto) error: ${error}`, + onError: (error) => `sendMedia(auto) error: ${error}`, }); } } @@ -287,7 +459,7 @@ export async function parseAndSendMediaTags( return { handled: true, normalizedText: text }; } -// Unstructured reply delivery for plain text and images. +// ---- Plain reply ---- export interface PlainReplyPayload { text?: string; @@ -307,9 +479,9 @@ export async function sendPlainReply( sendWithRetry: SendWithRetryFn, consumeQuoteRef: ConsumeQuoteRefFn, toolMediaUrls: string[], + deps: DeliverDeps, ): Promise { const { account, qualifiedTarget, log } = actx; - const prefix = `[qqbot:${account.accountId}]`; const collectedImageUrls: string[] = []; const localMediaToSend: string[] = []; @@ -323,8 +495,8 @@ export async function sendPlainReply( if (isHttpUrl || isDataUrl) { if (!collectedImageUrls.includes(url)) { collectedImageUrls.push(url); - log?.info( - `${prefix} Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : url.slice(0, 80) + "..."}`, + log?.debug?.( + `Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : url.slice(0, 80) + "..."}`, ); } return true; @@ -332,7 +504,7 @@ export async function sendPlainReply( if (isLocalFilePath(url)) { if (!localMediaToSend.includes(url)) { localMediaToSend.push(url); - log?.info(`${prefix} Collected local media for auto-routing: ${url}`); + log?.debug?.(`Collected local media for auto-routing: ${url}`); } return true; } @@ -356,11 +528,11 @@ export async function sendPlainReply( if (url && !collectedImageUrls.includes(url)) { if (url.startsWith("http://") || url.startsWith("https://")) { collectedImageUrls.push(url); - log?.info(`${prefix} Extracted HTTP image from markdown: ${url.slice(0, 80)}...`); + log?.debug?.(`Extracted HTTP image from markdown: ${url.slice(0, 80)}...`); } else if (isLocalFilePath(url)) { if (!localMediaToSend.includes(url)) { localMediaToSend.push(url); - log?.info(`${prefix} Collected local media from markdown for auto-routing: ${url}`); + log?.debug?.(`Collected local media from markdown for auto-routing: ${url}`); } } } @@ -374,17 +546,15 @@ export async function sendPlainReply( const url = m[1]; if (url && !collectedImageUrls.includes(url)) { collectedImageUrls.push(url); - log?.info(`${prefix} Extracted bare image URL: ${url.slice(0, 80)}...`); + log?.debug?.(`Extracted bare image URL: ${url.slice(0, 80)}...`); } } const useMarkdown = account.markdownSupport; - log?.info(`${prefix} Markdown mode: ${useMarkdown}, images: ${collectedImageUrls.length}`); + log?.debug?.(`Markdown mode: ${useMarkdown}, images: ${collectedImageUrls.length}`); let textWithoutImages = filterInternalMarkers(replyText); - // Strip markdown image tags that are neither HTTP URLs nor collected local paths - // to prevent leaking unresolvable paths (e.g. relative paths) to the user. for (const m of mdMatches) { const url = m[2]?.trim(); if (url && !url.startsWith("http://") && !url.startsWith("https://") && !isLocalFilePath(url)) { @@ -393,280 +563,82 @@ export async function sendPlainReply( } if (useMarkdown) { - await sendMarkdownReply({ + await sendMarkdownReply( textWithoutImages, - imageUrls: collectedImageUrls, + collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, - }); + deps, + ); } else { - await sendPlainTextReply({ + await sendPlainTextReply( textWithoutImages, - imageUrls: collectedImageUrls, + collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, - }); + deps, + ); } // Send local media collected from payload.mediaUrl or markdown local paths. if (localMediaToSend.length > 0) { - log?.info( - `${prefix} Sending ${localMediaToSend.length} local media via sendMedia auto-routing`, - ); - await sendQQBotAutoMediaBatch({ + log?.debug?.(`Sending ${localMediaToSend.length} local media via sendMedia auto-routing`); + await autoMediaBatch({ qualifiedTarget, account, replyToId: event.messageId, mediaUrls: localMediaToSend, + mediaSender: deps.mediaSender, log, - onSuccess: (mediaPath) => `${prefix} Sent local media: ${mediaPath}`, - onResultError: (mediaPath, error) => - `${prefix} sendMedia(auto) error for ${mediaPath}: ${error}`, - onThrownError: (mediaPath, error) => - `${prefix} sendMedia(auto) failed for ${mediaPath}: ${error}`, + onSuccess: (mediaPath) => `Sent local media: ${mediaPath}`, + onResultError: (mediaPath, error) => `sendMedia(auto) error for ${mediaPath}: ${error}`, + onThrownError: (mediaPath, error) => `sendMedia(auto) failed for ${mediaPath}: ${error}`, }); } // Forward media gathered during the tool phase. if (toolMediaUrls.length > 0) { - log?.info( - `${prefix} Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`, + log?.debug?.( + `Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`, ); - await sendQQBotAutoMediaBatch({ + await autoMediaBatch({ qualifiedTarget, account, replyToId: event.messageId, mediaUrls: toolMediaUrls, + mediaSender: deps.mediaSender, log, - onSuccess: (mediaUrl) => `${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`, - onResultError: (_mediaUrl, error) => `${prefix} Tool media forward error: ${error}`, - onThrownError: (_mediaUrl, error) => `${prefix} Tool media forward failed: ${error}`, + onSuccess: (mediaUrl) => `Forwarded tool media: ${mediaUrl.slice(0, 80)}...`, + onResultError: (_mediaUrl, error) => `Tool media forward error: ${error}`, + onThrownError: (_mediaUrl, error) => `Tool media forward failed: ${error}`, }); toolMediaUrls.length = 0; } } -// Internal helpers. +// ---- Markdown reply ---- -/** Decode a media path by stripping `MEDIA:`, expanding `~`, and unescaping. */ -function decodeMediaPath(raw: string, log: DeliverAccountContext["log"], prefix: string): 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?.(`${prefix} Decoding path with mixed encoding: ${mediaPath}`); - let 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?.(`${prefix} Successfully decoded path: ${mediaPath}`); - } - } - } catch (decodeErr) { - log?.error(`${prefix} Path decode error: ${String(decodeErr)}`); - } - - return mediaPath; -} - -/** Shared helper for sending chunked text replies. */ -async function sendQQBotTextChunk(params: { - account: ResolvedQQBotAccount; - event: DeliverEventContext; - token: string; - text: string; - consumeQuoteRef: ConsumeQuoteRefFn; - allowDm: boolean; -}): Promise { - const { account, event, token, text, consumeQuoteRef, allowDm } = params; - const ref = consumeQuoteRef(); - if (event.type === "c2c") { - return await sendC2CMessage(account.appId, token, event.senderId, text, event.messageId, ref); - } - if (event.type === "group" && event.groupOpenid) { - return await sendGroupMessage(account.appId, token, event.groupOpenid, text, event.messageId); - } - if (allowDm && event.type === "dm" && event.guildId) { - return await sendDmMessage(token, event.guildId, text, event.messageId); - } - if (event.channelId) { - return await sendChannelMessage(token, event.channelId, text, event.messageId); - } - return undefined; -} - -async function sendTextChunks( - text: string, +async function sendMarkdownReply( + textWithoutImages: string, + imageUrls: string[], + mdMatches: RegExpMatchArray[], + bareUrlMatches: RegExpMatchArray[], event: DeliverEventContext, actx: DeliverAccountContext, sendWithRetry: SendWithRetryFn, consumeQuoteRef: ConsumeQuoteRefFn, + deps: DeliverDeps, ): Promise { const { account, log } = actx; - const prefix = `[qqbot:${account.accountId}]`; - const chunks = getQQBotRuntime().channel.text.chunkMarkdownText(text, TEXT_CHUNK_LIMIT); - await sendQQBotTextChunksWithRetry({ - account, - event, - chunks, - sendWithRetry, - consumeQuoteRef, - allowDm: true, - log, - onSuccess: (chunk) => - `${prefix} Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`, - onError: (err) => `${prefix} Failed to send text chunk: ${String(err)}`, - }); -} -async function sendQQBotTextChunksWithRetry(params: { - account: ResolvedQQBotAccount; - event: DeliverEventContext; - chunks: string[]; - sendWithRetry: SendWithRetryFn; - consumeQuoteRef: ConsumeQuoteRefFn; - allowDm: boolean; - log?: DeliverAccountContext["log"]; - onSuccess: (chunk: string) => string; - onError: (err: unknown) => string; -}): Promise { - const { account, event, chunks, sendWithRetry, consumeQuoteRef, allowDm, log } = params; - for (const chunk of chunks) { - try { - await sendWithRetry((token) => - sendQQBotTextChunk({ - account, - event, - token, - text: chunk, - consumeQuoteRef, - allowDm, - }), - ); - log?.info(params.onSuccess(chunk)); - } catch (err) { - log?.error(params.onError(err)); - } - } -} - -async function sendQQBotResultWithLogging(params: { - run: () => Promise<{ error?: string }>; - log?: DeliverAccountContext["log"]; - onSuccess?: () => string | undefined; - onError: (error: string) => string; -}): Promise { - try { - const result = await params.run(); - if (result.error) { - params.log?.error(params.onError(result.error)); - return; - } - const successMessage = params.onSuccess?.(); - if (successMessage) { - params.log?.info(successMessage); - } - } catch (err) { - params.log?.error(params.onError(String(err))); - } -} - -async function sendQQBotPhotoWithLogging(params: { - target: MediaTargetContext; - imageUrl: string; - log?: DeliverAccountContext["log"]; - onSuccess?: (imageUrl: string) => string | undefined; - onError: (error: string) => string; -}): Promise { - await sendQQBotResultWithLogging({ - run: async () => await sendPhoto(params.target, params.imageUrl), - log: params.log, - onSuccess: params.onSuccess ? () => params.onSuccess?.(params.imageUrl) : undefined, - onError: params.onError, - }); -} - -/** Send voice with a 45s timeout guard. */ -async function sendVoiceWithTimeout( - target: MediaTargetContext, - voicePath: string, - account: ResolvedQQBotAccount, - log: DeliverAccountContext["log"], - prefix: string, -): Promise { - const uploadFormats = - account.config?.audioFormatPolicy?.uploadDirectFormats ?? - account.config?.voiceDirectUploadFormats; - const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false; - const voiceTimeout = 45000; - const ac = new AbortController(); - try { - const result = await Promise.race([ - sendVoice(target, voicePath, uploadFormats, transcodeEnabled).then((r) => { - if (ac.signal.aborted) { - log?.info(`${prefix} sendVoice completed after timeout, suppressing late delivery`); - return { - channel: "qqbot", - error: "Voice send completed after timeout (suppressed)", - } as typeof r; - } - return r; - }), - new Promise<{ channel: string; error: string }>((resolve) => - setTimeout(() => { - ac.abort(); - resolve({ channel: "qqbot", error: "Voice send timed out and was skipped" }); - }, voiceTimeout), - ), - ]); - if (result.error) { - log?.error(`${prefix} sendVoice error: ${result.error}`); - } - } catch (err) { - log?.error(`${prefix} sendVoice unexpected error: ${String(err)}`); - } -} - -/** Send in markdown mode. */ -async function sendMarkdownReply(params: ReplyModeParams): Promise { - const { textWithoutImages, imageUrls, mdMatches, bareUrlMatches } = params; - const { event, account, log, sendWithRetry, consumeQuoteRef, prefix } = - resolveReplyModeRuntime(params); - - // Split images into public URLs vs. Base64 payloads. const httpImageUrls: string[] = []; const base64ImageUrls: string[] = []; for (const url of imageUrls) { @@ -676,48 +648,32 @@ async function sendMarkdownReply(params: ReplyModeParams): Promise { httpImageUrls.push(url); } } - log?.info( - `${prefix} Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`, + log?.debug?.( + `Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`, ); - // Send Base64 images. + // Send Base64 images via Rich Media API. if (base64ImageUrls.length > 0) { - log?.info(`${prefix} Sending ${base64ImageUrls.length} image(s) via Rich Media API...`); + log?.debug?.(`Sending ${base64ImageUrls.length} image(s) via Rich Media API...`); for (const imageUrl of base64ImageUrls) { try { - await sendWithRetry(async (token) => { - if (event.type === "c2c") { - await sendC2CImageMessage( - account.appId, - token, - event.senderId, - imageUrl, - event.messageId, - ); - } else if (event.type === "group" && event.groupOpenid) { - await sendGroupImageMessage( - account.appId, - token, - event.groupOpenid, - imageUrl, - event.messageId, - ); - } else if (event.type === "dm" && event.guildId) { - log?.info(`${prefix} DM does not support rich media image, skipping Base64 image`); - } else if (event.channelId) { - log?.info(`${prefix} Channel does not support rich media, skipping Base64 image`); - } - }); - log?.info( - `${prefix} Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`, - ); + const target = buildDeliveryTarget(event); + const creds = accountToCreds(account); + if (target.type === "c2c" || target.type === "group") { + await withTokenRetry(creds, async () => { + await senderSendImage(target, imageUrl, creds, { msgId: event.messageId }); + }); + } else { + log?.debug?.(`${target.type} does not support rich media, skipping Base64 image`); + } + log?.debug?.(`Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`); } catch (imgErr) { - log?.error(`${prefix} Failed to send Base64 image via Rich Media API: ${String(imgErr)}`); + log?.error(`Failed to send Base64 image via Rich Media API: ${String(imgErr)}`); } } } - // Handle public image URLs. + // Handle public image URLs — format as markdown images with dimensions. const existingMdUrls = new Set(mdMatches.map((m) => m[2])); const imagesToAppend: string[] = []; @@ -726,11 +682,11 @@ async function sendMarkdownReply(params: ReplyModeParams): Promise { try { const size = await getImageSize(url); imagesToAppend.push(formatQQBotMarkdownImage(url, size)); - log?.info( - `${prefix} Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${url.slice(0, 60)}...`, + log?.debug?.( + `Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${url.slice(0, 60)}...`, ); } catch (err) { - log?.info(`${prefix} Failed to get image size, using default: ${String(err)}`); + log?.debug?.(`Failed to get image size, using default: ${formatErrorMessage(err)}`); imagesToAppend.push(formatQQBotMarkdownImage(url, null)); } } @@ -746,19 +702,19 @@ async function sendMarkdownReply(params: ReplyModeParams): Promise { try { const size = await getImageSize(imgUrl); result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size)); - log?.info( - `${prefix} Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${imgUrl.slice(0, 60)}...`, + log?.debug?.( + `Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${imgUrl.slice(0, 60)}...`, ); } catch (err) { - log?.info( - `${prefix} Failed to get image size for existing md, using default: ${String(err)}`, + log?.debug?.( + `Failed to get image size for existing md, using default: ${formatErrorMessage(err)}`, ); result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, null)); } } } - // Remove bare image URLs from the text body. + // Remove bare image URLs from text body. for (const m of bareUrlMatches) { result = result.replace(m[0], "").trim(); } @@ -771,8 +727,8 @@ async function sendMarkdownReply(params: ReplyModeParams): Promise { // Send markdown text. if (result.trim()) { - const mdChunks = chunkText(result, TEXT_CHUNK_LIMIT); - await sendQQBotTextChunksWithRetry({ + const mdChunks = deps.chunkText(result, TEXT_CHUNK_LIMIT); + await sendTextChunksWithRetry({ account, event, chunks: mdChunks, @@ -781,24 +737,34 @@ async function sendMarkdownReply(params: ReplyModeParams): Promise { allowDm: true, log, onSuccess: (chunk) => - `${prefix} Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`, - onError: (err) => `${prefix} Failed to send markdown message chunk: ${String(err)}`, + `Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`, + onError: (err) => `Failed to send markdown message chunk: ${formatErrorMessage(err)}`, }); } } -/** Send in plain-text mode. */ -async function sendPlainTextReply(params: ReplyModeParams): Promise { - const { event, account, log, sendWithRetry, consumeQuoteRef, prefix } = - resolveReplyModeRuntime(params); +// ---- Plain-text reply ---- - const imgMediaTarget = resolveQQBotMediaTargetContext(event, account, prefix); +async function sendPlainTextReply( + textWithoutImages: string, + imageUrls: string[], + mdMatches: RegExpMatchArray[], + bareUrlMatches: RegExpMatchArray[], + event: DeliverEventContext, + actx: DeliverAccountContext, + sendWithRetry: SendWithRetryFn, + consumeQuoteRef: ConsumeQuoteRefFn, + deps: DeliverDeps, +): Promise { + const { account, log } = actx; - let result = params.textWithoutImages; - for (const m of params.mdMatches) { + const imgMediaTarget = resolveMediaTargetContext(event, account); + + let result = textWithoutImages; + for (const m of mdMatches) { result = result.replace(m[0], "").trim(); } - for (const m of params.bareUrlMatches) { + for (const m of bareUrlMatches) { result = result.replace(m[0], "").trim(); } @@ -808,20 +774,20 @@ async function sendPlainTextReply(params: ReplyModeParams): Promise { } try { - for (const imageUrl of params.imageUrls) { - await sendQQBotPhotoWithLogging({ + for (const imageUrl of imageUrls) { + await sendPhotoWithLogging({ target: imgMediaTarget, imageUrl, + mediaSender: deps.mediaSender, log, - onSuccess: (nextImageUrl) => - `${prefix} Sent image via sendPhoto: ${nextImageUrl.slice(0, 80)}...`, - onError: (error) => `${prefix} Failed to send image: ${error}`, + onSuccess: (nextImageUrl) => `Sent image via sendPhoto: ${nextImageUrl.slice(0, 80)}...`, + onError: (error) => `Failed to send image: ${error}`, }); } if (result.trim()) { - const plainChunks = chunkText(result, TEXT_CHUNK_LIMIT); - await sendQQBotTextChunksWithRetry({ + const plainChunks = deps.chunkText(result, TEXT_CHUNK_LIMIT); + await sendTextChunksWithRetry({ account, event, chunks: plainChunks, @@ -830,11 +796,11 @@ async function sendPlainTextReply(params: ReplyModeParams): Promise { allowDm: false, log, onSuccess: (chunk) => - `${prefix} Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`, - onError: (err) => `${prefix} Send failed: ${String(err)}`, + `Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`, + onError: (err) => `Send failed: ${formatErrorMessage(err)}`, }); } } catch (err) { - log?.error(`${prefix} Send failed: ${String(err)}`); + log?.error(`Send failed: ${formatErrorMessage(err)}`); } } diff --git a/extensions/qqbot/src/outbound.ts b/extensions/qqbot/src/engine/messaging/outbound.ts similarity index 55% rename from extensions/qqbot/src/outbound.ts rename to extensions/qqbot/src/engine/messaging/outbound.ts index ce411aab1a9..929ced9be44 100644 --- a/extensions/qqbot/src/outbound.ts +++ b/extensions/qqbot/src/engine/messaging/outbound.ts @@ -1,168 +1,135 @@ import * as fs from "node:fs"; import * as path from "path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { formatErrorMessage } from "../utils/format.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; -import { - getAccessToken, - sendC2CFileMessage, - sendC2CImageMessage, - sendC2CMessage, - sendC2CVideoMessage, - sendC2CVoiceMessage, - sendChannelMessage, - sendDmMessage, - sendGroupFileMessage, - sendGroupImageMessage, - sendGroupMessage, - sendGroupVideoMessage, - sendGroupVoiceMessage, - sendProactiveC2CMessage, - sendProactiveGroupMessage, -} from "./api.js"; -import type { ResolvedQQBotAccount } from "./types.js"; -import { - audioFileToSilkBase64, - isAudioFile, - shouldTranscodeVoice, - waitForFile, -} from "./utils/audio-convert.js"; -import { debugError, debugLog, debugWarn } from "./utils/debug-log.js"; +} from "../utils/string-normalize.js"; + +// ---- Injected audio-convert dependencies ---- + +/** Audio conversion interface — implemented by the upper-layer audio-convert module. */ +export interface OutboundAudioAdapter { + audioFileToSilkBase64( + audioPath: string, + directUploadFormats?: string[], + ): Promise; + isAudioFile(pathOrUrl: string, mimeType?: string): boolean; + shouldTranscodeVoice(filePath: string): boolean; + waitForFile(filePath: string, maxWaitMs?: number): Promise; +} + +let _audioAdapter: OutboundAudioAdapter | null = null; +let _audioAdapterFactory: (() => OutboundAudioAdapter) | null = null; + +/** Register the audio conversion adapter — called by gateway startup. */ +export function registerOutboundAudioAdapter(adapter: OutboundAudioAdapter): void { + _audioAdapter = adapter; +} + +/** Register a factory that creates the adapter on first access (lazy init). */ +export function registerOutboundAudioAdapterFactory(factory: () => OutboundAudioAdapter): void { + _audioAdapterFactory = factory; +} + +function getAudio(): OutboundAudioAdapter { + if (!_audioAdapter && _audioAdapterFactory) { + _audioAdapter = _audioAdapterFactory(); + } + if (!_audioAdapter) { + throw new Error("OutboundAudioAdapter not registered"); + } + return _audioAdapter; +} + +// Re-alias for use in the file. +function audioFileToSilkBase64(p: string, f?: string[]): Promise { + return getAudio().audioFileToSilkBase64(p, f); +} +function isAudioFile(p: string, m?: string): boolean { + // Safe to return false when adapter is unavailable — this is a type-check + // function called by sendMedia's dispatch logic before any audio processing. + try { + return getAudio().isAudioFile(p, m); + } catch { + return false; + } +} +function shouldTranscodeVoice(p: string): boolean { + return getAudio().shouldTranscodeVoice(p); +} +function waitForFile(p: string, ms?: number): Promise { + return getAudio().waitForFile(p, ms); +} +import type { GatewayAccount } from "../types.js"; import { checkFileSize, downloadFile, fileExistsAsync, formatFileSize, readFileAsync, -} from "./utils/file-utils.js"; -import { normalizeMediaTags } from "./utils/media-tags.js"; -import { decodeCronPayload } from "./utils/payload.js"; +} from "../utils/file-utils.js"; +import { debugError, debugLog, debugWarn } from "../utils/log.js"; +import { normalizeMediaTags } from "../utils/media-tags.js"; +import { decodeCronPayload } from "../utils/payload.js"; import { getQQBotDataDir, getQQBotMediaDir, isLocalPath as isLocalFilePath, normalizePath, resolveQQBotPayloadLocalFilePath, - sanitizeFileName, -} from "./utils/platform.js"; +} from "../utils/platform.js"; +import { sanitizeFileName } from "../utils/string-normalize.js"; +import { + isImageFile as coreIsImageFile, + isVideoFile as coreIsVideoFile, +} from "./media-type-detect.js"; +// Bridge to core/ modules — use the canonical implementations from the core +// package so the same logic can be shared with the standalone version. +import { ReplyLimiter, type ReplyLimitResult } from "./reply-limiter.js"; +import { + sendText as senderSendText, + sendImage as senderSendImage, + sendVoiceMessage as senderSendVoice, + sendVideoMessage as senderSendVideo, + sendFileMessage as senderSendFile, + initApiConfig, + accountToCreds, + type DeliveryTarget, +} from "./sender.js"; +import { parseTarget as coreParseTarget } from "./target-parser.js"; + +// Module-level reply limiter instance (replaces the old Map-based tracker). +const replyLimiter = new ReplyLimiter(); // Limit passive replies per message_id within the QQ Bot reply window. +// Delegated to core/messaging/reply-limiter.ts for cross-version sharing. const MESSAGE_REPLY_LIMIT = 4; -const MESSAGE_REPLY_TTL = 60 * 60 * 1000; - -interface MessageReplyRecord { - count: number; - firstReplyAt: number; -} - -type QQMessageResult = { - ext_info?: { - ref_idx?: string; - }; -}; - -const messageReplyTracker = new Map(); - -function getRefIdx(result: QQMessageResult): string | undefined { - return result.ext_info?.ref_idx; -} /** Result of the passive-reply limit check. */ -export interface ReplyLimitResult { - allowed: boolean; - remaining: number; - shouldFallbackToProactive: boolean; - fallbackReason?: "expired" | "limit_exceeded"; - message?: string; -} +export type { ReplyLimitResult }; /** Check whether a message can still receive a passive reply. */ export function checkMessageReplyLimit(messageId: string): ReplyLimitResult { - const now = Date.now(); - const record = messageReplyTracker.get(messageId); - - // Opportunistically evict expired records to keep the tracker bounded. - if (messageReplyTracker.size > 10000) { - for (const [id, rec] of messageReplyTracker) { - if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) { - messageReplyTracker.delete(id); - } - } - } - - if (!record) { - return { - allowed: true, - remaining: MESSAGE_REPLY_LIMIT, - shouldFallbackToProactive: false, - }; - } - - if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) { - return { - allowed: false, - remaining: 0, - shouldFallbackToProactive: true, - fallbackReason: "expired", - message: "Message is older than 1 hour; sending as a proactive message instead", - }; - } - - const remaining = MESSAGE_REPLY_LIMIT - record.count; - if (remaining <= 0) { - return { - allowed: false, - remaining: 0, - shouldFallbackToProactive: true, - fallbackReason: "limit_exceeded", - message: `Passive reply limit reached (${MESSAGE_REPLY_LIMIT} per hour); sending proactively instead`, - }; - } - - return { - allowed: true, - remaining, - shouldFallbackToProactive: false, - }; + return replyLimiter.checkLimit(messageId); } /** Record one passive reply against a message. */ export function recordMessageReply(messageId: string): void { - const now = Date.now(); - const record = messageReplyTracker.get(messageId); - - if (!record) { - messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now }); - } else { - if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) { - messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now }); - } else { - record.count++; - } - } + replyLimiter.record(messageId); debugLog( - `[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`, + `[qqbot] recordMessageReply: ${messageId}, count=${replyLimiter.getStats().totalReplies}`, ); } /** Return reply-tracker stats for diagnostics. */ export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } { - let totalReplies = 0; - for (const record of messageReplyTracker.values()) { - totalReplies += record.count; - } - return { trackedMessages: messageReplyTracker.size, totalReplies }; + return replyLimiter.getStats(); } /** Return the passive-reply configuration. */ export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } { - return { - limit: MESSAGE_REPLY_LIMIT, - ttlMs: MESSAGE_REPLY_TTL, - ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000), - }; + return replyLimiter.getConfig(); } export interface OutboundContext { @@ -170,7 +137,7 @@ export interface OutboundContext { text: string; accountId?: string | null; replyToId?: string | null; - account: ResolvedQQBotAccount; + account: GatewayAccount; } export interface MediaOutboundContext extends OutboundContext { @@ -190,50 +157,9 @@ export interface OutboundResult { function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } { const timestamp = new Date().toISOString(); debugLog(`[${timestamp}] [qqbot] parseTarget: input=${to}`); - - let id = to.replace(/^qqbot:/i, ""); - - if (id.startsWith("c2c:")) { - const userId = id.slice(4); - if (!userId || userId.length === 0) { - const error = `Invalid c2c target format: ${to} - missing user ID`; - debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`); - throw new Error(error); - } - debugLog(`[${timestamp}] [qqbot] parseTarget: c2c target, user ID=${userId}`); - return { type: "c2c", id: userId }; - } - - if (id.startsWith("group:")) { - const groupId = id.slice(6); - if (!groupId || groupId.length === 0) { - const error = `Invalid group target format: ${to} - missing group ID`; - debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`); - throw new Error(error); - } - debugLog(`[${timestamp}] [qqbot] parseTarget: group target, group ID=${groupId}`); - return { type: "group", id: groupId }; - } - - if (id.startsWith("channel:")) { - const channelId = id.slice(8); - if (!channelId || channelId.length === 0) { - const error = `Invalid channel target format: ${to} - missing channel ID`; - debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`); - throw new Error(error); - } - debugLog(`[${timestamp}] [qqbot] parseTarget: channel target, channel ID=${channelId}`); - return { type: "channel", id: channelId }; - } - - if (!id || id.length === 0) { - const error = `Invalid target format: ${to} - empty ID after removing qqbot: prefix`; - debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`); - throw new Error(error); - } - - debugLog(`[${timestamp}] [qqbot] parseTarget: default c2c target, ID=${id}`); - return { type: "c2c", id }; + const parsed = coreParseTarget(to); + debugLog(`[${timestamp}] [qqbot] parseTarget: ${parsed.type} target, ID=${parsed.id}`); + return parsed; } // Structured media send helpers shared by gateway delivery and sendText. @@ -242,36 +168,27 @@ function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: strin export interface MediaTargetContext { targetType: "c2c" | "group" | "channel" | "dm"; targetId: string; - account: ResolvedQQBotAccount; + account: GatewayAccount; replyToId?: string; - logPrefix?: string; } /** Build a media target from a normal outbound context. */ -function buildMediaTarget( - ctx: { to: string; account: ResolvedQQBotAccount; replyToId?: string | null }, - logPrefix?: string, -): MediaTargetContext { +function buildMediaTarget(ctx: { + to: string; + account: GatewayAccount; + replyToId?: string | null; +}): MediaTargetContext { const target = parseTarget(ctx.to); return { targetType: target.type, targetId: target.id, account: ctx.account, replyToId: ctx.replyToId ?? undefined, - logPrefix, }; } -/** Resolve an authenticated access token for the account. */ -async function getToken(account: ResolvedQQBotAccount): Promise { - if (!account.appId || !account.clientSecret) { - throw new Error("QQBot not configured (missing appId or clientSecret)"); - } - return getAccessToken(account.appId, account.clientSecret); -} - /** Return true when public URLs should be passed through directly. */ -function shouldDirectUploadUrl(account: ResolvedQQBotAccount): boolean { +function shouldDirectUploadUrl(account: GatewayAccount): boolean { return account.config?.urlDirectUpload !== false; } @@ -379,7 +296,6 @@ function resolveExistingPathWithinRoots( function resolveOutboundMediaPath( rawPath: string, - prefix: string, mediaKind: QQBotMediaKind, options: ResolveOutboundMediaPathOptions = {}, ): ResolvedOutboundMediaPath { @@ -410,7 +326,7 @@ function resolveOutboundMediaPath( } } - debugWarn(`${prefix} blocked local ${mediaKind} path outside QQ Bot media storage`); + debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`); return { ok: false, error: `${qqBotMediaKindLabel[mediaKind]} path must be inside QQ Bot media storage`, @@ -424,8 +340,7 @@ export async function sendPhoto( ctx: MediaTargetContext, imagePath: string, ): Promise { - const prefix = ctx.logPrefix ?? "[qqbot]"; - const resolvedMediaPath = resolveOutboundMediaPath(imagePath, prefix, "image"); + const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image"); if (!resolvedMediaPath.ok) { return { channel: "qqbot", error: resolvedMediaPath.error }; } @@ -436,8 +351,8 @@ export async function sendPhoto( // Force a local download before upload when direct URL upload is disabled. if (isHttp && !shouldDirectUploadUrl(ctx.account)) { - debugLog(`${prefix} sendPhoto: urlDirectUpload=false, downloading URL first...`); - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendPhoto"); + debugLog(`sendPhoto: urlDirectUpload=false, downloading URL first...`); + const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto"); if (localFile) { return await sendPhoto(ctx, localFile); } @@ -469,42 +384,31 @@ export async function sendPhoto( return { channel: "qqbot", error: `Unsupported image format: ${ext}` }; } imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`; - debugLog(`${prefix} sendPhoto: local → Base64 (${formatFileSize(fileBuffer.length)})`); + debugLog(`sendPhoto: local → Base64 (${formatFileSize(fileBuffer.length)})`); } else if (!isHttp && !isData) { return { channel: "qqbot", error: `Unsupported image source: ${mediaPath.slice(0, 50)}` }; } try { - const token = await getToken(ctx.account); const localPath = isLocal ? mediaPath : undefined; + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - if (ctx.targetType === "c2c") { - const r = await sendC2CImageMessage( - ctx.account.appId, - token, - ctx.targetId, - imageUrl, - ctx.replyToId, - undefined, + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendImage(target, imageUrl, creds, { + msgId: ctx.replyToId, + content: undefined, localPath, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupImageMessage( - ctx.account.appId, - token, - ctx.targetId, - imageUrl, - ctx.replyToId, - ); + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - // Channel messages only support public URLs through markdown. if (isHttp) { - const r = await sendChannelMessage(token, ctx.targetId, `![](${mediaPath})`, ctx.replyToId); + const r = await senderSendText(target, `![](${mediaPath})`, creds, { + msgId: ctx.replyToId, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } - debugLog(`${prefix} sendPhoto: channel does not support local/Base64 images`); + debugLog(`sendPhoto: channel does not support local/Base64 images`); return { channel: "qqbot", error: "Channel does not support local/Base64 images" }; } } catch (err) { @@ -513,15 +417,15 @@ export async function sendPhoto( // Fall back to plugin-managed download + Base64 when QQ fails to fetch the URL directly. if (isHttp && !isData) { debugWarn( - `${prefix} sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, + `sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, ); - const retryResult = await downloadAndRetrySendPhoto(ctx, mediaPath, prefix); + const retryResult = await downloadAndRetrySendPhoto(ctx, mediaPath); if (retryResult) { return retryResult; } } - debugError(`${prefix} sendPhoto failed: ${msg}`); + debugError(`sendPhoto failed: ${msg}`); return { channel: "qqbot", error: msg }; } } @@ -530,20 +434,19 @@ export async function sendPhoto( async function downloadAndRetrySendPhoto( ctx: MediaTargetContext, httpUrl: string, - prefix: string, ): Promise { try { const downloadDir = getQQBotMediaDir("downloads", "url-fallback"); const localFile = await downloadFile(httpUrl, downloadDir); if (!localFile) { - debugError(`${prefix} sendPhoto fallback: download also failed for ${httpUrl.slice(0, 80)}`); + debugError(`sendPhoto fallback: download also failed for ${httpUrl.slice(0, 80)}`); return null; } - debugLog(`${prefix} sendPhoto fallback: downloaded → ${localFile}, retrying as Base64`); + debugLog(`sendPhoto fallback: downloaded → ${localFile}, retrying as Base64`); return await sendPhoto(ctx, localFile); } catch (err) { - debugError(`${prefix} sendPhoto fallback error:`, err); + debugError(`sendPhoto fallback error:`, err); return null; } } @@ -559,8 +462,7 @@ export async function sendVoice( directUploadFormats?: string[], transcodeEnabled: boolean = true, ): Promise { - const prefix = ctx.logPrefix ?? "[qqbot]"; - const resolvedMediaPath = resolveOutboundMediaPath(voicePath, prefix, "voice", { + const resolvedMediaPath = resolveOutboundMediaPath(voicePath, "voice", { allowMissingLocalPath: true, }); if (!resolvedMediaPath.ok) { @@ -572,55 +474,36 @@ export async function sendVoice( if (isHttp) { if (shouldDirectUploadUrl(ctx.account)) { try { - const token = await getToken(ctx.account); - if (ctx.targetType === "c2c") { - const r = await sendC2CVoiceMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, - mediaPath, - ctx.replyToId, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupVoiceMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, - mediaPath, - ctx.replyToId, - ); + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendVoice(target, creds, { + voiceUrl: mediaPath, + msgId: ctx.replyToId, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendVoice: voice not supported in channel`); + debugLog(`sendVoice: voice not supported in channel`); return { channel: "qqbot", error: "Voice not supported in channel" }; } } catch (err) { const msg = formatErrorMessage(err); debugWarn( - `${prefix} sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`, + `sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`, ); } } else { - debugLog(`${prefix} sendVoice: urlDirectUpload=false, downloading URL first...`); + debugLog(`sendVoice: urlDirectUpload=false, downloading URL first...`); } - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVoice"); + const localFile = await downloadToFallbackDir(mediaPath, "sendVoice"); if (localFile) { - return await sendVoiceFromLocal( - ctx, - localFile, - directUploadFormats, - transcodeEnabled, - prefix, - ); + return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled); } return { channel: "qqbot", error: `Failed to download audio: ${mediaPath.slice(0, 80)}` }; } - return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled, prefix); + return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled); } /** Send voice from a local file. */ @@ -629,7 +512,6 @@ async function sendVoiceFromLocal( mediaPath: string, directUploadFormats: string[] | undefined, transcodeEnabled: boolean, - prefix: string, ): Promise { // TTS can still be flushing the file to disk, so wait for a stable file first. const fileSize = await waitForFile(mediaPath); @@ -640,7 +522,7 @@ async function sendVoiceFromLocal( // Re-check containment after the file appears to prevent symlink-race escapes. const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath); if (!safeMediaPath) { - debugWarn(`${prefix} sendVoice: blocked local voice path outside QQ Bot media storage`); + debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`); return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" }; } @@ -649,7 +531,7 @@ async function sendVoiceFromLocal( if (needsTranscode && !transcodeEnabled) { const ext = normalizeLowercaseStringOrEmpty(path.extname(safeMediaPath)); debugLog( - `${prefix} sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`, + `sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`, ); return { channel: "qqbot", @@ -664,44 +546,28 @@ async function sendVoiceFromLocal( if (!uploadBase64) { const buf = await readFileAsync(safeMediaPath); uploadBase64 = buf.toString("base64"); - debugLog( - `${prefix} sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`, - ); + debugLog(`sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`); } else { - debugLog(`${prefix} sendVoice: SILK ready (${fileSize} bytes)`); + debugLog(`sendVoice: SILK ready (${fileSize} bytes)`); } - const token = await getToken(ctx.account); + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - if (ctx.targetType === "c2c") { - const r = await sendC2CVoiceMessage( - ctx.account.appId, - token, - ctx.targetId, - uploadBase64, - undefined, - ctx.replyToId, - undefined, - safeMediaPath, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupVoiceMessage( - ctx.account.appId, - token, - ctx.targetId, - uploadBase64, - undefined, - ctx.replyToId, - ); + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendVoice(target, creds, { + voiceBase64: uploadBase64, + msgId: ctx.replyToId, + filePath: safeMediaPath, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendVoice: voice not supported in channel`); + debugLog(`sendVoice: voice not supported in channel`); return { channel: "qqbot", error: "Voice not supported in channel" }; } } catch (err) { const msg = formatErrorMessage(err); - debugError(`${prefix} sendVoice (local) failed: ${msg}`); + debugError(`sendVoice (local) failed: ${msg}`); return { channel: "qqbot", error: msg }; } } @@ -711,8 +577,7 @@ export async function sendVideoMsg( ctx: MediaTargetContext, videoPath: string, ): Promise { - const prefix = ctx.logPrefix ?? "[qqbot]"; - const resolvedMediaPath = resolveOutboundMediaPath(videoPath, prefix, "video"); + const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video"); if (!resolvedMediaPath.ok) { return { channel: "qqbot", error: resolvedMediaPath.error }; } @@ -720,60 +585,46 @@ export async function sendVideoMsg( const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://"); if (isHttp && !shouldDirectUploadUrl(ctx.account)) { - debugLog(`${prefix} sendVideoMsg: urlDirectUpload=false, downloading URL first...`); - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg"); + debugLog(`sendVideoMsg: urlDirectUpload=false, downloading URL first...`); + const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg"); if (localFile) { - return await sendVideoFromLocal(ctx, localFile, prefix); + return await sendVideoFromLocal(ctx, localFile); } return { channel: "qqbot", error: `Failed to download video: ${mediaPath.slice(0, 80)}` }; } try { - const token = await getToken(ctx.account); - if (isHttp) { - if (ctx.targetType === "c2c") { - const r = await sendC2CVideoMessage( - ctx.account.appId, - token, - ctx.targetId, - mediaPath, - undefined, - ctx.replyToId, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupVideoMessage( - ctx.account.appId, - token, - ctx.targetId, - mediaPath, - undefined, - ctx.replyToId, - ); + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendVideo(target, creds, { + videoUrl: mediaPath, + msgId: ctx.replyToId, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendVideoMsg: video not supported in channel`); + debugLog(`sendVideoMsg: video not supported in channel`); return { channel: "qqbot", error: "Video not supported in channel" }; } } - return await sendVideoFromLocal(ctx, mediaPath, prefix); + return await sendVideoFromLocal(ctx, mediaPath); } catch (err) { const msg = formatErrorMessage(err); // If direct URL upload fails, retry through a local download path. if (isHttp) { debugWarn( - `${prefix} sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, + `sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, ); - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg"); + const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg"); if (localFile) { - return await sendVideoFromLocal(ctx, localFile, prefix); + return await sendVideoFromLocal(ctx, localFile); } } - debugError(`${prefix} sendVideoMsg failed: ${msg}`); + debugError(`sendVideoMsg failed: ${msg}`); return { channel: "qqbot", error: msg }; } } @@ -782,7 +633,6 @@ export async function sendVideoMsg( async function sendVideoFromLocal( ctx: MediaTargetContext, mediaPath: string, - prefix: string, ): Promise { if (!(await fileExistsAsync(mediaPath))) { return { channel: "qqbot", error: "Video not found" }; @@ -794,39 +644,25 @@ async function sendVideoFromLocal( const fileBuffer = await readFileAsync(mediaPath); const videoBase64 = fileBuffer.toString("base64"); - debugLog(`${prefix} sendVideoMsg: local video (${formatFileSize(fileBuffer.length)})`); + debugLog(`sendVideoMsg: local video (${formatFileSize(fileBuffer.length)})`); try { - const token = await getToken(ctx.account); - if (ctx.targetType === "c2c") { - const r = await sendC2CVideoMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendVideo(target, creds, { videoBase64, - ctx.replyToId, - undefined, - mediaPath, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupVideoMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, - videoBase64, - ctx.replyToId, - ); + msgId: ctx.replyToId, + localPath: mediaPath, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendVideoMsg: video not supported in channel`); + debugLog(`sendVideoMsg: video not supported in channel`); return { channel: "qqbot", error: "Video not supported in channel" }; } } catch (err) { const msg = formatErrorMessage(err); - debugError(`${prefix} sendVideoMsg (local) failed: ${msg}`); + debugError(`sendVideoMsg (local) failed: ${msg}`); return { channel: "qqbot", error: msg }; } } @@ -837,11 +673,10 @@ export async function sendDocument( filePath: string, options: SendDocumentOptions = {}, ): Promise { - const prefix = ctx.logPrefix ?? "[qqbot]"; const extraLocalRoots = options.allowQQBotDataDownloads ? [getQQBotDataDir("downloads")] : undefined; - const resolvedMediaPath = resolveOutboundMediaPath(filePath, prefix, "file", { + const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", { extraLocalRoots, }); if (!resolvedMediaPath.ok) { @@ -852,62 +687,47 @@ export async function sendDocument( const fileName = sanitizeFileName(path.basename(mediaPath)); if (isHttp && !shouldDirectUploadUrl(ctx.account)) { - debugLog(`${prefix} sendDocument: urlDirectUpload=false, downloading URL first...`); - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument"); + debugLog(`sendDocument: urlDirectUpload=false, downloading URL first...`); + const localFile = await downloadToFallbackDir(mediaPath, "sendDocument"); if (localFile) { - return await sendDocumentFromLocal(ctx, localFile, prefix); + return await sendDocumentFromLocal(ctx, localFile); } return { channel: "qqbot", error: `Failed to download file: ${mediaPath.slice(0, 80)}` }; } try { - const token = await getToken(ctx.account); - if (isHttp) { - if (ctx.targetType === "c2c") { - const r = await sendC2CFileMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, - mediaPath, - ctx.replyToId, + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendFile(target, creds, { + fileUrl: mediaPath, + msgId: ctx.replyToId, fileName, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupFileMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, - mediaPath, - ctx.replyToId, - fileName, - ); + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendDocument: file not supported in channel`); + debugLog(`sendDocument: file not supported in channel`); return { channel: "qqbot", error: "File not supported in channel" }; } } - return await sendDocumentFromLocal(ctx, mediaPath, prefix); + return await sendDocumentFromLocal(ctx, mediaPath); } catch (err) { const msg = formatErrorMessage(err); // If direct URL upload fails, retry through a local download path. if (isHttp) { debugWarn( - `${prefix} sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, + `sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, ); - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument"); + const localFile = await downloadToFallbackDir(mediaPath, "sendDocument"); if (localFile) { - return await sendDocumentFromLocal(ctx, localFile, prefix); + return await sendDocumentFromLocal(ctx, localFile); } } - debugError(`${prefix} sendDocument failed: ${msg}`); + debugError(`sendDocument failed: ${msg}`); return { channel: "qqbot", error: msg }; } } @@ -916,7 +736,6 @@ export async function sendDocument( async function sendDocumentFromLocal( ctx: MediaTargetContext, mediaPath: string, - prefix: string, ): Promise { const fileName = sanitizeFileName(path.basename(mediaPath)); @@ -932,61 +751,43 @@ async function sendDocumentFromLocal( return { channel: "qqbot", error: `File is empty: ${mediaPath}` }; } const fileBase64 = fileBuffer.toString("base64"); - debugLog(`${prefix} sendDocument: local file (${formatFileSize(fileBuffer.length)})`); + debugLog(`sendDocument: local file (${formatFileSize(fileBuffer.length)})`); try { - const token = await getToken(ctx.account); - if (ctx.targetType === "c2c") { - const r = await sendC2CFileMessage( - ctx.account.appId, - token, - ctx.targetId, + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendFile(target, creds, { fileBase64, - undefined, - ctx.replyToId, + msgId: ctx.replyToId, fileName, - mediaPath, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupFileMessage( - ctx.account.appId, - token, - ctx.targetId, - fileBase64, - undefined, - ctx.replyToId, - fileName, - ); + localFilePath: mediaPath, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendDocument: file not supported in channel`); + debugLog(`sendDocument: file not supported in channel`); return { channel: "qqbot", error: "File not supported in channel" }; } } catch (err) { const msg = formatErrorMessage(err); - debugError(`${prefix} sendDocument (local) failed: ${msg}`); + debugError(`sendDocument (local) failed: ${msg}`); return { channel: "qqbot", error: msg }; } } /** Download a remote file into the fallback media directory. */ -async function downloadToFallbackDir( - httpUrl: string, - prefix: string, - caller: string, -): Promise { +async function downloadToFallbackDir(httpUrl: string, caller: string): Promise { try { const downloadDir = getQQBotMediaDir("downloads", "url-fallback"); const localFile = await downloadFile(httpUrl, downloadDir); if (!localFile) { - debugError(`${prefix} ${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`); + debugError(`${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`); return null; } - debugLog(`${prefix} ${caller} fallback: downloaded → ${localFile}`); + debugLog(`${caller} fallback: downloaded → ${localFile}`); return localFile; } catch (err) { - debugError(`${prefix} ${caller} fallback download error:`, err); + debugError(`${caller} fallback download error:`, err); return null; } } @@ -1001,6 +802,8 @@ export async function sendText(ctx: OutboundContext): Promise { let { text, replyToId } = ctx; let fallbackToProactive = false; + initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); + debugLog( "[qqbot] sendText ctx:", JSON.stringify( @@ -1151,99 +954,30 @@ export async function sendText(ctx: OutboundContext): Promise { debugLog(`[qqbot] sendText: Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`); // Send queue items in order. - const mediaTarget = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendText]"); + const mediaTarget = buildMediaTarget({ to, account, replyToId }); let lastResult: OutboundResult = { channel: "qqbot" }; for (const item of sendQueue) { try { if (item.type === "text") { + const target = parseTarget(to); + const creds = accountToCreds(account); + const deliveryTarget: DeliveryTarget = { + type: target.type === "channel" ? "channel" : target.type, + id: target.id, + }; + const result = await senderSendText(deliveryTarget, item.content, creds, { + msgId: replyToId ?? undefined, + }); if (replyToId) { - const accessToken = await getToken(account); - const target = parseTarget(to); - if (target.type === "c2c") { - const result = await sendC2CMessage( - account.appId, - accessToken, - target.id, - item.content, - replyToId, - ); - recordMessageReply(replyToId); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: result.ext_info?.ref_idx, - }; - } else if (target.type === "group") { - const result = await sendGroupMessage( - account.appId, - accessToken, - target.id, - item.content, - replyToId, - ); - recordMessageReply(replyToId); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: result.ext_info?.ref_idx, - }; - } else { - const result = await sendChannelMessage( - accessToken, - target.id, - item.content, - replyToId, - ); - recordMessageReply(replyToId); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } - } else { - const accessToken = await getToken(account); - const target = parseTarget(to); - if (target.type === "c2c") { - const result = await sendProactiveC2CMessage( - account.appId, - accessToken, - target.id, - item.content, - ); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else if (target.type === "group") { - const result = await sendProactiveGroupMessage( - account.appId, - accessToken, - target.id, - item.content, - ); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else { - const result = await sendChannelMessage(accessToken, target.id, item.content); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } + recordMessageReply(replyToId); } + lastResult = { + channel: "qqbot", + messageId: result.id, + timestamp: result.timestamp, + refIdx: result.ext_info?.ref_idx, + }; debugLog(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`); } else if (item.type === "image") { lastResult = await sendPhoto(mediaTarget, item.content); @@ -1301,170 +1035,45 @@ export async function sendText(ctx: OutboundContext): Promise { } try { - const accessToken = await getAccessToken(account.appId, account.clientSecret); const target = parseTarget(to); + const creds = accountToCreds(account); + const deliveryTarget: DeliveryTarget = { + type: target.type === "channel" ? "channel" : target.type, + id: target.id, + }; debugLog("[qqbot] sendText target:", JSON.stringify(target)); - if (!replyToId) { - let outResult: OutboundResult; - if (target.type === "c2c") { - const result = await sendProactiveC2CMessage(account.appId, accessToken, target.id, text); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else if (target.type === "group") { - const result = await sendProactiveGroupMessage(account.appId, accessToken, target.id, text); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else { - const result = await sendChannelMessage(accessToken, target.id, text); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } - return outResult; - } - - if (target.type === "c2c") { - const result = await sendC2CMessage(account.appId, accessToken, target.id, text, replyToId); - recordMessageReply(replyToId); - return { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: result.ext_info?.ref_idx, - }; - } else if (target.type === "group") { - const result = await sendGroupMessage(account.appId, accessToken, target.id, text, replyToId); - recordMessageReply(replyToId); - return { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: result.ext_info?.ref_idx, - }; - } else { - const result = await sendChannelMessage(accessToken, target.id, text, replyToId); - recordMessageReply(replyToId); - return { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; + const result = await senderSendText(deliveryTarget, text, creds, { + msgId: replyToId ?? undefined, + }); + if (replyToId) { + recordMessageReply(replyToId); } + return { + channel: "qqbot", + messageId: result.id, + timestamp: result.timestamp, + refIdx: result.ext_info?.ref_idx, + }; } catch (err) { const message = formatErrorMessage(err); return { channel: "qqbot", error: message }; } } -/** Send a proactive message without a replyToId. */ -export async function sendProactiveMessage( - account: ResolvedQQBotAccount, - to: string, - text: string, -): Promise { - const timestamp = new Date().toISOString(); - - if (!account.appId || !account.clientSecret) { - const errorMsg = "QQBot not configured (missing appId or clientSecret)"; - debugError(`[${timestamp}] [qqbot] sendProactiveMessage: ${errorMsg}`); - return { channel: "qqbot", error: errorMsg }; - } - - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: starting, to=${to}, text length=${text.length}, accountId=${account.accountId}`, - ); - - try { - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: getting access token for appId=${account.appId}`, - ); - const accessToken = await getAccessToken(account.appId, account.clientSecret); - - debugLog(`[${timestamp}] [qqbot] sendProactiveMessage: parsing target=${to}`); - const target = parseTarget(to); - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: target parsed, type=${target.type}, id=${target.id}`, - ); - - let outResult: OutboundResult; - if (target.type === "c2c") { - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: sending proactive C2C message to user=${target.id}`, - ); - const result = await sendProactiveC2CMessage(account.appId, accessToken, target.id, text); - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: proactive C2C message sent successfully, messageId=${result.id}`, - ); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else if (target.type === "group") { - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: sending proactive group message to group=${target.id}`, - ); - const result = await sendProactiveGroupMessage(account.appId, accessToken, target.id, text); - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: proactive group message sent successfully, messageId=${result.id}`, - ); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else { - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: sending channel message to channel=${target.id}`, - ); - const result = await sendChannelMessage(accessToken, target.id, text); - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: channel message sent successfully, messageId=${result.id}`, - ); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } - return outResult; - } catch (err) { - const errorMessage = formatErrorMessage(err); - debugError(`[${timestamp}] [qqbot] sendProactiveMessage: error: ${errorMessage}`); - debugError( - `[${timestamp}] [qqbot] sendProactiveMessage: error stack: ${err instanceof Error ? err.stack : "No stack trace"}`, - ); - return { channel: "qqbot", error: errorMessage }; - } -} - /** Send rich media, auto-routing by media type and source. */ export async function sendMedia(ctx: MediaOutboundContext): Promise { const { to, text, replyToId, account, mimeType } = ctx; + initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); + if (!account.appId || !account.clientSecret) { return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; } if (!ctx.mediaUrl) { return { channel: "qqbot", error: "mediaUrl is required for sendMedia" }; } - const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "[qqbot:sendMedia]", "media", { + const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "media", { allowMissingLocalPath: true, }); if (!resolvedMediaPath.ok) { @@ -1472,7 +1081,7 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise { try { - const token = await getToken(ctx.account); - if (ctx.targetType === "c2c") { - await sendC2CMessage(ctx.account.appId, token, ctx.targetId, text, ctx.replyToId); - } else if (ctx.targetType === "group") { - await sendGroupMessage(ctx.account.appId, token, ctx.targetId, text, ctx.replyToId); - } else if (ctx.targetType === "channel") { - await sendChannelMessage(token, ctx.targetId, text, ctx.replyToId); - } else if (ctx.targetType === "dm") { - await sendDmMessage(token, ctx.targetId, text, ctx.replyToId); - } + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + await senderSendText(target, text, creds, { msgId: ctx.replyToId }); } catch (err) { - debugError( - `[qqbot] sendTextAfterMedia failed: ${err instanceof Error ? err.message : JSON.stringify(err)}`, - ); + debugError(`[qqbot] sendTextAfterMedia failed: ${formatErrorMessage(err)}`); } } -/** Extract a lowercase extension from a path or URL, ignoring query and hash segments. */ -function getCleanExt(filePath: string): string { - const cleanPath = filePath.split("?")[0].split("#")[0]; - return normalizeLowercaseStringOrEmpty(path.extname(cleanPath)); -} +// Media type detection delegated to core/outbound/media-type-detect.ts. +// Re-alias for backward compatibility within this file. +const isImageFile = coreIsImageFile; +const isVideoFile = coreIsVideoFile; -/** Check whether a file is an image using MIME first and extension as fallback. */ -function isImageFile(filePath: string, mimeType?: string): boolean { - if (mimeType) { - if (mimeType.startsWith("image/")) { - return true; - } - } - const ext = getCleanExt(filePath); - return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext); -} - -/** Check whether a file or URL is a video using MIME first and extension as fallback. */ -function isVideoFile(filePath: string, mimeType?: string): boolean { - if (mimeType) { - if (mimeType.startsWith("video/")) { - return true; - } - } - const ext = getCleanExt(filePath); - return [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"].includes(ext); +/** + * Send a proactive (no reply context) text message to a qualified target. + * + * Thin wrapper around {@link sendText} for callers that have a fully-qualified + * target string (e.g. `"qqbot:c2c:"`) and a {@link GatewayAccount}, + * and do not want to manage access tokens or delivery-target parsing manually. + * + * @param account Resolved gateway account. + * @param to Fully-qualified target address (`qqbot:c2c:`, `qqbot:group:`, etc.). + * @param content Message content. + */ +export async function sendProactiveMessage( + account: GatewayAccount, + to: string, + content: string, +): Promise { + return sendText({ account, to, text: content }); } /** @@ -1604,7 +1200,7 @@ function isVideoFile(filePath: string, mimeType?: string): boolean { * ``` */ export async function sendCronMessage( - account: ResolvedQQBotAccount, + account: GatewayAccount, to: string, message: string, ): Promise { @@ -1640,7 +1236,7 @@ export async function sendCronMessage( ); // Send the reminder content. - const result = await sendProactiveMessage(account, targetTo, payload.content); + const result = await sendText({ account, to: targetTo, text: payload.content }); if (result.error) { debugError( @@ -1656,5 +1252,5 @@ export async function sendCronMessage( // Fall back to plain text handling when the payload is not structured. debugLog(`[${timestamp}] [qqbot] sendCronMessage: plain text message, sending to ${to}`); - return await sendProactiveMessage(account, to, message); + return await sendText({ account, to, text: message }); } diff --git a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts new file mode 100644 index 00000000000..6e292b5c458 --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts @@ -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; +} + +/** 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( + appId: string, + clientSecret: string, + sendFn: (token: string) => Promise, + log?: ReplyContext["log"], + accountId?: string, +): Promise { + 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 { + 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 { + 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 { + 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 ""; + } + 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 ""; + } +} + +async function readStructuredPayloadLocalFile(filePath: string): Promise { + 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 { + 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 = { + ".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 { + 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 { + 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 { + 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)}`); + } +} diff --git a/extensions/qqbot/src/engine/messaging/reply-limiter.ts b/extensions/qqbot/src/engine/messaging/reply-limiter.ts new file mode 100644 index 00000000000..a86c19186dc --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/reply-limiter.ts @@ -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(); + + 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); + } + } + } +} diff --git a/extensions/qqbot/src/engine/messaging/sender.ts b/extensions/qqbot/src/engine/messaging/sender.ts new file mode 100644 index 00000000000..ca9e5a72305 --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/sender.ts @@ -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 + * + * 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(); + +/** 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 { + 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 { + 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 { + 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( + creds: AccountCreds, + sendFn: (token: string) => Promise, + log?: EngineLogger, + _accountId?: string, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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"; +} diff --git a/extensions/qqbot/src/engine/messaging/target-parser.ts b/extensions/qqbot/src/engine/messaging/target-parser.ts new file mode 100644 index 00000000000..e0c43be6eda --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/target-parser.ts @@ -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); +} diff --git a/extensions/qqbot/src/engine/ref/format-message-ref.ts b/extensions/qqbot/src/engine/ref/format-message-ref.ts new file mode 100644 index 00000000000..cc4d0f820bf --- /dev/null +++ b/extensions/qqbot/src/engine/ref/format-message-ref.ts @@ -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; + }>; + + 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 { + 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() || ""; +} diff --git a/extensions/qqbot/src/engine/ref/format-ref-entry.ts b/extensions/qqbot/src/engine/ref/format-ref-entry.ts new file mode 100644 index 00000000000..b0e3cf33d13 --- /dev/null +++ b/extensions/qqbot/src/engine/ref/format-ref-entry.ts @@ -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 = { + 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]"; +} diff --git a/extensions/qqbot/src/ref-index-store.ts b/extensions/qqbot/src/engine/ref/store.ts similarity index 54% rename from extensions/qqbot/src/ref-index-store.ts rename to extensions/qqbot/src/engine/ref/store.ts index 22c924758d4..04cb206f16c 100644 --- a/extensions/qqbot/src/ref-index-store.ts +++ b/extensions/qqbot/src/engine/ref/store.ts @@ -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 | null = null; let totalLinesOnDisk = 0; -/** Lazily load the JSONL store into memory. */ function loadFromFile(): Map { if (cache !== null) { return cache; } - cache = new Map(); totalLinesOnDisk = 0; @@ -52,7 +42,6 @@ function loadFromFile(): Map { 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 { 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 }; } diff --git a/extensions/qqbot/src/engine/ref/types.ts b/extensions/qqbot/src/engine/ref/types.ts new file mode 100644 index 00000000000..505900b1933 --- /dev/null +++ b/extensions/qqbot/src/engine/ref/types.ts @@ -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; +} diff --git a/extensions/qqbot/src/known-users.ts b/extensions/qqbot/src/engine/session/known-users.ts similarity index 77% rename from extensions/qqbot/src/known-users.ts rename to extensions/qqbot/src/engine/session/known-users.ts index 99a14174437..47a9c241f9f 100644 --- a/extensions/qqbot/src/known-users.ts +++ b/extensions/qqbot/src/engine/session/known-users.ts @@ -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 | null = null; - const SAVE_THROTTLE_MS = 5000; let saveTimer: ReturnType | 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): string { + const base = `${user.accountId}:${user.type}:${user.openid}`; + return user.type === "group" && user.groupOpenid ? `${base}:${user.groupOpenid}` : base; +} + function loadUsersFromFile(): Map { 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): 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. */ diff --git a/extensions/qqbot/src/session-store.ts b/extensions/qqbot/src/engine/session/session-store.ts similarity index 84% rename from extensions/qqbot/src/session-store.ts rename to extensions/qqbot/src/engine/session/session-store.ts index c5bc9a98c91..5fd291dc366 100644 --- a/extensions/qqbot/src/session-store.ts +++ b/extensions/qqbot/src/engine/session/session-store.ts @@ -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(); - 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; } diff --git a/extensions/qqbot/src/engine/tools/channel-api.ts b/extensions/qqbot/src/engine/tools/channel-api.ts new file mode 100644 index 00000000000..d5f2f600b65 --- /dev/null +++ b/extensions/qqbot/src/engine/tools/channel-api.ts @@ -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; + query?: Record; +} + +/** + * 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 { + 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 = { + 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, + }); + } +} diff --git a/extensions/qqbot/src/engine/tools/remind-logic.test.ts b/extensions/qqbot/src/engine/tools/remind-logic.test.ts new file mode 100644 index 00000000000..6323e3d754d --- /dev/null +++ b/extensions/qqbot/src/engine/tools/remind-logic.test.ts @@ -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); + }); + }); +}); diff --git a/extensions/qqbot/src/engine/tools/remind-logic.ts b/extensions/qqbot/src/engine/tools/remind-logic.ts new file mode 100644 index 00000000000..ebcc6a243b9 --- /dev/null +++ b/extensions/qqbot/src/engine/tools/remind-logic.ts @@ -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 (3–6 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}"`, + }); +} diff --git a/extensions/qqbot/src/engine/types.ts b/extensions/qqbot/src/engine/types.ts new file mode 100644 index 00000000000..dafdb77d0ee --- /dev/null +++ b/extensions/qqbot/src/engine/types.ts @@ -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 & { + allowFrom?: Array; + groupAllowFrom?: Array; + dmPolicy?: "open" | "allowlist" | "disabled"; + groupPolicy?: "open" | "allowlist" | "disabled"; + streaming?: { mode?: string }; + audioFormatPolicy?: { + uploadDirectFormats?: string[]; + transcodeEnabled?: boolean; + }; + voiceDirectUploadFormats?: string[]; + }; +} diff --git a/extensions/qqbot/src/engine/utils/audio.test.ts b/extensions/qqbot/src/engine/utils/audio.test.ts new file mode 100644 index 00000000000..03792b6d12e --- /dev/null +++ b/extensions/qqbot/src/engine/utils/audio.test.ts @@ -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 + }); + }); +}); diff --git a/extensions/qqbot/src/utils/audio-convert.ts b/extensions/qqbot/src/engine/utils/audio.ts similarity index 54% rename from extensions/qqbot/src/utils/audio-convert.ts rename to extensions/qqbot/src/engine/utils/audio.ts index 6978fa22e71..52fa7140d98 100644 --- a/extensions/qqbot/src/utils/audio-convert.ts +++ b/extensions/qqbot/src/engine/utils/audio.ts @@ -1,17 +1,27 @@ +/** + * Audio format conversion utilities. + * 音频格式转换工具。 + * + * Handles SILK ↔ PCM ↔ WAV ↔ MP3 conversions for QQ Bot voice messaging. + * Prefers ffmpeg when available; falls back to WASM decoders (silk-wasm, + * mpg123-decoder) for environments without native tooling. + * + * Self-contained within engine/ — no framework SDK dependency. + */ + import { execFile } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { asRecord, readString } from "../config-record-shared.js"; -import { debugLog, debugError, debugWarn } from "./debug-log.js"; +import { formatErrorMessage } from "./format.js"; +import { debugLog, debugError, debugWarn } from "./log.js"; import { detectFfmpeg, isWindows } from "./platform.js"; +import { normalizeLowercaseStringOrEmpty as normalizeLowercase } from "./string-normalize.js"; type SilkWasm = typeof import("silk-wasm"); let _silkWasmPromise: Promise | null = null; -function loadSilkWasm(): Promise { +/** Lazy-load the silk-wasm module (singleton cache; returns null on failure). */ +export function loadSilkWasm(): Promise { if (_silkWasmPromise) { return _silkWasmPromise; } @@ -24,8 +34,8 @@ function loadSilkWasm(): Promise { return _silkWasmPromise; } -/** Wrap PCM s16le bytes in a WAV container. */ -function pcmToWav( +/** Wrap raw PCM s16le data into a standard WAV file. */ +export function pcmToWav( pcmData: Uint8Array, sampleRate: number, channels: number = 1, @@ -39,22 +49,19 @@ function pcmToWav( const buffer = Buffer.alloc(fileSize); - // RIFF header buffer.write("RIFF", 0); buffer.writeUInt32LE(fileSize - 8, 4); buffer.write("WAVE", 8); - // fmt sub-chunk buffer.write("fmt ", 12); - buffer.writeUInt32LE(16, 16); // sub-chunk size - buffer.writeUInt16LE(1, 20); // PCM format + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); buffer.writeUInt16LE(channels, 22); buffer.writeUInt32LE(sampleRate, 24); buffer.writeUInt32LE(byteRate, 28); buffer.writeUInt16LE(blockAlign, 32); buffer.writeUInt16LE(bitsPerSample, 34); - // data sub-chunk buffer.write("data", 36); buffer.writeUInt32LE(dataSize, 40); Buffer.from(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength).copy(buffer, headerSize); @@ -62,8 +69,8 @@ function pcmToWav( return buffer; } -/** Strip a leading AMR header from QQ voice payloads when present. */ -function stripAmrHeader(buf: Buffer): Buffer { +/** Strip the AMR header that may be present in QQ voice payloads. */ +export function stripAmrHeader(buf: Buffer): Buffer { const AMR_HEADER = Buffer.from("#!AMR\n"); if (buf.length > 6 && buf.subarray(0, 6).equals(AMR_HEADER)) { return buf.subarray(6); @@ -71,7 +78,7 @@ function stripAmrHeader(buf: Buffer): Buffer { return buf; } -/** Convert SILK or AMR voice files into WAV. */ +/** Convert a SILK or AMR voice file to WAV format. */ export async function convertSilkToWav( inputPath: string, outputDir?: string, @@ -81,9 +88,7 @@ export async function convertSilkToWav( } const fileBuf = fs.readFileSync(inputPath); - const strippedBuf = stripAmrHeader(fileBuf); - const rawData = new Uint8Array( strippedBuf.buffer, strippedBuf.byteOffset, @@ -95,10 +100,8 @@ export async function convertSilkToWav( return null; } - // QQ voice commonly uses 24 kHz. const sampleRate = 24000; const result = await silk.decode(rawData, sampleRate); - const wavBuffer = pcmToWav(result.data, sampleRate); const dir = outputDir || path.dirname(inputPath); @@ -112,34 +115,23 @@ export async function convertSilkToWav( return { wavPath, duration: result.duration }; } -/** Return true when an attachment looks like a voice file. */ +/** Check whether an attachment is a voice file (by MIME type or extension). */ export function isVoiceAttachment(att: { content_type?: string; filename?: string }): boolean { if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) { return true; } - const ext = att.filename ? normalizeLowercaseStringOrEmpty(path.extname(att.filename)) : ""; + const ext = att.filename ? normalizeLowercase(path.extname(att.filename)) : ""; return [".amr", ".silk", ".slk", ".slac"].includes(ext); } -/** Format a duration as a user-readable string. */ -export function formatDuration(durationMs: number): string { - const seconds = Math.round(durationMs / 1000); - if (seconds < 60) { - return `${seconds}s`; - } - const minutes = Math.floor(seconds / 60); - const remainSeconds = seconds % 60; - return remainSeconds > 0 ? `${minutes}m ${remainSeconds}s` : `${minutes}m`; -} - +/** Check whether a file path is a known audio format. */ export function isAudioFile(filePath: string, mimeType?: string): boolean { - // Prefer MIME when extension data is missing or misleading. if (mimeType) { if (mimeType === "voice" || mimeType.startsWith("audio/")) { return true; } } - const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); + const ext = normalizeLowercase(path.extname(filePath)); return [ ".silk", ".slk", @@ -156,7 +148,6 @@ export function isAudioFile(filePath: string, mimeType?: string): boolean { ].includes(ext); } -/** Voice MIME types the QQ platform accepts without transcoding. */ const QQ_NATIVE_VOICE_MIMES = new Set([ "audio/silk", "audio/amr", @@ -167,334 +158,33 @@ const QQ_NATIVE_VOICE_MIMES = new Set([ "audio/mp3", ]); -/** Voice extensions the QQ platform accepts without transcoding. */ const QQ_NATIVE_VOICE_EXTS = new Set([".silk", ".slk", ".amr", ".wav", ".mp3"]); -/** - * Return true when voice input must be transcoded before upload. - */ +/** Check whether a voice file needs transcoding for upload (QQ-native formats skip it). */ export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean { - // Prefer MIME when it is available. - if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercaseStringOrEmpty(mimeType))) { + if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercase(mimeType))) { return false; } - const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); + const ext = normalizeLowercase(path.extname(filePath)); if (QQ_NATIVE_VOICE_EXTS.has(ext)) { return false; } return isAudioFile(filePath, mimeType); } -// TTS helpers. - -export interface TTSConfig { - baseUrl: string; - apiKey: string; - model: string; - voice: string; - authStyle?: "bearer" | "api-key"; - queryParams?: Record; - speed?: number; -} - -type QQBotTtsProviderConfig = { - baseUrl?: string; - apiKey?: string; - authStyle?: string; - queryParams?: Record; -}; - -type QQBotTtsBlock = QQBotTtsProviderConfig & { - model?: string; - voice?: string; - speed?: number; -}; - -function readNumber(record: Record | undefined, key: string): number | undefined { - const value = record?.[key]; - return typeof value === "number" ? value : undefined; -} - -function readStringMap(value: unknown): Record { - const record = asRecord(value); - if (!record) { - return {}; - } - return Object.fromEntries( - Object.entries(record).flatMap(([key, entryValue]) => - typeof entryValue === "string" ? [[key, entryValue]] : [], - ), - ); -} - -function resolveTTSFromBlock( - block: QQBotTtsBlock, - providerCfg: QQBotTtsProviderConfig | undefined, -): TTSConfig | null { - const baseUrl = readString(block, "baseUrl") ?? readString(providerCfg, "baseUrl"); - const apiKey = readString(block, "apiKey") ?? readString(providerCfg, "apiKey"); - const model = readString(block, "model") ?? "tts-1"; - const voice = readString(block, "voice") ?? "alloy"; - if (!baseUrl || !apiKey) { - return null; - } - - const authStyle = - (readString(block, "authStyle") ?? readString(providerCfg, "authStyle")) === "api-key" - ? ("api-key" as const) - : ("bearer" as const); - const queryParams: Record = { - ...readStringMap(providerCfg?.queryParams), - ...readStringMap(block.queryParams), - }; - const speed = readNumber(block, "speed"); - - return { - baseUrl: baseUrl.replace(/\/+$/, ""), - apiKey, - model, - voice, - authStyle, - ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), - ...(speed !== undefined ? { speed } : {}), - }; -} - -export function resolveTTSConfig(cfg: Record): TTSConfig | null { - const models = asRecord(cfg.models); - const providers = asRecord(models?.providers); - - // Prefer plugin-specific TTS config first. - const channels = asRecord(cfg.channels); - const qqbot = asRecord(channels?.qqbot); - const channelTts = asRecord(qqbot?.tts); - if (channelTts && channelTts.enabled !== false) { - const providerId = readString(channelTts, "provider") ?? "openai"; - const providerCfg = asRecord(providers?.[providerId]); - const result = resolveTTSFromBlock(channelTts, providerCfg); - if (result) { - return result; - } - } - - // Fall back to framework-level TTS config. - const messages = asRecord(cfg.messages); - const msgTts = asRecord(messages?.tts); - const autoMode = readString(msgTts, "auto"); - if (msgTts && autoMode !== "off" && autoMode !== "disabled") { - const providerId = readString(msgTts, "provider") ?? "openai"; - const providerBlock = asRecord(msgTts[providerId]) ?? {}; - const providerCfg = asRecord(providers?.[providerId]); - const result = resolveTTSFromBlock(providerBlock, providerCfg); - if (result) { - return result; - } - } - - return null; -} - -/** - * Check whether global TTS is potentially available by inspecting the - * framework-level `messages.tts` config. This mirrors the resolution logic - * in the core `resolveTtsConfig`: when `auto` is set it must not be `"off"`; - * when only the legacy `enabled` boolean is present it must be truthy; - * when neither is set TTS defaults to off. - * - * This does NOT guarantee a specific provider is registered/configured – it - * only checks that TTS is not explicitly (or implicitly) disabled. - */ -export function isGlobalTTSAvailable(cfg: OpenClawConfig): boolean { - const msgTts = cfg.messages?.tts; - if (!msgTts) { - return false; - } - // Framework canonical field takes precedence. - if (msgTts.auto) { - return msgTts.auto !== "off"; - } - // Legacy compat: `enabled: true` → "always", absent/false → "off". - return msgTts.enabled === true; -} - -/** Build the TTS endpoint URL and auth headers. */ -function buildTTSRequest(ttsCfg: TTSConfig): { url: string; headers: Record } { - let url = `${ttsCfg.baseUrl}/audio/speech`; - if (ttsCfg.queryParams && Object.keys(ttsCfg.queryParams).length > 0) { - const qs = new URLSearchParams(ttsCfg.queryParams).toString(); - url += `?${qs}`; - } - - const headers: Record = { "Content-Type": "application/json" }; - if (ttsCfg.authStyle === "api-key") { - headers["api-key"] = ttsCfg.apiKey; - } else { - headers["Authorization"] = `Bearer ${ttsCfg.apiKey}`; - } - - return { url, headers }; -} - -export async function textToSpeechPCM( - text: string, - ttsCfg: TTSConfig, -): Promise<{ pcmBuffer: Buffer; sampleRate: number }> { - const sampleRate = 24000; - const { url, headers } = buildTTSRequest(ttsCfg); - - debugLog( - `[tts] Request: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, url=${url}`, - ); - debugLog( - `[tts] Input text (${text.length} chars): "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`, - ); - - // Prefer PCM first to avoid an extra decode pass. - const formats: Array<{ format: string; needsDecode: boolean }> = [ - { format: "pcm", needsDecode: false }, - { format: "mp3", needsDecode: true }, - ]; - - let lastError: Error | null = null; - const startTime = Date.now(); - - for (const { format, needsDecode } of formats) { - const controller = new AbortController(); - const ttsTimeout = setTimeout(() => controller.abort(), 120000); - - try { - const body: Record = { - model: ttsCfg.model, - input: text, - voice: ttsCfg.voice, - response_format: format, - ...(format === "pcm" ? { sample_rate: sampleRate } : {}), - ...(ttsCfg.speed !== undefined ? { speed: ttsCfg.speed } : {}), - }; - - debugLog(`[tts] Trying format=${format}...`); - const fetchStart = Date.now(); - const resp = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(body), - signal: controller.signal, - }).finally(() => clearTimeout(ttsTimeout)); - - const fetchMs = Date.now() - fetchStart; - - if (!resp.ok) { - const detail = await resp.text().catch(() => ""); - debugLog( - `[tts] HTTP ${resp.status} for format=${format} (${fetchMs}ms): ${detail.slice(0, 200)}`, - ); - // Some providers reject PCM but accept MP3, so retry there. - if (format === "pcm" && (resp.status === 400 || resp.status === 422)) { - debugLog(`[tts] PCM format not supported, falling back to mp3`); - lastError = new Error(`TTS PCM not supported: ${detail.slice(0, 200)}`); - continue; - } - throw new Error(`TTS failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`); - } - - const arrayBuffer = await resp.arrayBuffer(); - const rawBuffer = Buffer.from(arrayBuffer); - debugLog( - `[tts] Response OK: format=${format}, size=${rawBuffer.length} bytes, latency=${fetchMs}ms`, - ); - - if (!needsDecode) { - debugLog( - `[tts] Done: PCM direct, ${rawBuffer.length} bytes, total=${Date.now() - startTime}ms`, - ); - return { pcmBuffer: rawBuffer, sampleRate }; - } - - // MP3 responses must be decoded back into PCM. - debugLog(`[tts] Decoding mp3 response (${rawBuffer.length} bytes) to PCM...`); - const tmpDir = path.join(fs.mkdtempSync(path.join(require("node:os").tmpdir(), "tts-"))); - const tmpMp3 = path.join(tmpDir, "tts.mp3"); - fs.writeFileSync(tmpMp3, rawBuffer); - - try { - // Prefer ffmpeg when it is available. - const ffmpegCmd = await checkFfmpeg(); - if (ffmpegCmd) { - const pcmBuf = await ffmpegToPCM(ffmpegCmd, tmpMp3, sampleRate); - debugLog( - `[tts] Done: mp3→PCM (ffmpeg), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`, - ); - return { pcmBuffer: pcmBuf, sampleRate }; - } - const pcmBuf = await wasmDecodeMp3ToPCM(rawBuffer, sampleRate); - if (pcmBuf) { - debugLog( - `[tts] Done: mp3→PCM (wasm), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`, - ); - return { pcmBuffer: pcmBuf, sampleRate }; - } - throw new Error("No decoder available for mp3 (install ffmpeg for best compatibility)"); - } finally { - try { - fs.unlinkSync(tmpMp3); - fs.rmdirSync(tmpDir); - } catch {} - } - } catch (err) { - clearTimeout(ttsTimeout); - lastError = err instanceof Error ? err : new Error(String(err)); - debugLog(`[tts] Error for format=${format}: ${lastError.message.slice(0, 200)}`); - if (format === "pcm") { - continue; - } - throw lastError; - } - } - - debugLog(`[tts] All formats exhausted after ${Date.now() - startTime}ms`); - throw lastError ?? new Error("TTS failed: all formats exhausted"); -} - -export async function pcmToSilk( - pcmBuffer: Buffer, - sampleRate: number, -): Promise<{ silkBuffer: Buffer; duration: number }> { - const silk = await loadSilkWasm(); - if (!silk) { - throw new Error("silk-wasm is not available; cannot encode PCM to SILK"); - } - const pcmData = new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength); - const result = await silk.encode(pcmData, sampleRate); - return { - silkBuffer: Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength), - duration: result.duration, - }; -} - -export async function textToSilk( - text: string, - ttsCfg: TTSConfig, - outputDir: string, -): Promise<{ silkPath: string; silkBase64: string; duration: number }> { - const { pcmBuffer, sampleRate } = await textToSpeechPCM(text, ttsCfg); - const { silkBuffer, duration } = await pcmToSilk(pcmBuffer, sampleRate); - - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - const silkPath = path.join(outputDir, `tts-${Date.now()}.silk`); - fs.writeFileSync(silkPath, silkBuffer); - - return { silkPath, silkBase64: silkBuffer.toString("base64"), duration }; -} - -// Generic audio -> SILK conversion. - -/** Upload formats accepted directly by the QQ Bot API. */ const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"]; +function normalizeFormats(formats: string[]): string[] { + return formats.map((f) => { + const lower = normalizeLowercase(f); + return lower.startsWith(".") ? lower : `.${lower}`; + }); +} + /** - * Convert a local audio file into an uploadable Base64 payload. + * Convert a local audio file to Base64-encoded SILK for QQ API upload. + * + * Attempts conversion via ffmpeg → WASM decoders → null fallback chain. */ export async function audioFileToSilkBase64( filePath: string, @@ -510,8 +200,7 @@ export async function audioFileToSilkBase64( return null; } - const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); - + const ext = normalizeLowercase(path.extname(filePath)); const uploadFormats = directUploadFormats ? normalizeFormats(directUploadFormats) : QQ_NATIVE_UPLOAD_FORMATS; @@ -520,7 +209,6 @@ export async function audioFileToSilkBase64( return buf.toString("base64"); } - // Some .slk/.slac files are already SILK and can be uploaded directly. if ([".slk", ".slac"].includes(ext)) { const stripped = stripAmrHeader(buf); const raw = new Uint8Array(stripped.buffer, stripped.byteOffset, stripped.byteLength); @@ -531,7 +219,6 @@ export async function audioFileToSilkBase64( } } - // Also detect SILK by header, not just by extension. const rawCheck = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); const strippedCheck = stripAmrHeader(buf); const strippedRaw = new Uint8Array( @@ -547,8 +234,7 @@ export async function audioFileToSilkBase64( const targetRate = 24000; - // Prefer ffmpeg for broad codec coverage. - const ffmpegCmd = await checkFfmpeg(); + const ffmpegCmd = await detectFfmpeg(); if (ffmpegCmd) { try { debugLog( @@ -567,7 +253,6 @@ export async function audioFileToSilkBase64( } } - // Fall back to WASM decoders when ffmpeg is unavailable. debugLog(`[audio-convert] fallback: trying WASM decoders for ${ext}`); if (ext === ".pcm") { @@ -603,7 +288,9 @@ export async function audioFileToSilkBase64( } /** - * Wait until a file exists and its size has stabilized. + * Wait for a file to appear and stabilize, then return its final size. + * + * Polls at `pollMs` intervals; returns 0 on timeout or persistent empty file. */ export async function waitForFile( filePath: string, @@ -682,13 +369,25 @@ export async function waitForFile( return 0; } -/** Delegate ffmpeg detection to the platform helper. */ -async function checkFfmpeg(): Promise { - return detectFfmpeg(); +/** Encode PCM s16le data into SILK format. */ +export async function pcmToSilk( + pcmBuffer: Buffer, + sampleRate: number, +): Promise<{ silkBuffer: Buffer; duration: number }> { + const silk = await loadSilkWasm(); + if (!silk) { + throw new Error("silk-wasm is not available; cannot encode PCM to SILK"); + } + const pcmData = new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength); + const result = await silk.encode(pcmData, sampleRate); + return { + silkBuffer: Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength), + duration: result.duration, + }; } -/** Convert arbitrary audio into mono 24 kHz PCM s16le with ffmpeg. */ -function ffmpegToPCM( +/** Use ffmpeg to convert any audio to mono 24 kHz PCM s16le. */ +export function ffmpegToPCM( ffmpegCmd: string, inputPath: string, sampleRate: number = 24000, @@ -728,19 +427,10 @@ function ffmpegToPCM( }); } -type MpegDecoderConstructor = typeof import("mpg123-decoder").MPEGDecoder; - -let mpegDecoderConstructorPromise: Promise | null = null; - -async function loadMpegDecoderConstructor(): Promise { - mpegDecoderConstructorPromise ??= import("mpg123-decoder").then(({ MPEGDecoder }) => MPEGDecoder); - return mpegDecoderConstructorPromise; -} - -/** Decode MP3 into PCM through mpg123-decoder when ffmpeg is unavailable. */ -async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise { +/** Decode MP3 to PCM via mpg123-decoder WASM (fallback when ffmpeg is unavailable). */ +export async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise { try { - const MPEGDecoder = await loadMpegDecoderConstructor(); + const { MPEGDecoder } = await import("mpg123-decoder"); debugLog(`[audio-convert] WASM MP3 decode: size=${buf.length} bytes`); const decoder = new MPEGDecoder(); await decoder.ready; @@ -759,7 +449,6 @@ async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise { - const lower = normalizeLowercaseStringOrEmpty(f); - return lower.startsWith(".") ? lower : `.${lower}`; - }); -} - -/** Parse standard PCM WAV as a no-ffmpeg fallback. */ -function parseWavFallback(buf: Buffer): Buffer | null { +/** Parse a standard PCM WAV and extract mono 24 kHz PCM data (fallback without ffmpeg). */ +export function parseWavFallback(buf: Buffer): Buffer | null { if (buf.length < 44) { return null; } @@ -850,7 +529,6 @@ function parseWavFallback(buf: Buffer): Buffer | null { return null; } - // Find the PCM data chunk. let offset = 36; while (offset < buf.length - 8) { const chunkId = buf.toString("ascii", offset, offset + 4); @@ -860,7 +538,6 @@ function parseWavFallback(buf: Buffer): Buffer | null { const dataEnd = Math.min(dataStart + chunkSize, buf.length); let pcm = new Uint8Array(buf.buffer, buf.byteOffset + dataStart, dataEnd - dataStart); - // Downmix multi-channel audio to mono. if (channels > 1) { const samplesPerCh = pcm.length / (2 * channels); const mono = new Uint8Array(samplesPerCh * 2); @@ -876,7 +553,6 @@ function parseWavFallback(buf: Buffer): Buffer | null { pcm = mono; } - // Resample with simple linear interpolation. const targetRate = 24000; if (sampleRate !== targetRate) { const inSamples = pcm.length / 2; diff --git a/extensions/qqbot/src/engine/utils/data-paths.ts b/extensions/qqbot/src/engine/utils/data-paths.ts new file mode 100644 index 00000000000..e741d87e0e2 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/data-paths.ts @@ -0,0 +1,38 @@ +/** + * Centralised filename helpers for persisted QQBot state. + * + * Every persistence module routes file paths through these helpers so the + * naming convention stays in sync and legacy migrations are handled + * consistently. + * + * Key design decisions: + * - Credential backup is keyed only by `accountId` because recovery runs + * exactly when the appId is missing from config. + */ + +import path from "node:path"; +import { getQQBotDataDir } from "./platform.js"; + +/** + * Normalise an identifier so it is safe to embed in a filename. + * Keeps alphanumerics, dot, underscore, dash; everything else becomes `_`. + */ +export function safeName(id: string): string { + return id.replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +// ---- credential backup ---- + +/** + * Per-accountId credential backup file. Not keyed by appId because the + * whole point of this file is to recover credentials when appId is + * missing from the live config. + */ +export function getCredentialBackupFile(accountId: string): string { + return path.join(getQQBotDataDir("data"), `credential-backup-${safeName(accountId)}.json`); +} + +/** Legacy single-file credential backup (pre-multi-account-isolation). */ +export function getLegacyCredentialBackupFile(): string { + return path.join(getQQBotDataDir("data"), "credential-backup.json"); +} diff --git a/extensions/qqbot/src/engine/utils/diagnostics.ts b/extensions/qqbot/src/engine/utils/diagnostics.ts new file mode 100644 index 00000000000..f3503ffdaca --- /dev/null +++ b/extensions/qqbot/src/engine/utils/diagnostics.ts @@ -0,0 +1,109 @@ +/** + * Gateway startup diagnostics — extracted from utils/platform.ts. + * + * Depends on utils/platform.ts for detection functions, but no plugin-sdk. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { debugLog } from "./log.js"; +import { + getHomeDir, + getTempDir, + getQQBotDataDir, + getPlatform, + isWindows, + detectFfmpeg, + checkSilkWasmAvailable, +} from "./platform.js"; + +export interface DiagnosticReport { + platform: string; + arch: string; + nodeVersion: string; + homeDir: string; + tempDir: string; + dataDir: string; + ffmpeg: string | null; + silkWasm: boolean; + warnings: string[]; +} + +/** + * Run startup diagnostics and return an environment report. + * Called during gateway startup to log environment details and warnings. + */ +export async function runDiagnostics(): Promise { + const warnings: string[] = []; + + const platform = `${process.platform} (${os.release()})`; + const arch = process.arch; + const nodeVersion = process.version; + const homeDir = getHomeDir(); + const tempDir = getTempDir(); + const dataDir = getQQBotDataDir(); + + const ffmpegPath = await detectFfmpeg(); + if (!ffmpegPath) { + warnings.push( + isWindows() + ? "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with choco install ffmpeg, scoop install ffmpeg, or from https://ffmpeg.org." + : getPlatform() === "darwin" + ? "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with brew install ffmpeg." + : "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with sudo apt install ffmpeg or sudo yum install ffmpeg.", + ); + } + + const silkWasm = await checkSilkWasmAvailable(); + if (!silkWasm) { + warnings.push( + "⚠️ silk-wasm is unavailable. QQ voice send/receive will not work. Ensure Node.js >= 16 and WASM support are available.", + ); + } + + try { + const testFile = path.join(dataDir, ".write-test"); + fs.writeFileSync(testFile, "test"); + fs.unlinkSync(testFile); + } catch { + warnings.push(`⚠️ Data directory is not writable: ${dataDir}. Check filesystem permissions.`); + } + + if (isWindows()) { + if (/[\u4e00-\u9fa5]/.test(homeDir) || homeDir.includes(" ")) { + warnings.push( + `⚠️ Home directory contains Chinese characters or spaces: ${homeDir}. Some tools may fail. Consider setting QQBOT_DATA_DIR to an ASCII-only path.`, + ); + } + } + + const report: DiagnosticReport = { + platform, + arch, + nodeVersion, + homeDir, + tempDir, + dataDir, + ffmpeg: ffmpegPath, + silkWasm, + warnings, + }; + + debugLog("=== QQBot Environment Diagnostics ==="); + debugLog(` Platform: ${platform} (${arch})`); + debugLog(` Node: ${nodeVersion}`); + debugLog(` Home: ${homeDir}`); + debugLog(` Data dir: ${dataDir}`); + debugLog(` ffmpeg: ${ffmpegPath ?? "not installed"}`); + debugLog(` silk-wasm: ${silkWasm ? "available" : "unavailable"}`); + if (warnings.length > 0) { + debugLog(" --- Warnings ---"); + for (const w of warnings) { + debugLog(` ${w}`); + } + } + debugLog("======================"); + + return report; +} diff --git a/extensions/qqbot/src/utils/file-utils-runtime.ts b/extensions/qqbot/src/engine/utils/file-utils-runtime.ts similarity index 100% rename from extensions/qqbot/src/utils/file-utils-runtime.ts rename to extensions/qqbot/src/engine/utils/file-utils-runtime.ts diff --git a/extensions/qqbot/src/utils/file-utils.test.ts b/extensions/qqbot/src/engine/utils/file-utils.test.ts similarity index 76% rename from extensions/qqbot/src/utils/file-utils.test.ts rename to extensions/qqbot/src/engine/utils/file-utils.test.ts index 3dab5901131..ed3be3005a7 100644 --- a/extensions/qqbot/src/utils/file-utils.test.ts +++ b/extensions/qqbot/src/engine/utils/file-utils.test.ts @@ -3,12 +3,14 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const mediaRuntimeMocks = vi.hoisted(() => ({ - fetchRemoteMedia: vi.fn(), +const adapterMocks = vi.hoisted(() => ({ + fetchMedia: vi.fn(), })); -vi.mock("./file-utils-runtime.js", () => ({ - fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args), +vi.mock("../adapter/index.js", () => ({ + getPlatformAdapter: () => ({ + fetchMedia: (...args: unknown[]) => adapterMocks.fetchMedia(...args), + }), })); import { QQBOT_MEDIA_SSRF_POLICY, downloadFile } from "./file-utils.js"; @@ -17,7 +19,7 @@ describe("qqbot file-utils downloadFile", () => { let tempDir: string; beforeEach(async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockReset(); + adapterMocks.fetchMedia.mockReset(); tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "qqbot-file-utils-")); }); @@ -25,8 +27,8 @@ describe("qqbot file-utils downloadFile", () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); - it("downloads through the guarded media runtime with the qqbot SSRF policy", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + it("downloads through the guarded media adapter with the qqbot SSRF policy", async () => { + adapterMocks.fetchMedia.mockResolvedValueOnce({ buffer: Buffer.from("image-bytes"), contentType: "image/png", fileName: "remote.png", @@ -41,7 +43,7 @@ describe("qqbot file-utils downloadFile", () => { expect(savedPath).toBeTruthy(); expect(savedPath).toMatch(/photo_\d+_[0-9a-f]{6}\.png$/); expect(await fs.promises.readFile(savedPath!, "utf8")).toBe("image-bytes"); - expect(mediaRuntimeMocks.fetchRemoteMedia).toHaveBeenCalledWith({ + expect(adapterMocks.fetchMedia).toHaveBeenCalledWith({ url: "https://media.qq.com/assets/photo.png", filePathHint: "photo.png", ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY, @@ -65,6 +67,6 @@ describe("qqbot file-utils downloadFile", () => { const savedPath = await downloadFile("http://media.qq.com/assets/photo.png", tempDir); expect(savedPath).toBeNull(); - expect(mediaRuntimeMocks.fetchRemoteMedia).not.toHaveBeenCalled(); + expect(adapterMocks.fetchMedia).not.toHaveBeenCalled(); }); }); diff --git a/extensions/qqbot/src/utils/file-utils.ts b/extensions/qqbot/src/engine/utils/file-utils.ts similarity index 84% rename from extensions/qqbot/src/utils/file-utils.ts rename to extensions/qqbot/src/engine/utils/file-utils.ts index 63020a6bc8c..0d29f06ed34 100644 --- a/extensions/qqbot/src/utils/file-utils.ts +++ b/extensions/qqbot/src/engine/utils/file-utils.ts @@ -1,13 +1,10 @@ import crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; -import { fetchRemoteMedia } from "./file-utils-runtime.js"; +import { getPlatformAdapter } from "../adapter/index.js"; +import type { SsrfPolicyConfig } from "../adapter/types.js"; +import { formatErrorMessage } from "./format.js"; +import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-normalize.js"; /** Maximum file size accepted by the QQ Bot API. */ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; @@ -16,22 +13,22 @@ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; const QQBOT_MEDIA_HOSTNAME_ALLOWLIST = [ - // QQ富媒体 + // QQ rich media "*.qpic.cn", "*.qq.com", "*.weiyun.com", "*.qq.com.cn", - // QQ机器人 + // QQ Bot "*.ugcimg.cn", - // 腾讯云COS + // Tencent Cloud COS "*.myqcloud.com", "*.tencentcos.cn", "*.tencentcos.com", ]; -export const QQBOT_MEDIA_SSRF_POLICY: SsrFPolicy = { +export const QQBOT_MEDIA_SSRF_POLICY: SsrfPolicyConfig = { hostnameAllowlist: QQBOT_MEDIA_HOSTNAME_ALLOWLIST, allowRfc2544BenchmarkRange: true, }; @@ -152,7 +149,7 @@ export async function downloadFile( fs.mkdirSync(destDir, { recursive: true }); } - const fetched = await fetchRemoteMedia({ + const fetched = await getPlatformAdapter().fetchMedia({ url: parsedUrl.toString(), filePathHint: originalFilename, ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY, @@ -174,7 +171,16 @@ export async function downloadFile( const destPath = path.join(destDir, safeFilename); await fs.promises.writeFile(destPath, fetched.buffer); return destPath; - } catch { + } catch (err) { + console.error( + `[qqbot:downloadFile] FAILED url=${url.slice(0, 120)} error=${err instanceof Error ? err.message : String(err)}`, + ); + if (err instanceof Error && err.stack) { + console.error(`[qqbot:downloadFile] stack=${err.stack.split("\n").slice(0, 3).join(" | ")}`); + } + if (err instanceof Error && err.cause) { + console.error(`[qqbot:downloadFile] cause=${formatErrorMessage(err.cause)}`); + } return null; } } diff --git a/extensions/qqbot/src/engine/utils/format.test.ts b/extensions/qqbot/src/engine/utils/format.test.ts new file mode 100644 index 00000000000..452ebc9577c --- /dev/null +++ b/extensions/qqbot/src/engine/utils/format.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { formatErrorMessage, formatDuration } from "./format.js"; + +describe("engine/utils/format", () => { + describe("formatErrorMessage", () => { + it("extracts message from Error instances", () => { + expect(formatErrorMessage(new Error("boom"))).toBe("boom"); + }); + + it("returns strings as-is", () => { + expect(formatErrorMessage("plain text")).toBe("plain text"); + }); + + it("traverses the .cause chain", () => { + const inner = new Error("inner"); + const outer = new Error("outer", { cause: inner }); + expect(formatErrorMessage(outer)).toBe("outer | inner"); + }); + + it("handles string cause", () => { + const err = new Error("outer", { cause: "string cause" }); + expect(formatErrorMessage(err)).toBe("outer | string cause"); + }); + + it("stringifies numbers", () => { + expect(formatErrorMessage(42)).toBe("42"); + }); + + it("stringifies null", () => { + expect(formatErrorMessage(null)).toBe("null"); + }); + + it("stringifies undefined", () => { + expect(formatErrorMessage(undefined)).toBe("undefined"); + }); + + it("JSON-stringifies plain objects", () => { + expect(formatErrorMessage({ code: 500 })).toBe('{"code":500}'); + }); + }); + + describe("formatDuration", () => { + it("formats zero", () => { + expect(formatDuration(0)).toBe("0s"); + }); + + it("formats sub-minute durations as seconds", () => { + expect(formatDuration(45_000)).toBe("45s"); + }); + + it("formats exactly 60 seconds as 1m", () => { + expect(formatDuration(60_000)).toBe("1m"); + }); + + it("formats mixed minutes and seconds", () => { + expect(formatDuration(90_000)).toBe("1m 30s"); + }); + + it("formats exact minutes without trailing seconds", () => { + expect(formatDuration(300_000)).toBe("5m"); + }); + + it("rounds sub-second values", () => { + expect(formatDuration(1_499)).toBe("1s"); + expect(formatDuration(1_500)).toBe("2s"); + }); + }); +}); diff --git a/extensions/qqbot/src/engine/utils/format.ts b/extensions/qqbot/src/engine/utils/format.ts new file mode 100644 index 00000000000..b4b52211f33 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/format.ts @@ -0,0 +1,70 @@ +/** + * General formatting and string utilities. + * 通用格式化与字符串工具。 + * + * Pure utility functions with zero external dependencies. + * Replaces `openclaw/plugin-sdk/error-runtime` and `text-runtime` + * helpers for use inside engine/. + * + * NOTE: The framework `formatErrorMessage` also applies `redactSensitiveText()` + * for token masking. We intentionally omit that here — the framework's log + * pipeline handles redaction at a higher level. + */ + +/** + * Format any error object into a readable string. + * 将任意错误对象格式化为可读字符串。 + * + * Traverses the `.cause` chain for nested Error objects to include + * the full error context (e.g. network errors wrapped inside HTTP errors). + */ +export function formatErrorMessage(err: unknown): string { + if (err instanceof Error) { + let formatted = err.message || err.name || "Error"; + let cause: unknown = err.cause; + const seen = new Set([err]); + while (cause && !seen.has(cause)) { + seen.add(cause); + if (cause instanceof Error) { + if (cause.message) { + formatted += ` | ${cause.message}`; + } + cause = cause.cause; + } else if (typeof cause === "string") { + formatted += ` | ${cause}`; + break; + } else { + break; + } + } + return formatted; + } + if (typeof err === "string") { + return err; + } + if ( + err === null || + err === undefined || + typeof err === "number" || + typeof err === "boolean" || + typeof err === "bigint" + ) { + return String(err); + } + try { + return JSON.stringify(err); + } catch { + return Object.prototype.toString.call(err); + } +} + +/** Format a millisecond duration into a human-readable string (e.g. "5m 30s"). */ +export function formatDuration(durationMs: number): string { + const seconds = Math.round(durationMs / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainSeconds = seconds % 60; + return remainSeconds > 0 ? `${minutes}m ${remainSeconds}s` : `${minutes}m`; +} diff --git a/extensions/qqbot/src/utils/image-size.test.ts b/extensions/qqbot/src/engine/utils/image-size.test.ts similarity index 64% rename from extensions/qqbot/src/utils/image-size.test.ts rename to extensions/qqbot/src/engine/utils/image-size.test.ts index 9bab2c6239d..47727a630c1 100644 --- a/extensions/qqbot/src/utils/image-size.test.ts +++ b/extensions/qqbot/src/engine/utils/image-size.test.ts @@ -1,12 +1,14 @@ import { Buffer } from "buffer"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const mediaRuntimeMocks = vi.hoisted(() => ({ - fetchRemoteMedia: vi.fn(), +const adapterMocks = vi.hoisted(() => ({ + fetchMedia: vi.fn(), })); -vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ - fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args), +vi.mock("../adapter/index.js", () => ({ + getPlatformAdapter: () => ({ + fetchMedia: (...args: unknown[]) => adapterMocks.fetchMedia(...args), + }), })); import { getImageSizeFromUrl, parseImageSize } from "./image-size.js"; @@ -35,20 +37,20 @@ function buildPngHeader(width: number, height: number): Buffer { describe("getImageSizeFromUrl", () => { beforeEach(() => { - mediaRuntimeMocks.fetchRemoteMedia.mockReset(); + adapterMocks.fetchMedia.mockReset(); }); - describe("fetchRemoteMedia options contract", () => { + describe("fetchMedia options contract", () => { it("passes maxBytes, maxRedirects, ssrfPolicy, and headers", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + adapterMocks.fetchMedia.mockResolvedValueOnce({ buffer: buildPngHeader(800, 600), contentType: "image/png", }); await getImageSizeFromUrl("https://cdn.example.com/photo.png"); - expect(mediaRuntimeMocks.fetchRemoteMedia).toHaveBeenCalledOnce(); - const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0]; + expect(adapterMocks.fetchMedia).toHaveBeenCalledOnce(); + const opts = adapterMocks.fetchMedia.mock.calls[0][0]; expect(opts.url).toBe("https://cdn.example.com/photo.png"); expect(opts.maxBytes).toBe(65_536); @@ -62,60 +64,52 @@ describe("getImageSizeFromUrl", () => { }); it("threads caller abort signal through requestInit", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + adapterMocks.fetchMedia.mockResolvedValueOnce({ buffer: buildPngHeader(100, 100), }); await getImageSizeFromUrl("https://cdn.example.com/img.png", 3000); - const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0]; + const opts = adapterMocks.fetchMedia.mock.calls[0][0]; expect(opts.requestInit.signal).toBeInstanceOf(AbortSignal); }); }); - describe("SSRF blocking (fetchRemoteMedia rejects)", () => { - it("returns null when fetchRemoteMedia throws for loopback", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce( - new Error("SSRF blocked: loopback address"), - ); + describe("SSRF blocking (adapter.fetchMedia rejects)", () => { + it("returns null when adapter.fetchMedia throws for loopback", async () => { + adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: loopback address")); const result = await getImageSizeFromUrl("https://127.0.0.1/img.png"); expect(result).toBeNull(); }); - it("returns null when fetchRemoteMedia throws for IPv6 loopback", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce( - new Error("SSRF blocked: loopback address"), - ); + it("returns null when adapter.fetchMedia throws for IPv6 loopback", async () => { + adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: loopback address")); const result = await getImageSizeFromUrl("https://[::1]/img.png"); expect(result).toBeNull(); }); - it("returns null when fetchRemoteMedia throws for link-local/metadata", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce( - new Error("SSRF blocked: link-local address"), - ); + it("returns null when adapter.fetchMedia throws for link-local/metadata", async () => { + adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: link-local address")); const result = await getImageSizeFromUrl("https://169.254.169.254/latest/meta-data/"); expect(result).toBeNull(); }); - it("returns null when fetchRemoteMedia throws for RFC1918 addresses", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce( - new Error("SSRF blocked: private address"), - ); + it("returns null when adapter.fetchMedia throws for RFC1918 addresses", async () => { + adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: private address")); const result = await getImageSizeFromUrl("https://10.0.0.1/img.png"); expect(result).toBeNull(); }); - it("returns null on http error from fetchRemoteMedia", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(new Error("HTTP 403 Forbidden")); + it("returns null on http error from adapter.fetchMedia", async () => { + adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("HTTP 403 Forbidden")); const result = await getImageSizeFromUrl("https://cdn.example.com/forbidden.png"); @@ -125,7 +119,7 @@ describe("getImageSizeFromUrl", () => { describe("happy path", () => { it("returns parsed dimensions for a valid PNG", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + adapterMocks.fetchMedia.mockResolvedValueOnce({ buffer: buildPngHeader(1920, 1080), contentType: "image/png", }); @@ -136,7 +130,7 @@ describe("getImageSizeFromUrl", () => { }); it("returns null when the buffer is not a recognized image format", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + adapterMocks.fetchMedia.mockResolvedValueOnce({ buffer: Buffer.from("not an image"), contentType: "text/html", }); diff --git a/extensions/qqbot/src/utils/image-size.ts b/extensions/qqbot/src/engine/utils/image-size.ts similarity index 94% rename from extensions/qqbot/src/utils/image-size.ts rename to extensions/qqbot/src/engine/utils/image-size.ts index b488bcbe894..9838eda84ac 100644 --- a/extensions/qqbot/src/utils/image-size.ts +++ b/extensions/qqbot/src/engine/utils/image-size.ts @@ -5,9 +5,10 @@ */ import { Buffer } from "buffer"; -import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; -import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { debugLog } from "./debug-log.js"; +import { getPlatformAdapter } from "../adapter/index.js"; +import type { SsrfPolicyConfig } from "../adapter/types.js"; +import { formatErrorMessage } from "./format.js"; +import { debugLog } from "./log.js"; export interface ImageSize { width: number; @@ -150,7 +151,7 @@ export function parseImageSize(buffer: Buffer): ImageSize | null { * (no hostname allowlist) because markdown image URLs can legitimately point to * any public host, not just QQ-owned CDNs. */ -const IMAGE_PROBE_SSRF_POLICY: SsrFPolicy = {}; +const IMAGE_PROBE_SSRF_POLICY: SsrfPolicyConfig = {}; /** * Fetch image dimensions from a public URL using only the first 64 KB. @@ -167,7 +168,7 @@ export async function getImageSizeFromUrl( const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { - const { buffer } = await fetchRemoteMedia({ + const { buffer } = await getPlatformAdapter().fetchMedia({ url, maxBytes: 65_536, maxRedirects: 0, @@ -192,7 +193,7 @@ export async function getImageSizeFromUrl( clearTimeout(timeoutId); } } catch (err) { - debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${String(err)}`); + debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${formatErrorMessage(err)}`); return null; } } @@ -216,7 +217,7 @@ export function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null { return size; } catch (err) { - debugLog(`[image-size] Error parsing Base64: ${String(err)}`); + debugLog(`[image-size] Error parsing Base64: ${formatErrorMessage(err)}`); return null; } } diff --git a/extensions/qqbot/src/engine/utils/log.ts b/extensions/qqbot/src/engine/utils/log.ts new file mode 100644 index 00000000000..dd054bdd92e --- /dev/null +++ b/extensions/qqbot/src/engine/utils/log.ts @@ -0,0 +1,32 @@ +/** + * QQBot debug logging utilities. + * QQBot 调试日志工具。 + * + * Only outputs when the QQBOT_DEBUG environment variable is set, + * preventing user message content from leaking in production logs. + * + * Self-contained within engine/ — no framework SDK dependency. + */ + +const isDebug = () => !!process.env.QQBOT_DEBUG; + +/** Debug-level log; only outputs when QQBOT_DEBUG is enabled. */ +export function debugLog(...args: unknown[]): void { + if (isDebug()) { + console.log(...args); + } +} + +/** Debug-level warning; only outputs when QQBOT_DEBUG is enabled. */ +export function debugWarn(...args: unknown[]): void { + if (isDebug()) { + console.warn(...args); + } +} + +/** Debug-level error; only outputs when QQBOT_DEBUG is enabled. */ +export function debugError(...args: unknown[]): void { + if (isDebug()) { + console.error(...args); + } +} diff --git a/extensions/qqbot/src/utils/media-tags.test.ts b/extensions/qqbot/src/engine/utils/media-tags.test.ts similarity index 100% rename from extensions/qqbot/src/utils/media-tags.test.ts rename to extensions/qqbot/src/engine/utils/media-tags.test.ts diff --git a/extensions/qqbot/src/utils/media-tags.ts b/extensions/qqbot/src/engine/utils/media-tags.ts similarity index 76% rename from extensions/qqbot/src/utils/media-tags.ts rename to extensions/qqbot/src/engine/utils/media-tags.ts index e57c2a93caf..efc7324a0aa 100644 --- a/extensions/qqbot/src/utils/media-tags.ts +++ b/extensions/qqbot/src/engine/utils/media-tags.ts @@ -1,4 +1,35 @@ -import { expandTilde } from "./platform.js"; +/** + * Media tag normalization for QQ Bot messages. + * + * Normalizes malformed ``, ``, etc. tags emitted by + * smaller models into canonical wrapped-tag format. + * + * Zero external dependencies. + */ + +/** Lowercase and trim a string, returning empty string for falsy input. */ +function lc(s: string): string { + return (s ?? "").toLowerCase().trim(); +} + +/** Expand `~` prefix to the process home directory. */ +function expandTilde(p: string): string { + if (!p) { + return p; + } + const home = + typeof process !== "undefined" ? (process.env.HOME ?? process.env.USERPROFILE) : undefined; + if (!home) { + return p; + } + if (p === "~") { + return home; + } + if (p.startsWith("~/") || p.startsWith("~\\")) { + return `${home}/${p.slice(2)}`; + } + return p; +} // Canonical media tags. `qqmedia` is the generic auto-routing tag. const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile", "qqmedia"] as const; @@ -48,8 +79,9 @@ ALL_TAG_NAMES.sort((a, b) => b.length - a.length); const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|"); -const LEFT_BRACKET = "(?:[<<<]|<)"; -const RIGHT_BRACKET = "(?:[>>>]|>)"; +const LEFT_BRACKET = "(?:[<\uff1c\u003c]|<)"; +const RIGHT_BRACKET = "(?:[>\uff1e\u003e]|>)"; + /** Match self-closing media-tag syntax with file/src/path/url attributes. */ export const SELF_CLOSING_TAG_REGEX = new RegExp( "`?" + @@ -57,12 +89,12 @@ export const SELF_CLOSING_TAG_REGEX = new RegExp( "\\s*(" + TAG_NAME_PATTERN + ")" + - "(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s<<>>>]*?[\"']?)*" + + "(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s\uff1c<>\uff1e>]*?[\"']?)*" + "\\s+(?:file|src|path|url)\\s*=\\s*" + "[\"']?" + - "([^\"'\\s>>]+?)" + + "([^\"'\\s>\uff1e]+?)" + "[\"']?" + - "(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s<<>>>]*?[\"']?)*" + + "(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s\uff1c<>\uff1e>]*?[\"']?)*" + "\\s*/?" + "\\s*" + RIGHT_BRACKET + @@ -79,7 +111,7 @@ export const FUZZY_MEDIA_TAG_REGEX = new RegExp( ")\\s*" + RIGHT_BRACKET + "[\"']?\\s*" + - "([^<<<>>\"'`]+?)" + + "([^<\uff1c<\uff1e>\"'`]+?)" + "\\s*[\"']?" + LEFT_BRACKET + "\\s*/?\\s*(?:" + @@ -92,7 +124,7 @@ export const FUZZY_MEDIA_TAG_REGEX = new RegExp( /** Normalize a raw tag name into the canonical tag set. */ function resolveTagName(raw: string): (typeof VALID_TAGS)[number] { - const lower = raw.trim().toLowerCase(); + const lower = lc(raw); if ((VALID_TAGS as readonly string[]).includes(lower)) { return lower as (typeof VALID_TAGS)[number]; } diff --git a/extensions/qqbot/src/utils/payload.ts b/extensions/qqbot/src/engine/utils/payload.ts similarity index 75% rename from extensions/qqbot/src/utils/payload.ts rename to extensions/qqbot/src/engine/utils/payload.ts index 5021d30bf8a..a5bf529d2ca 100644 --- a/extensions/qqbot/src/utils/payload.ts +++ b/extensions/qqbot/src/engine/utils/payload.ts @@ -1,10 +1,19 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +/** + * Structured payload parsing and encoding for QQ Bot messages. + * + * Handles `QQBOT_PAYLOAD:` (model-emitted structured payloads) and + * `QQBOT_CRON:` (persisted cron reminder payloads). + * + * Zero external dependencies. + */ + +import type { ChatScope } from "../types.js"; /** Structured reminder payload emitted by the model. */ export interface CronReminderPayload { type: "cron_reminder"; content: string; - targetType: "c2c" | "group"; + targetType: ChatScope; targetAddress: string; originalMessageId?: string; } @@ -31,34 +40,29 @@ export interface ParseResult { const PAYLOAD_PREFIX = "QQBOT_PAYLOAD:"; const CRON_PREFIX = "QQBOT_CRON:"; +function formatErr(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + /** Parse model output that may start with the QQ Bot structured payload prefix. */ export function parseQQBotPayload(text: string): ParseResult { const trimmedText = text.trim(); if (!trimmedText.startsWith(PAYLOAD_PREFIX)) { - return { - isPayload: false, - text: text, - }; + return { isPayload: false, text }; } const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim(); if (!jsonContent) { - return { - isPayload: true, - error: "Payload body is empty", - }; + return { isPayload: true, error: "Payload body is empty" }; } try { const payload = JSON.parse(jsonContent) as QQBotPayload; if (!payload.type) { - return { - isPayload: true, - error: "Payload is missing the type field", - }; + return { isPayload: true, error: "Payload is missing the type field" }; } if (payload.type === "cron_reminder") { @@ -78,15 +82,9 @@ export function parseQQBotPayload(text: string): ParseResult { } } - return { - isPayload: true, - payload, - }; + return { isPayload: true, payload }; } catch (e) { - return { - isPayload: true, - error: `Failed to parse JSON: ${formatErrorMessage(e)}`, - }; + return { isPayload: true, error: `Failed to parse JSON: ${formatErr(e)}` }; } } @@ -106,18 +104,13 @@ export function decodeCronPayload(message: string): { const trimmedMessage = message.trim(); if (!trimmedMessage.startsWith(CRON_PREFIX)) { - return { - isCronPayload: false, - }; + return { isCronPayload: false }; } const base64Content = trimmedMessage.slice(CRON_PREFIX.length); if (!base64Content) { - return { - isCronPayload: true, - error: "Cron payload body is empty", - }; + return { isCronPayload: true, error: "Cron payload body is empty" }; } try { @@ -132,21 +125,12 @@ export function decodeCronPayload(message: string): { } if (!payload.content || !payload.targetType || !payload.targetAddress) { - return { - isCronPayload: true, - error: "Cron payload is missing required fields", - }; + return { isCronPayload: true, error: "Cron payload is missing required fields" }; } - return { - isCronPayload: true, - payload, - }; + return { isCronPayload: true, payload }; } catch (e) { - return { - isCronPayload: true, - error: `Failed to decode cron payload: ${formatErrorMessage(e)}`, - }; + return { isCronPayload: true, error: `Failed to decode cron payload: ${formatErr(e)}` }; } } diff --git a/extensions/qqbot/src/utils/platform.test.ts b/extensions/qqbot/src/engine/utils/platform.test.ts similarity index 81% rename from extensions/qqbot/src/utils/platform.test.ts rename to extensions/qqbot/src/engine/utils/platform.test.ts index 208d6a507cd..ed4f64a392f 100644 --- a/extensions/qqbot/src/utils/platform.test.ts +++ b/extensions/qqbot/src/engine/utils/platform.test.ts @@ -96,6 +96,22 @@ describe("qqbot local media path remapping", () => { expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile)); }); + it("allows structured payload files inside sibling OpenClaw media subdirectories", () => { + // Core helpers such as `saveMediaBuffer(..., "outbound", ...)` place framework + // attachments under sibling directories of `media/qqbot/`. The plugin must + // trust the shared `~/.openclaw/media` root so auto-routed sends can access + // those files without the path-outside-storage guard firing. + const actualHome = getHomeDir(); + const outboundDir = path.join(actualHome, ".openclaw", "media", "outbound"); + fs.mkdirSync(outboundDir, { recursive: true }); + const outboundFile = fs.mkdtempSync(path.join(outboundDir, "qqbot-outbound-")); + const mediaFile = path.join(outboundFile, "tts.mp3"); + fs.writeFileSync(mediaFile, "audio", "utf8"); + createdPaths.push(outboundFile); + + expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile)); + }); + it("blocks structured payload files inside the QQ Bot data directory", () => { const { actualHome, testRootName } = createOpenClawTestRoot(); diff --git a/extensions/qqbot/src/utils/platform.ts b/extensions/qqbot/src/engine/utils/platform.ts similarity index 58% rename from extensions/qqbot/src/utils/platform.ts rename to extensions/qqbot/src/engine/utils/platform.ts index a4e59ffed60..bec897074f3 100644 --- a/extensions/qqbot/src/utils/platform.ts +++ b/extensions/qqbot/src/engine/utils/platform.ts @@ -1,35 +1,19 @@ /** - * Cross-platform compatibility helpers. + * Cross-platform path and detection helpers for core/ modules. * - * This module centralizes home/temp directory discovery, local-path checks, - * ffmpeg/ffprobe lookup, native-module compatibility checks, and startup diagnostics. + * Provides home/data/media directory helpers, platform detection, + * ffmpeg/silk-wasm availability checks — all without importing + * `openclaw/plugin-sdk`. The temp-directory fallback is delegated + * to the PlatformAdapter. */ import { execFile } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { debugLog, debugWarn } from "./debug-log.js"; - -// Basic platform information. - -export type PlatformType = "darwin" | "linux" | "win32" | "other"; - -export function getPlatform(): PlatformType { - const p = process.platform; - if (p === "darwin" || p === "linux" || p === "win32") { - return p; - } - return "other"; -} - -export function isWindows(): boolean { - return process.platform === "win32"; -} - -// Home directory helpers. +import { getPlatformAdapter } from "../adapter/index.js"; +import { formatErrorMessage } from "./format.js"; +import { debugLog, debugWarn } from "./log.js"; /** * Resolve the current user's home directory safely across platforms. @@ -37,7 +21,7 @@ export function isWindows(): boolean { * Priority: * 1. `os.homedir()` * 2. `$HOME` or `%USERPROFILE%` - * 3. the OpenClaw temp directory as a last resort + * 3. PlatformAdapter.getTempDir() as a last resort */ export function getHomeDir(): string { try { @@ -45,21 +29,19 @@ export function getHomeDir(): string { if (home && fs.existsSync(home)) { return home; } - } catch {} + } catch { + /* fallback */ + } - // Fall back to environment variables. const envHome = process.env.HOME || process.env.USERPROFILE; if (envHome && fs.existsSync(envHome)) { return envHome; } - // Final fallback. - return resolvePreferredOpenClawTmpDir(); + return getPlatformAdapter().getTempDir(); } -/** - * Return a path under `~/.openclaw/qqbot`, creating it on demand. - */ +/** Return a path under `~/.openclaw/qqbot`, creating it on demand. */ export function getQQBotDataDir(...subPaths: string[]): string { const dir = path.join(getHomeDir(), ".openclaw", "qqbot", ...subPaths); if (!fs.existsSync(dir)) { @@ -82,201 +64,42 @@ export function getQQBotMediaDir(...subPaths: string[]): string { return dir; } -// Temporary directory helpers. - -/** Return the preferred OpenClaw temp directory. */ -export function getTempDir(): string { - return resolvePreferredOpenClawTmpDir(); +/** + * Return `~/.openclaw/media`, OpenClaw's shared media root. + * + * This mirrors the directory that core's `buildMediaLocalRoots` exposes as an + * allowlisted location (see `openclaw/src/media/local-roots.ts`). Using it as a + * QQ Bot payload root lets the plugin trust framework-produced files that live + * in sibling subdirectories such as `outbound/` (written by + * `saveMediaBuffer(..., "outbound", ...)`) or `inbound/`, while still keeping + * the check anchored to a single, well-known directory. + */ +export function getOpenClawMediaDir(): string { + return path.join(getHomeDir(), ".openclaw", "media"); } -// Tilde expansion. +// ---- Basic platform information ---- -/** - * Expand `~` to the current user's home directory. - * - * Supports `~` and `~/...`. Other forms are returned unchanged. - */ -export function expandTilde(p: string): string { - if (!p) { +export type PlatformType = "darwin" | "linux" | "win32" | "other"; + +export function getPlatform(): PlatformType { + const p = process.platform; + if (p === "darwin" || p === "linux" || p === "win32") { return p; } - if (p === "~") { - return getHomeDir(); - } - if (p.startsWith("~/") || p.startsWith("~\\")) { - return path.join(getHomeDir(), p.slice(2)); - } - return p; + return "other"; } -/** - * Normalize a user-provided path by trimming, stripping `file://`, and expanding `~`. - */ -export function normalizePath(p: string): string { - let result = p.trim(); - // Strip the local file URI scheme. - if (result.startsWith("file://")) { - result = result.slice("file://".length); - // Decode URL-escaped paths when possible. - try { - result = decodeURIComponent(result); - } catch { - // Keep the raw string if decoding fails. - } - } - return expandTilde(result); +export function isWindows(): boolean { + return process.platform === "win32"; } -function isPathWithinRoot(candidate: string, root: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +/** Return the preferred temporary directory. */ +export function getTempDir(): string { + return getPlatformAdapter().getTempDir(); } -/** - * Remap legacy or hallucinated QQ Bot local media paths to real files when possible. - */ -export function resolveQQBotLocalMediaPath(p: string): string { - const normalized = normalizePath(p); - if (!isLocalPath(normalized) || fs.existsSync(normalized)) { - return normalized; - } - - const homeDir = getHomeDir(); - const mediaRoot = getQQBotMediaDir(); - const dataRoot = getQQBotDataDir(); - const workspaceRoot = path.join(homeDir, ".openclaw", "workspace", "qqbot"); - const candidateRoots = [ - { from: workspaceRoot, to: mediaRoot }, - { from: dataRoot, to: mediaRoot }, - { from: mediaRoot, to: dataRoot }, - ]; - - for (const { from, to } of candidateRoots) { - if (!isPathWithinRoot(normalized, from)) { - continue; - } - const relative = path.relative(from, normalized); - const candidate = path.join(to, relative); - if (fs.existsSync(candidate)) { - debugWarn(`[platform] Remapped missing QQBot media path ${normalized} -> ${candidate}`); - return candidate; - } - } - - return normalized; -} - -/** - * Resolve a structured-payload local file path and enforce that it stays within - * QQ Bot-owned storage roots. - */ -export function resolveQQBotPayloadLocalFilePath(p: string): string | null { - const candidate = resolveQQBotLocalMediaPath(p); - if (!candidate.trim()) { - return null; - } - - const resolvedCandidate = path.resolve(candidate); - if (!fs.existsSync(resolvedCandidate)) { - return null; - } - - const canonicalCandidate = fs.realpathSync(resolvedCandidate); - const allowedRoots = [getQQBotMediaDir()]; - - for (const root of allowedRoots) { - const resolvedRoot = path.resolve(root); - const canonicalRoot = fs.existsSync(resolvedRoot) - ? fs.realpathSync(resolvedRoot) - : resolvedRoot; - if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) { - return canonicalCandidate; - } - } - - return null; -} - -// Filename normalization. - -/** - * Normalize filenames into a UTF-8 form that the QQ Bot API accepts reliably. - * - * This decodes percent-escaped names, converts Unicode to NFC, and strips ASCII - * control characters. - */ -export function sanitizeFileName(name: string): string { - if (!name) { - return name; - } - - let result = name.trim(); - - // Decode percent-escaped names when they came from URLs. - if (result.includes("%")) { - try { - result = decodeURIComponent(result); - } catch { - // Keep the raw value if it is not valid percent-encoding. - } - } - - // Convert macOS-style NFD names into standard NFC form. - result = result.normalize("NFC"); - - // Drop ASCII control characters while keeping printable Unicode content. - result = result.replace(/\p{Cc}/gu, ""); - - return result; -} - -// Local path detection. - -/** - * Return true when the string looks like a local filesystem path rather than a URL. - */ -export function isLocalPath(p: string): boolean { - if (!p) { - return false; - } - // Local file URI. - if (p.startsWith("file://")) { - return true; - } - // Tilde-based Unix path. - if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) { - return true; - } - // Unix absolute path. - if (p.startsWith("/")) { - return true; - } - // Windows drive-letter path. - if (/^[a-zA-Z]:[\\/]/.test(p)) { - return true; - } - // Windows UNC path. - if (p.startsWith("\\\\")) { - return true; - } - // POSIX relative path. - if (p.startsWith("./") || p.startsWith("../")) { - return true; - } - // Windows relative path. - if (p.startsWith(".\\") || p.startsWith("..\\")) { - return true; - } - return false; -} - -/** Looser local-path heuristic used for markdown-extracted paths. */ -export function looksLikeLocalPath(p: string): boolean { - if (isLocalPath(p)) { - return true; - } - return /^(?:Users|home|tmp|var|private|[A-Z]:)/i.test(p); -} +// ---- ffmpeg detection ---- let _ffmpegPath: string | null | undefined; let _ffmpegCheckPromise: Promise | null = null; @@ -358,6 +181,8 @@ export function resetFfmpegCache(): void { _ffmpegCheckPromise = null; } +// ---- silk-wasm detection ---- + let _silkWasmAvailable: boolean | null = null; /** Check whether silk-wasm can run in the current environment. */ @@ -365,10 +190,8 @@ export async function checkSilkWasmAvailable(): Promise { if (_silkWasmAvailable !== null) { return _silkWasmAvailable; } - try { const { isSilk } = await import("silk-wasm"); - // Use an empty buffer as a cheap smoke test for WASM loading. isSilk(new Uint8Array(0)); _silkWasmAvailable = true; debugLog("[platform] silk-wasm: available"); @@ -379,100 +202,146 @@ export async function checkSilkWasmAvailable(): Promise { return _silkWasmAvailable; } -// Startup environment diagnostics. +// ---- Tilde expansion and path normalization ---- -export interface DiagnosticReport { - platform: string; - arch: string; - nodeVersion: string; - homeDir: string; - tempDir: string; - dataDir: string; - ffmpeg: string | null; - silkWasm: boolean; - warnings: string[]; +/** Expand `~` to the current user's home directory. */ +export function expandTilde(p: string): string { + if (!p) { + return p; + } + if (p === "~") { + return getHomeDir(); + } + if (p.startsWith("~/") || p.startsWith("~\\")) { + return path.join(getHomeDir(), p.slice(2)); + } + return p; +} + +/** Normalize a user-provided path by trimming, stripping `file://`, and expanding `~`. */ +export function normalizePath(p: string): string { + let result = p.trim(); + if (result.startsWith("file://")) { + result = result.slice("file://".length); + try { + result = decodeURIComponent(result); + } catch { + // Keep the raw string if decoding fails. + } + } + return expandTilde(result); +} + +// ---- Local path detection ---- + +/** Return true when the string looks like a local filesystem path rather than a URL. */ +export function isLocalPath(p: string): boolean { + if (!p) { + return false; + } + if (p.startsWith("file://")) { + return true; + } + if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) { + return true; + } + if (p.startsWith("/")) { + return true; + } + if (/^[a-zA-Z]:[\\/]/.test(p)) { + return true; + } + if (p.startsWith("\\\\")) { + return true; + } + if (p.startsWith("./") || p.startsWith("../")) { + return true; + } + if (p.startsWith(".\\") || p.startsWith("..\\")) { + return true; + } + return false; +} + +/** Looser local-path heuristic used for markdown-extracted paths. */ +export function looksLikeLocalPath(p: string): boolean { + if (isLocalPath(p)) { + return true; + } + return /^(?:Users|home|tmp|var|private|[A-Z]:)/i.test(p); +} + +// ---- QQBot media path resolution ---- + +function isPathWithinRoot(candidate: string, root: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +/** Remap legacy or hallucinated QQ Bot local media paths to real files when possible. */ +export function resolveQQBotLocalMediaPath(p: string): string { + const normalized = normalizePath(p); + if (!isLocalPath(normalized) || fs.existsSync(normalized)) { + return normalized; + } + + const homeDir = getHomeDir(); + const mediaRoot = getQQBotMediaDir(); + const dataRoot = getQQBotDataDir(); + const workspaceRoot = path.join(homeDir, ".openclaw", "workspace", "qqbot"); + const candidateRoots = [ + { from: workspaceRoot, to: mediaRoot }, + { from: dataRoot, to: mediaRoot }, + { from: mediaRoot, to: dataRoot }, + ]; + + for (const { from, to } of candidateRoots) { + if (!isPathWithinRoot(normalized, from)) { + continue; + } + const relative = path.relative(from, normalized); + const candidate = path.join(to, relative); + if (fs.existsSync(candidate)) { + debugWarn(`[platform] Remapped missing QQBot media path ${normalized} -> ${candidate}`); + return candidate; + } + } + + return normalized; } /** - * Run startup diagnostics and return an environment report. - * Called during gateway startup to log environment details and warnings. + * Resolve a structured-payload local file path and enforce that it stays within + * QQ Bot-owned storage roots. */ -export async function runDiagnostics(): Promise { - const warnings: string[] = []; - - const platform = `${process.platform} (${os.release()})`; - const arch = process.arch; - const nodeVersion = process.version; - const homeDir = getHomeDir(); - const tempDir = getTempDir(); - const dataDir = getQQBotDataDir(); - - // Check ffmpeg availability. - const ffmpegPath = await detectFfmpeg(); - if (!ffmpegPath) { - warnings.push( - isWindows() - ? "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with choco install ffmpeg, scoop install ffmpeg, or from https://ffmpeg.org." - : getPlatform() === "darwin" - ? "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with brew install ffmpeg." - : "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with sudo apt install ffmpeg or sudo yum install ffmpeg.", - ); +export function resolveQQBotPayloadLocalFilePath(p: string): string | null { + const candidate = resolveQQBotLocalMediaPath(p); + if (!candidate.trim()) { + return null; } - // Check silk-wasm availability. - const silkWasm = await checkSilkWasmAvailable(); - if (!silkWasm) { - warnings.push( - "⚠️ silk-wasm is unavailable. QQ voice send/receive will not work. Ensure Node.js >= 16 and WASM support are available.", - ); + const resolvedCandidate = path.resolve(candidate); + if (!fs.existsSync(resolvedCandidate)) { + return null; } - // Check whether the data directory is writable. - try { - const testFile = path.join(dataDir, ".write-test"); - fs.writeFileSync(testFile, "test"); - fs.unlinkSync(testFile); - } catch { - warnings.push(`⚠️ Data directory is not writable: ${dataDir}. Check filesystem permissions.`); - } + const canonicalCandidate = fs.realpathSync(resolvedCandidate); + // Trust both the QQ Bot-owned subdirectory and OpenClaw's shared `~/.openclaw/media` + // root. Core helpers like `saveMediaBuffer(..., "outbound", ...)` place framework + // attachments under sibling directories (e.g. `media/outbound/`) that are already + // part of the core media allowlist; we mirror that so auto-routed sends work + // without leaving the plugin's trust boundary. + const allowedRoots = [getOpenClawMediaDir(), getQQBotMediaDir()]; - // Windows-specific reminder. - if (isWindows()) { - // Chinese characters or spaces in the home path can break external tools. - if (/[\u4e00-\u9fa5]/.test(homeDir) || homeDir.includes(" ")) { - warnings.push( - `⚠️ Home directory contains Chinese characters or spaces: ${homeDir}. Some tools may fail. Consider setting QQBOT_DATA_DIR to an ASCII-only path.`, - ); + for (const root of allowedRoots) { + const resolvedRoot = path.resolve(root); + const canonicalRoot = fs.existsSync(resolvedRoot) + ? fs.realpathSync(resolvedRoot) + : resolvedRoot; + if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) { + return canonicalCandidate; } } - const report: DiagnosticReport = { - platform, - arch, - nodeVersion, - homeDir, - tempDir, - dataDir, - ffmpeg: ffmpegPath, - silkWasm, - warnings, - }; - - // Print the report once for startup visibility. - debugLog("=== QQBot Environment Diagnostics ==="); - debugLog(` Platform: ${platform} (${arch})`); - debugLog(` Node: ${nodeVersion}`); - debugLog(` Home: ${homeDir}`); - debugLog(` Data dir: ${dataDir}`); - debugLog(` ffmpeg: ${ffmpegPath ?? "not installed"}`); - debugLog(` silk-wasm: ${silkWasm ? "available" : "unavailable"}`); - if (warnings.length > 0) { - debugLog(" --- Warnings ---"); - for (const w of warnings) { - debugLog(` ${w}`); - } - } - debugLog("======================"); - - return report; + return null; } diff --git a/extensions/qqbot/src/engine/utils/request-context.ts b/extensions/qqbot/src/engine/utils/request-context.ts new file mode 100644 index 00000000000..ac579cdd0f9 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/request-context.ts @@ -0,0 +1,75 @@ +/** + * Request-level context using AsyncLocalStorage. + * + * Provides ambient context (accountId, target openid, chat type, etc.) + * throughout the request lifecycle without explicit parameter threading. + * + * Gateway establishes the scope around each inbound message via + * `runWithRequestContext()`; any async code within that scope (including + * AI agent calls and tool `execute` callbacks) can retrieve the current + * request via `getRequestContext()` without racing with concurrent + * inbound messages. + * + * This is a pure Node.js module with zero framework dependencies, + * making it trivially portable between the built-in and standalone + * versions of QQBot. + */ + +import { AsyncLocalStorage } from "node:async_hooks"; + +/** Context values available during one inbound message handling cycle. */ +export interface RequestContext { + /** The account ID handling this request. */ + accountId: string; + /** + * Fully qualified delivery target, e.g. `qqbot:c2c:` or + * `qqbot:group:`. This is what downstream code (e.g. the + * `qqbot_remind` tool building a cron job) uses verbatim. + */ + target?: string; + /** The target openid (C2C) or group openid (group). */ + targetId?: string; + /** Chat type of the originating event. */ + chatType?: "c2c" | "group" | "guild" | "dm" | "channel"; +} + +const store = new AsyncLocalStorage(); + +/** + * Execute an async function with request-scoped context. + * + * All code running within `fn` (including nested async calls) can + * retrieve the context via `getRequestContext()`. + * + * @param ctx - The context to attach to this request. + * @param fn - The async function to run within the context. + * @returns The return value of `fn`. + */ +export function runWithRequestContext(ctx: RequestContext, fn: () => T): T { + return store.run(ctx, fn); +} + +/** + * Retrieve the current request context. + * + * Returns `undefined` when called outside of a `runWithRequestContext` + * scope. + */ +export function getRequestContext(): RequestContext | undefined { + return store.getStore(); +} + +/** + * Convenience accessor for the current request's fully qualified + * delivery target. + */ +export function getRequestTarget(): string | undefined { + return store.getStore()?.target; +} + +/** + * Convenience accessor for the current request's account ID. + */ +export function getRequestAccountId(): string | undefined { + return store.getStore()?.accountId; +} diff --git a/extensions/qqbot/src/engine/utils/string-normalize.ts b/extensions/qqbot/src/engine/utils/string-normalize.ts new file mode 100644 index 00000000000..491479e78c6 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/string-normalize.ts @@ -0,0 +1,137 @@ +/** + * String normalization and record-coercion helpers. + * + * These are self-contained re-implementations of the functions that + * the plugin previously imported from `openclaw/plugin-sdk/text-runtime` + * and `openclaw/plugin-sdk/text-runtime` (via record-coerce / string-coerce). + * + * core/ modules use these instead of importing plugin-sdk, keeping the + * shared layer portable between the built-in and standalone versions. + */ + +// ---- String coercion ---- + +/** Return the trimmed string or `null` when the value is not a non-empty string. */ +export function normalizeNullableString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +/** Return the trimmed string or `undefined` when the value is not a non-empty string. */ +export function normalizeOptionalString(value: unknown): string | undefined { + return normalizeNullableString(value) ?? undefined; +} + +/** + * Stringify then normalize. Accepts `string | number | boolean | bigint`. + * Returns `undefined` for objects, arrays, null, and undefined. + */ +export function normalizeStringifiedOptionalString(value: unknown): string | undefined { + if (typeof value === "string") { + return normalizeOptionalString(value); + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return normalizeOptionalString(String(value)); + } + return undefined; +} + +/** Return the trimmed lowercase string or `undefined`. */ +export function normalizeOptionalLowercaseString(value: unknown): string | undefined { + return normalizeOptionalString(value)?.toLowerCase(); +} + +/** Return the trimmed lowercase string or `""`. */ +export function normalizeLowercaseStringOrEmpty(value: unknown): string { + return normalizeOptionalLowercaseString(value) ?? ""; +} + +/** Return the raw string value or `undefined`. No trimming. */ +export function readStringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** Return true when the value is a non-empty trimmed string. */ +export function hasNonEmptyString(value: unknown): value is string { + return normalizeOptionalString(value) !== undefined; +} + +// ---- Record coercion ---- + +/** Coerce a value into a `Record`, defaulting to `{}`. */ +export function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +/** Coerce a value into a `Record` or `undefined`. */ +export function asOptionalObjectRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +/** Read a string field from a record. */ +export function readStringField( + record: Record | null | undefined, + key: string, +): string | undefined { + const v = record?.[key]; + return typeof v === "string" ? v : undefined; +} + +/** Read a number field from a record. */ +export function readNumberField( + record: Record | null | undefined, + key: string, +): number | undefined { + const v = record?.[key]; + return typeof v === "number" ? v : undefined; +} + +/** Read a boolean field from a record. */ +export function readBooleanField( + record: Record | null | undefined, + key: string, +): boolean | undefined { + const v = record?.[key]; + return typeof v === "boolean" ? v : undefined; +} + +/** Coerce a value into a string→string map, filtering out non-string values. */ +export function readStringMap(value: unknown): Record { + const record = asOptionalObjectRecord(value); + if (!record) { + return {}; + } + return Object.fromEntries( + Object.entries(record).flatMap(([key, entryValue]) => + typeof entryValue === "string" ? [[key, entryValue]] : [], + ), + ); +} + +// ---- Filename normalization ---- + +/** + * Normalize filenames into a UTF-8 form that the QQ Bot API accepts reliably. + * + * Decodes percent-escaped names, converts Unicode to NFC, and strips + * ASCII control characters. + */ +export function sanitizeFileName(name: string): string { + if (!name) { + return name; + } + let result = name.trim(); + if (result.includes("%")) { + try { + result = decodeURIComponent(result); + } catch { + // Keep the raw value if it is not valid percent-encoding. + } + } + result = result.normalize("NFC"); + result = result.replace(/\p{Cc}/gu, ""); + return result; +} diff --git a/extensions/qqbot/src/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts similarity index 85% rename from extensions/qqbot/src/stt.ts rename to extensions/qqbot/src/engine/utils/stt.ts index 24801180263..30332995abd 100644 --- a/extensions/qqbot/src/stt.ts +++ b/extensions/qqbot/src/engine/utils/stt.ts @@ -1,14 +1,18 @@ /** - * OpenAI-compatible STT used at the plugin layer. + * OpenAI-compatible STT (Speech-to-Text) configuration and transcription. * - * This avoids pushing raw WAV PCM into the framework media-understanding pipeline. + * Migrated from `src/stt.ts` — uses core/utils/string-normalize instead + * of openclaw/plugin-sdk/text-runtime. */ import * as fs from "node:fs"; import path from "node:path"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { asRecord, readString } from "./config-record-shared.js"; -import { sanitizeFileName } from "./utils/platform.js"; +import { + normalizeOptionalString, + asOptionalObjectRecord as asRecord, + readStringField as readString, + sanitizeFileName, +} from "./string-normalize.js"; export interface STTConfig { baseUrl: string; @@ -16,6 +20,7 @@ export interface STTConfig { model: string; } +/** Resolve the STT configuration from the nested config object. */ export function resolveSTTConfig(cfg: Record): STTConfig | null { const channels = asRecord(cfg.channels); const qqbot = asRecord(channels?.qqbot); @@ -55,6 +60,7 @@ export function resolveSTTConfig(cfg: Record): STTConfig | null return null; } +/** Send audio to an OpenAI-compatible STT endpoint and return the transcript. */ export async function transcribeAudio( audioPath: string, cfg: Record, diff --git a/extensions/qqbot/src/engine/utils/text-chunk.ts b/extensions/qqbot/src/engine/utils/text-chunk.ts new file mode 100644 index 00000000000..ef4df0bb4f0 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/text-chunk.ts @@ -0,0 +1,39 @@ +/** + * Text chunking — core/ version. + * + * The actual chunking logic is provided by the framework runtime + * (`runtime.channel.text.chunkMarkdownText`). This module exposes a + * registerable adapter so core/ modules can call `chunkText()` without + * importing the plugin-sdk runtime store. + */ + +/** Maximum text length for a single QQ Bot message. */ +export const TEXT_CHUNK_LIMIT = 5000; + +/** Text chunker function signature. */ +export type ChunkTextFn = (text: string, limit: number) => string[]; + +let _chunkText: ChunkTextFn | null = null; + +/** Register the text chunker — called by the outer-layer startup. */ +export function registerTextChunker(fn: ChunkTextFn): void { + _chunkText = fn; +} + +/** + * Markdown-aware text chunking. + * + * Delegates to the registered chunker (framework runtime). + * Falls back to a naive split when no chunker is registered. + */ +export function chunkText(text: string, limit: number = TEXT_CHUNK_LIMIT): string[] { + if (_chunkText) { + return _chunkText(text, limit); + } + // Naive fallback: split by limit without markdown awareness. + const chunks: string[] = []; + for (let i = 0; i < text.length; i += limit) { + chunks.push(text.slice(i, i + limit)); + } + return chunks.length > 0 ? chunks : [text]; +} diff --git a/extensions/qqbot/src/utils/text-parsing.test.ts b/extensions/qqbot/src/engine/utils/text-parsing.test.ts similarity index 100% rename from extensions/qqbot/src/utils/text-parsing.test.ts rename to extensions/qqbot/src/engine/utils/text-parsing.test.ts diff --git a/extensions/qqbot/src/engine/utils/text-parsing.ts b/extensions/qqbot/src/engine/utils/text-parsing.ts new file mode 100644 index 00000000000..ee385ce142d --- /dev/null +++ b/extensions/qqbot/src/engine/utils/text-parsing.ts @@ -0,0 +1,155 @@ +/** + * Text parsing utilities — zero external dependency. + * + * Contains pure functions for message text processing. + */ + +import type { RefAttachmentSummary } from "../ref/types.js"; + +// ============ Internal markers ============ + +const INTERNAL_MARKER_RE = /\[internal:?\s*[^\]]*\]|\[debug:?\s*[^\]]*\]|\[system:?\s*[^\]]*\]/gi; + +/** Remove internal markers like `[internal:...]`, `[debug:...]`, `[system:...]`. */ +export function filterInternalMarkers(text: string | undefined | null): string { + if (!text) { + return ""; + } + return text.replace(INTERNAL_MARKER_RE, "").trim(); +} + +// ============ Ref indices ============ + +/** QQ 引用(回复)消息类型常量。 */ +export const MSG_TYPE_QUOTE = 103; + +/** + * Parse message_scene.ext to extract refMsgIdx and msgIdx. + * + * Supports both ext prefix formats: + * - `ref_msg_idx=` / `msg_idx=` (platform native format) + * - `refMsgIdx:` / `msgIdx:` (legacy internal format) + * + * When `messageType` equals `MSG_TYPE_QUOTE` (103) and `msgElements` is + * provided, `msgElements[0].msg_idx` takes precedence over the ext-parsed + * `refMsgIdx` value — the element-level index is more authoritative for + * quote messages. + */ +export function parseRefIndices( + ext?: string[], + messageType?: number, + msgElements?: Array<{ msg_idx?: string }>, +): { refMsgIdx?: string; msgIdx?: string } { + let refMsgIdx: string | undefined; + let msgIdx: string | undefined; + + if (ext && ext.length > 0) { + for (const item of ext) { + if (typeof item !== "string") { + continue; + } + // Platform native format: ref_msg_idx= / msg_idx= + if (item.startsWith("ref_msg_idx=")) { + refMsgIdx = item.slice("ref_msg_idx=".length).trim(); + } else if (item.startsWith("msg_idx=")) { + msgIdx = item.slice("msg_idx=".length).trim(); + } + // Legacy internal format: refMsgIdx: / msgIdx: + else if (item.startsWith("refMsgIdx:")) { + refMsgIdx = item.slice("refMsgIdx:".length).trim(); + } else if (item.startsWith("msgIdx:")) { + msgIdx = item.slice("msgIdx:".length).trim(); + } + } + } + + // For quote messages, msg_elements[0].msg_idx is more authoritative. + if (messageType === MSG_TYPE_QUOTE) { + const refElement = msgElements?.[0]; + if (refElement?.msg_idx) { + refMsgIdx = refElement.msg_idx; + } + } + + return { refMsgIdx, msgIdx }; +} + +// ============ Face tags ============ + +const MAX_FACE_EXT_BYTES = 64 * 1024; + +/** Estimate Base64 decoded byte size (replaces plugin-sdk estimateBase64DecodedBytes). */ +function estimateBase64Size(base64: string): number { + const len = base64.length; + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.ceil((len * 3) / 4) - padding; +} + +/** Replace QQ face tags with readable text labels. */ +export function parseFaceTags(text: string | undefined | null): string { + if (!text) { + return ""; + } + + return text.replace(//g, (_match, ext: string) => { + try { + if (estimateBase64Size(ext) > MAX_FACE_EXT_BYTES) { + return "[Emoji: unknown emoji]"; + } + const decoded = Buffer.from(ext, "base64").toString("utf-8"); + const parsed = JSON.parse(decoded); + const faceName = parsed.text || "unknown emoji"; + return `[Emoji: ${faceName}]`; + } catch { + return _match; + } + }); +} + +// ============ Attachment summaries ============ + +/** Lowercase a string safely (replaces plugin-sdk normalizeLowercaseStringOrEmpty). */ +function lc(s: string | undefined | null): string { + return (s ?? "").toLowerCase(); +} + +/** Build attachment summaries for ref-index caching. */ +export function buildAttachmentSummaries( + attachments?: Array<{ + content_type: string; + url: string; + filename?: string; + voice_wav_url?: string; + }>, + localPaths?: Array, +): RefAttachmentSummary[] | undefined { + if (!attachments || attachments.length === 0) { + return undefined; + } + + return attachments.map((att, idx) => { + const ct = lc(att.content_type); + let type: RefAttachmentSummary["type"] = "unknown"; + if (ct.startsWith("image/")) { + type = "image"; + } else if ( + ct === "voice" || + ct.startsWith("audio/") || + ct.includes("silk") || + ct.includes("amr") + ) { + type = "voice"; + } else if (ct.startsWith("video/")) { + type = "video"; + } else if (ct.startsWith("application/") || ct.startsWith("text/")) { + type = "file"; + } + + return { + type, + filename: att.filename, + contentType: att.content_type, + localPath: localPaths?.[idx] ?? undefined, + }; + }); +} diff --git a/extensions/qqbot/src/utils/upload-cache.ts b/extensions/qqbot/src/engine/utils/upload-cache.ts similarity index 95% rename from extensions/qqbot/src/utils/upload-cache.ts rename to extensions/qqbot/src/engine/utils/upload-cache.ts index f659da6ef48..fcb912abd97 100644 --- a/extensions/qqbot/src/utils/upload-cache.ts +++ b/extensions/qqbot/src/engine/utils/upload-cache.ts @@ -4,7 +4,8 @@ */ import * as crypto from "node:crypto"; -import { debugLog } from "./debug-log.js"; +import type { ChatScope } from "../types.js"; +import { debugLog } from "./log.js"; interface CacheEntry { fileInfo: string; @@ -34,7 +35,7 @@ function buildCacheKey( /** Look up a cached `file_info` value. */ export function getCachedFileInfo( contentHash: string, - scope: "c2c" | "group", + scope: ChatScope, targetId: string, fileType: number, ): string | null { @@ -57,7 +58,7 @@ export function getCachedFileInfo( /** Store an upload result in the cache. */ export function setCachedFileInfo( contentHash: string, - scope: "c2c" | "group", + scope: ChatScope, targetId: string, fileType: number, fileInfo: string, diff --git a/extensions/qqbot/src/engine/utils/voice-text.ts b/extensions/qqbot/src/engine/utils/voice-text.ts new file mode 100644 index 00000000000..3e2d22cb5de --- /dev/null +++ b/extensions/qqbot/src/engine/utils/voice-text.ts @@ -0,0 +1,15 @@ +/** + * Voice transcript formatting utility. + * + * Zero external dependencies — pure string formatting. + */ + +/** 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"); +} diff --git a/extensions/qqbot/src/exec-approvals.ts b/extensions/qqbot/src/exec-approvals.ts new file mode 100644 index 00000000000..f63a1f9e8a9 --- /dev/null +++ b/extensions/qqbot/src/exec-approvals.ts @@ -0,0 +1,226 @@ +import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime"; +import { + createChannelExecApprovalProfile, + isChannelExecApprovalClientEnabledFromConfig, + matchesApprovalRequestFilters, +} from "openclaw/plugin-sdk/approval-client-runtime"; +import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; +import { listQQBotAccountIds, resolveQQBotAccount } from "./bridge/config.js"; +import type { QQBotExecApprovalConfig } from "./types.js"; + +function normalizeApproverId(value: string | number): string | undefined { + const trimmed = normalizeOptionalString(String(value)); + return trimmed || undefined; +} + +export function resolveQQBotExecApprovalConfig(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): QQBotExecApprovalConfig | undefined { + const account = resolveQQBotAccount(params.cfg, params.accountId); + const config = account.config.execApprovals; + if (!config) { + return undefined; + } + return { + ...config, + enabled: account.enabled && account.secretSource !== "none" ? config.enabled : false, + }; +} + +export function getQQBotExecApprovalApprovers(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + const accountConfig = resolveQQBotAccount(params.cfg, params.accountId).config; + return resolveApprovalApprovers({ + explicit: resolveQQBotExecApprovalConfig(params)?.approvers, + allowFrom: accountConfig.allowFrom, + normalizeApprover: normalizeApproverId, + }); +} + +function countQQBotExecApprovalEligibleAccounts(params: { + cfg: OpenClawConfig; + request: ExecApprovalRequest | PluginApprovalRequest; +}): number { + return listQQBotAccountIds(params.cfg).filter((accountId) => { + const account = resolveQQBotAccount(params.cfg, accountId); + if (!account.enabled || account.secretSource === "none") { + return false; + } + const config = resolveQQBotExecApprovalConfig({ + cfg: params.cfg, + accountId, + }); + return ( + isChannelExecApprovalClientEnabledFromConfig({ + enabled: config?.enabled, + approverCount: getQQBotExecApprovalApprovers({ cfg: params.cfg, accountId }).length, + }) && + matchesApprovalRequestFilters({ + request: params.request.request, + agentFilter: config?.agentFilter, + sessionFilter: config?.sessionFilter, + fallbackAgentIdFromSessionKey: true, + }) + ); + }).length; +} + +function matchesQQBotRequestAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + request: ExecApprovalRequest | PluginApprovalRequest; +}): boolean { + const turnSourceChannel = normalizeLowercaseStringOrEmpty( + params.request.request.turnSourceChannel, + ); + const boundAccountId = resolveApprovalRequestChannelAccountId({ + cfg: params.cfg, + request: params.request, + channel: "qqbot", + }); + if (turnSourceChannel && turnSourceChannel !== "qqbot" && !boundAccountId) { + return ( + countQQBotExecApprovalEligibleAccounts({ + cfg: params.cfg, + request: params.request, + }) <= 1 + ); + } + return ( + !boundAccountId || + !params.accountId || + normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId) + ); +} + +/** + * Count QQBot accounts that could actually deliver a native approval + * message — i.e. accounts that are enabled and have resolvable secrets. + * Disabled or unconfigured accounts never spawn a handler, so they + * must not contribute to the single-account shortcut in the fallback + * ownership check below. + */ +function countQQBotFallbackEligibleAccounts(cfg: OpenClawConfig): number { + return listQQBotAccountIds(cfg).filter((accountId) => { + const account = resolveQQBotAccount(cfg, accountId); + return account.enabled && account.secretSource !== "none"; + }).length; +} + +/** + * Fallback account-ownership check — applied when `execApprovals` is NOT + * configured for any QQBot account. In this mode every enabled account + * handler would otherwise race to deliver the same approval to its own + * openid namespace, so we must enforce per-account isolation. + * + * Rules: + * - If the request carries a bound account (via `turnSourceAccountId` + * or session binding), only the handler whose `accountId` matches it + * delivers the approval. This is strict: a handler with an unknown + * `accountId` (null/undefined) must not claim a bound request. + * - If no account is bound, only deliver when there is a single + * *eligible* QQBot account (enabled + secret resolved). Disabled or + * unconfigured accounts never deliver anyway, so they shouldn't + * block the remaining single account from handling the approval. + * Multiple eligible accounts cannot safely race because openids are + * account-scoped — cross-account delivery hits the QQ Bot API with + * a mismatched token and fails. + */ +function matchesQQBotFallbackRequestAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + request: ExecApprovalRequest | PluginApprovalRequest; +}): boolean { + const boundAccountId = resolveApprovalRequestChannelAccountId({ + cfg: params.cfg, + request: params.request, + channel: "qqbot", + }); + + if (boundAccountId) { + if (!params.accountId) { + return false; + } + return normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId); + } + + return countQQBotFallbackEligibleAccounts(params.cfg) <= 1; +} + +/** + * Minimal structural shape required to evaluate per-account ownership. + * + * The SDK types (`ExecApprovalRequest` / `PluginApprovalRequest`) and the + * channel-local approval request types (see `engine/approval/index.ts`) + * share the same logical fields but differ on bookkeeping metadata + * (e.g. `createdAtMs`), so we accept any object exposing the relevant + * routing fields. Consumers can pass either flavor safely. + */ +type QQBotApprovalAccountOwnershipRequest = { + request: { + sessionKey?: string | null; + turnSourceChannel?: string | null; + turnSourceTo?: string | null; + turnSourceAccountId?: string | null; + }; +}; + +/** + * Unified per-account ownership check used by both the profile and + * fallback approval paths. Dispatches to the profile rules when the + * current account has `execApprovals` configured, otherwise uses the + * fallback rules. + * + * This is the single source of truth for "does this QQBot handler own + * this approval request?" and is consumed by both the capability + * gate (shouldHandle) and the lazy native runtime adapter. + */ +export function matchesQQBotApprovalAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + request: QQBotApprovalAccountOwnershipRequest; +}): boolean { + const normalized = { + cfg: params.cfg, + accountId: params.accountId, + request: params.request as unknown as ExecApprovalRequest | PluginApprovalRequest, + }; + if (resolveQQBotExecApprovalConfig(normalized) !== undefined) { + return matchesQQBotRequestAccount(normalized); + } + return matchesQQBotFallbackRequestAccount(normalized); +} + +const qqbotExecApprovalProfile = createChannelExecApprovalProfile({ + resolveConfig: resolveQQBotExecApprovalConfig, + resolveApprovers: getQQBotExecApprovalApprovers, + matchesRequestAccount: matchesQQBotRequestAccount, + fallbackAgentIdFromSessionKey: true, + requireClientEnabledForLocalPromptSuppression: false, +}); + +export const isQQBotExecApprovalClientEnabled = qqbotExecApprovalProfile.isClientEnabled; +export const isQQBotExecApprovalApprover = qqbotExecApprovalProfile.isApprover; +export const isQQBotExecApprovalAuthorizedSender = qqbotExecApprovalProfile.isAuthorizedSender; +export const resolveQQBotExecApprovalTarget = qqbotExecApprovalProfile.resolveTarget; +export const shouldHandleQQBotExecApprovalRequest = qqbotExecApprovalProfile.shouldHandleRequest; + +export function isQQBotExecApprovalHandlerConfigured(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + return isChannelExecApprovalClientEnabledFromConfig({ + enabled: resolveQQBotExecApprovalConfig(params)?.enabled, + approverCount: getQQBotExecApprovalApprovers(params).length, + }); +} diff --git a/extensions/qqbot/src/gateway.ts b/extensions/qqbot/src/gateway.ts deleted file mode 100644 index f6111387f8f..00000000000 --- a/extensions/qqbot/src/gateway.ts +++ /dev/null @@ -1,1530 +0,0 @@ -import path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import WebSocket from "ws"; -import { - clearTokenCache, - getAccessToken, - getGatewayUrl, - initApiConfig, - onMessageSent, - PLUGIN_USER_AGENT, - sendC2CInputNotify, - sendC2CMessage, - sendChannelMessage, - sendDmMessage, - sendGroupMessage, - startBackgroundTokenRefresh, - stopBackgroundTokenRefresh, -} from "./api.js"; -import { formatQQBotAllowFrom } from "./channel-config-shared.js"; -import { formatVoiceText, processAttachments } from "./inbound-attachments.js"; -import { flushKnownUsers, recordKnownUser } from "./known-users.js"; -import { createMessageQueue, type QueuedMessage } from "./message-queue.js"; -import { - parseAndSendMediaTags, - sendPlainReply, - type DeliverAccountContext, - type DeliverEventContext, -} from "./outbound-deliver.js"; -import { sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js"; -import { - flushRefIndex, - formatRefEntryForAgent, - getRefIndex, - setRefIndex, - type RefAttachmentSummary, -} from "./ref-index-store.js"; -import { - handleStructuredPayload, - sendErrorToTarget, - sendWithTokenRetry, - type MessageTarget, - type ReplyContext, -} from "./reply-dispatcher.js"; -import { getQQBotRuntime } from "./runtime.js"; -import { clearSession, loadSession, saveSession } from "./session-store.js"; -import { matchSlashCommand, type SlashCommandContext } from "./slash-commands.js"; -import type { - C2CMessageEvent, - GroupMessageEvent, - GuildMessageEvent, - ResolvedQQBotAccount, - WSPayload, -} from "./types.js"; -import { TYPING_INPUT_SECOND, TypingKeepAlive } from "./typing-keepalive.js"; -import { isGlobalTTSAvailable, resolveTTSConfig } from "./utils/audio-convert.js"; -import { runDiagnostics } from "./utils/platform.js"; -import { buildAttachmentSummaries, parseFaceTags, parseRefIndices } from "./utils/text-parsing.js"; - -// QQ Bot 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, -}; - -// Always request the full intent set for groups, DMs, and guild channels. -const FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C; -const FULL_INTENTS_DESC = "groups + DMs + channels"; - -// Reconnect configuration. -const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; -const RATE_LIMIT_DELAY = 60000; -const MAX_RECONNECT_ATTEMPTS = 100; -const MAX_QUICK_DISCONNECT_COUNT = 3; -const QUICK_DISCONNECT_THRESHOLD = 5000; - -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 ""; -} - -function readOptionalMessageSceneExt( - event: GuildMessageEvent | C2CMessageEvent | GroupMessageEvent, -): string[] | undefined { - if (!("message_scene" in event)) { - return undefined; - } - return event.message_scene?.ext; -} - -export interface GatewayContext { - account: ResolvedQQBotAccount; - abortSignal: AbortSignal; - cfg: OpenClawConfig; - onReady?: (data: unknown) => void; - onError?: (error: Error) => void; - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; -} - -/** - * Start the Gateway WebSocket connection with automatic reconnect support. - */ -export async function startGateway(ctx: GatewayContext): Promise { - const { account, abortSignal, cfg, onReady, onError, log } = ctx; - - if (!account.appId || !account.clientSecret) { - throw new Error("QQBot not configured (missing appId or clientSecret)"); - } - - // Run environment diagnostics during startup. - const diag = await runDiagnostics(); - if (diag.warnings.length > 0) { - for (const w of diag.warnings) { - log?.info(`[qqbot:${account.accountId}] ${w}`); - } - } - - // Initialize API behavior such as markdown support. - initApiConfig(account.appId, { - markdownSupport: account.markdownSupport, - }); - log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport}`); - - // Cache outbound refIdx values from QQ delivery responses for future quoting. - onMessageSent(account.appId, (refIdx, meta) => { - log?.info( - `[qqbot:${account.accountId}] 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 } : {}), - }; - // Preserve the original TTS text for voice messages so later quoting can use it. - if (meta.mediaType === "voice" && meta.ttsText) { - attachment.transcript = meta.ttsText; - attachment.transcriptSource = "tts"; - log?.info( - `[qqbot:${account.accountId}] Saving voice transcript (TTS): ${meta.ttsText.slice(0, 50)}`, - ); - } - attachments.push(attachment); - } - setRefIndex(refIdx, { - content: meta.text ?? "", - senderId: account.accountId, - senderName: account.accountId, - timestamp: Date.now(), - isBot: true, - ...(attachments.length > 0 ? { attachments } : {}), - }); - log?.info( - `[qqbot:${account.accountId}] Cached outbound refIdx: ${refIdx}, attachments=${JSON.stringify(attachments)}`, - ); - }); - - // Log TTS configuration state for diagnostics. - const ttsCfg = resolveTTSConfig(cfg as Record); - if (ttsCfg) { - const maskedKey = - ttsCfg.apiKey.length > 8 - ? `${ttsCfg.apiKey.slice(0, 4)}****${ttsCfg.apiKey.slice(-4)}` - : "****"; - log?.info( - `[qqbot:${account.accountId}] TTS configured (plugin): model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, baseUrl=${ttsCfg.baseUrl}`, - ); - log?.info( - `[qqbot:${account.accountId}] TTS apiKey: ${maskedKey}${ttsCfg.queryParams ? `, queryParams=${JSON.stringify(ttsCfg.queryParams)}` : ""}${ttsCfg.speed !== undefined ? `, speed=${ttsCfg.speed}` : ""}`, - ); - } else if (isGlobalTTSAvailable(cfg)) { - const globalProvider = cfg.messages?.tts?.provider ?? "auto"; - log?.info( - `[qqbot:${account.accountId}] TTS configured (global fallback): provider=${globalProvider}`, - ); - } else { - log?.info( - `[qqbot:${account.accountId}] TTS not configured (voice messages will be unavailable)`, - ); - } - - let reconnectAttempts = 0; - let isAborted = false; - let currentWs: WebSocket | null = null; - let heartbeatInterval: ReturnType | null = null; - let sessionId: string | null = null; - let lastSeq: number | null = null; - let lastConnectTime = 0; - let quickDisconnectCount = 0; - let isConnecting = false; - let reconnectTimer: ReturnType | null = null; - let shouldRefreshToken = false; - - // Restore a persisted session when it still matches the current appId. - const savedSession = loadSession(account.accountId, account.appId); - if (savedSession) { - sessionId = savedSession.sessionId; - lastSeq = savedSession.lastSeq; - log?.info( - `[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}`, - ); - } - - // Queue messages per peer while still allowing cross-peer concurrency. - const msgQueue = createMessageQueue({ - accountId: account.accountId, - log, - isAborted: () => isAborted, - }); - - // Intercept plugin-level slash commands before queueing normal traffic. - const URGENT_COMMANDS = ["/stop"]; - - const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise => { - const content = (msg.content ?? "").trim(); - if (!content.startsWith("/")) { - msgQueue.enqueue(msg); - return; - } - - const contentLower = normalizeLowercaseStringOrEmpty(content); - const isUrgentCommand = URGENT_COMMANDS.some( - (cmd) => - contentLower === normalizeLowercaseStringOrEmpty(cmd) || - contentLower.startsWith(normalizeLowercaseStringOrEmpty(cmd) + " "), - ); - if (isUrgentCommand) { - log?.info( - `[qqbot:${account.accountId}] Urgent command detected: ${content.slice(0, 20)}, executing immediately`, - ); - const peerId = msgQueue.getMessagePeerId(msg); - const droppedCount = msgQueue.clearUserQueue(peerId); - if (droppedCount > 0) { - log?.info( - `[qqbot:${account.accountId}] Dropped ${droppedCount} queued messages for ${peerId} due to urgent command`, - ); - } - msgQueue.executeImmediate(msg); - return; - } - - const receivedAt = Date.now(); - const peerId = msgQueue.getMessagePeerId(msg); - - // commandAuthorized is not meaningful for pre-dispatch commands: requireAuth:true - // commands are in frameworkCommands (not in the local registry) and are never - // matched by matchSlashCommand, so the auth gate inside it never fires here. - 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: msgQueue.getSnapshot(peerId), - }; - - try { - const reply = await matchSlashCommand(cmdCtx); - if (reply === null) { - // Not a plugin-level command. Let the normal framework path handle it. - msgQueue.enqueue(msg); - return; - } - - log?.info( - `[qqbot:${account.accountId}] Slash command matched: ${content}, replying directly`, - ); - const token = await getAccessToken(account.appId, account.clientSecret); - - // Handle either a plain-text reply or a reply with an attached file. - // Note: all current pre-dispatch commands return plain strings; the file - // path below is retained for forward-compatibility if a future requireAuth:false - // command returns a SlashCommandFileResult. - const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply; - const replyText = isFileResult ? reply.text : reply; - const replyFile = isFileResult ? reply.filePath : null; - - // Send the text portion first. - if (msg.type === "c2c") { - await sendC2CMessage(account.appId, token, msg.senderId, replyText, msg.messageId); - } else if (msg.type === "group" && msg.groupOpenid) { - await sendGroupMessage(account.appId, token, msg.groupOpenid, replyText, msg.messageId); - } else if (msg.channelId) { - await sendChannelMessage(token, msg.channelId, replyText, msg.messageId); - } else if (msg.type === "dm" && msg.guildId) { - await sendDmMessage(token, msg.guildId, replyText, msg.messageId); - } - - // Send the file attachment if the command produced one. - 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; - const mediaCtx: MediaTargetContext = { - targetType, - targetId, - account, - replyToId: msg.messageId, - logPrefix: `[qqbot:${account.accountId}]`, - }; - await sendDocument(mediaCtx, replyFile); - log?.info(`[qqbot:${account.accountId}] Slash command file sent: ${replyFile}`); - } catch (fileErr) { - log?.error( - `[qqbot:${account.accountId}] Failed to send slash command file: ${String(fileErr)}`, - ); - } - } - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Slash command error: ${String(err)}`); - // Fall back to the normal queue path if the slash command handler fails. - msgQueue.enqueue(msg); - } - }; - - abortSignal.addEventListener("abort", () => { - isAborted = true; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - cleanup(); - stopBackgroundTokenRefresh(account.appId); - flushKnownUsers(); - flushRefIndex(); - }); - - const cleanup = () => { - if (heartbeatInterval) { - clearInterval(heartbeatInterval); - heartbeatInterval = null; - } - if ( - currentWs && - (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING) - ) { - currentWs.close(); - } - currentWs = null; - }; - - const getReconnectDelay = () => { - const idx = Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1); - return RECONNECT_DELAYS[idx]; - }; - - const scheduleReconnect = (customDelay?: number) => { - if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`); - return; - } - - // Replace any pending reconnect timer with the new one. - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - - const delay = customDelay ?? getReconnectDelay(); - reconnectAttempts++; - log?.info( - `[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`, - ); - - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - if (!isAborted) { - void connect(); - } - }, delay); - }; - - const connect = async () => { - // Do not allow overlapping connection attempts. - if (isConnecting) { - log?.debug?.(`[qqbot:${account.accountId}] Already connecting, skip`); - return; - } - isConnecting = true; - - try { - cleanup(); - - // Clear the cached token before reconnecting when forced refresh was requested. - if (shouldRefreshToken) { - log?.info(`[qqbot:${account.accountId}] Refreshing token...`); - clearTokenCache(account.appId); - shouldRefreshToken = false; - } - - const accessToken = await getAccessToken(account.appId, account.clientSecret); - log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`); - const gatewayUrl = await getGatewayUrl(accessToken); - - log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`); - - const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": PLUGIN_USER_AGENT } }); - currentWs = ws; - - const pluginRuntime = getQQBotRuntime(); - - // Handle one inbound gateway message after it has left the queue. - const handleMessage = async (event: { - type: "c2c" | "guild" | "dm" | "group"; - senderId: string; - senderName?: string; - content: string; - messageId: string; - timestamp: string; - channelId?: string; - guildId?: string; - groupOpenid?: string; - attachments?: Array<{ - content_type: string; - url: string; - filename?: string; - voice_wav_url?: string; - asr_refer_text?: string; - }>; - refMsgIdx?: string; - msgIdx?: string; - }) => { - log?.debug?.(`[qqbot:${account.accountId}] Received message: ${JSON.stringify(event)}`); - log?.info( - `[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`, - ); - if (event.attachments?.length) { - log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`); - } - - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "inbound", - }); - - // Send typing state and keep it alive for C2C conversations only. - const isC2C = event.type === "c2c" || event.type === "dm"; - // Keep the mutable handle in an object so TypeScript does not over-narrow it. - const typing: { keepAlive: TypingKeepAlive | null } = { keepAlive: null }; - - const inputNotifyPromise: Promise = (async () => { - if (!isC2C) { - return undefined; - } - try { - let token = await getAccessToken(account.appId, account.clientSecret); - try { - const notifyResponse = await sendC2CInputNotify( - token, - event.senderId, - event.messageId, - TYPING_INPUT_SECOND, - ); - log?.info( - `[qqbot:${account.accountId}] Sent input notify to ${event.senderId}${notifyResponse.refIdx ? `, got refIdx=${notifyResponse.refIdx}` : ""}`, - ); - typing.keepAlive = new TypingKeepAlive( - () => getAccessToken(account.appId, account.clientSecret), - () => clearTokenCache(account.appId), - event.senderId, - event.messageId, - log, - `[qqbot:${account.accountId}]`, - ); - typing.keepAlive.start(); - return notifyResponse.refIdx; - } catch (notifyErr) { - const errMsg = String(notifyErr); - if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) { - log?.info(`[qqbot:${account.accountId}] InputNotify token expired, refreshing...`); - clearTokenCache(account.appId); - token = await getAccessToken(account.appId, account.clientSecret); - const notifyResponse = await sendC2CInputNotify( - token, - event.senderId, - event.messageId, - TYPING_INPUT_SECOND, - ); - typing.keepAlive = new TypingKeepAlive( - () => getAccessToken(account.appId, account.clientSecret), - () => clearTokenCache(account.appId), - event.senderId, - event.messageId, - log, - `[qqbot:${account.accountId}]`, - ); - typing.keepAlive.start(); - return notifyResponse.refIdx; - } else { - throw notifyErr; - } - } - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] sendC2CInputNotify error: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - return undefined; - } - })(); - - const isGroupChat = event.type === "guild" || event.type === "group"; - // Keep `peer.id` as the raw peer identifier and let `peer.kind` carry the routing type. - const peerId = - event.type === "guild" - ? (event.channelId ?? "unknown") - : event.type === "group" - ? (event.groupOpenid ?? "unknown") - : event.senderId; - - const route = pluginRuntime.channel.routing.resolveAgentRoute({ - cfg, - channel: "qqbot", - accountId: account.accountId, - peer: { - kind: isGroupChat ? "group" : "direct", - id: peerId, - }, - }); - - const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg); - - // Static prompting lives in the QQ Bot skills. This body only carries dynamic context. - const systemPrompts: string[] = []; - if (account.systemPrompt) { - systemPrompts.push(account.systemPrompt); - } - - const processed = await processAttachments(event.attachments, { - accountId: account.accountId, - cfg, - log, - }); - const { - attachmentInfo, - imageUrls, - imageMediaTypes, - voiceAttachmentPaths, - voiceAttachmentUrls, - voiceAsrReferTexts, - voiceTranscripts, - voiceTranscriptSources, - attachmentLocalPaths, - } = processed; - - 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; - - let replyToId: string | undefined; - let replyToBody: string | undefined; - let replyToSender: string | undefined; - let replyToIsQuote = false; - - if (event.refMsgIdx) { - const refEntry = getRefIndex(event.refMsgIdx); - if (refEntry) { - replyToId = event.refMsgIdx; - replyToBody = formatRefEntryForAgent(refEntry); - replyToSender = refEntry.senderName ?? refEntry.senderId; - replyToIsQuote = true; - log?.info( - `[qqbot:${account.accountId}] Quote detected: refMsgIdx=${event.refMsgIdx}, sender=${replyToSender}, content="${replyToBody.slice(0, 80)}..."`, - ); - } else { - log?.info( - `[qqbot:${account.accountId}] Quote detected but refMsgIdx not in cache: ${event.refMsgIdx}`, - ); - replyToId = event.refMsgIdx; - replyToIsQuote = true; - } - } - - // Prefer the push-event msgIdx, falling back to the InputNotify refIdx. - const inputNotifyRefIdx = await inputNotifyPromise; - const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx; - if (currentMsgIdx) { - const attSummaries = buildAttachmentSummaries(event.attachments, attachmentLocalPaths); - // Attach voice transcript metadata to the matching attachment summaries. - 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]; - } - voiceIdx++; - } - } - } - setRefIndex(currentMsgIdx, { - content: parsedContent, - senderId: event.senderId, - senderName: event.senderName, - timestamp: new Date(event.timestamp).getTime(), - attachments: attSummaries, - }); - log?.info( - `[qqbot:${account.accountId}] Cached msgIdx=${currentMsgIdx} for future reference (source: ${event.msgIdx ? "message_scene.ext" : "InputNotify"})`, - ); - } - - // Body is the user-visible raw message shown in the Web UI. - const body = pluginRuntime.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 } : {}), - }); - - // BodyForAgent is the full model-visible context. - const uniqueVoicePaths = [...new Set(voiceAttachmentPaths)]; - const uniqueVoiceUrls = [...new Set(voiceAttachmentUrls)]; - const uniqueVoiceAsrReferTexts = [...new Set(voiceAsrReferTexts)].filter(Boolean); - const sttTranscriptCount = voiceTranscriptSources.filter((s) => s === "stt").length; - const asrFallbackCount = voiceTranscriptSources.filter((s) => s === "asr").length; - const fallbackCount = voiceTranscriptSources.filter((s) => s === "fallback").length; - if ( - voiceAttachmentPaths.length > 0 || - voiceAttachmentUrls.length > 0 || - uniqueVoiceAsrReferTexts.length > 0 - ) { - const asrPreview = - uniqueVoiceAsrReferTexts.length > 0 ? uniqueVoiceAsrReferTexts[0].slice(0, 50) : ""; - log?.info( - `[qqbot:${account.accountId}] Voice input summary: local=${uniqueVoicePaths.length}, remote=${uniqueVoiceUrls.length}, ` + - `asrReferTexts=${uniqueVoiceAsrReferTexts.length}, transcripts=${voiceTranscripts.length}, ` + - `source(stt/asr/fallback)=${sttTranscriptCount}/${asrFallbackCount}/${fallbackCount}` + - (asrPreview - ? `, asr_preview="${asrPreview}${uniqueVoiceAsrReferTexts[0].length > 50 ? "..." : ""}"` - : ""), - ); - } - 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 hasTTS = - !!resolveTTSConfig(cfg as Record) || isGlobalTTSAvailable(cfg); - - let quotePart = ""; - if (replyToIsQuote) { - if (replyToBody) { - quotePart = `[Quoted message begins]\n${replyToBody}\n[Quoted message ends]\n`; - } else { - quotePart = `[Quoted message begins]\nOriginal content unavailable\n[Quoted message ends]\n`; - } - } - - const staticParts: string[] = [`[QQBot] to=${qualifiedTarget}`]; - if (hasTTS) { - staticParts.push("voice synthesis enabled"); - } - const staticInstruction = staticParts.join(" | "); - systemPrompts.unshift(staticInstruction); - - 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" : ""; - - const userMessage = `${quotePart}${userContent}`; - const agentBody = userContent.startsWith("/") - ? userContent - : `${systemPrompts.join("\n")}\n\n${dynamicCtx}${userMessage}`; - - log?.info(`[qqbot:${account.accountId}] agentBody length: ${agentBody.length}`); - - const fromAddress = - event.type === "guild" - ? `qqbot:channel:${event.channelId}` - : event.type === "group" - ? `qqbot:group:${event.groupOpenid}` - : `qqbot:c2c:${event.senderId}`; - const toAddress = fromAddress; - - const rawAllowFrom = account.config?.allowFrom ?? []; - const normalizedAllowFrom = formatQQBotAllowFrom({ - allowFrom: rawAllowFrom, - }); - const normalizedSenderId = event.senderId.replace(/^qqbot:/i, "").toUpperCase(); - const allowAll = - normalizedAllowFrom.length === 0 || normalizedAllowFrom.some((e) => e === "*"); - const commandAuthorized = allowAll || normalizedAllowFrom.includes(normalizedSenderId); - - // Split local media paths from remote URLs for framework-native media handling. - 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); - } - } - - const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({ - Body: body, - BodyForAgent: agentBody, - RawBody: event.content, - CommandBody: event.content, - From: fromAddress, - To: toAddress, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: isGroupChat ? "group" : "direct", - SenderId: event.senderId, - SenderName: event.senderName, - Provider: "qqbot", - Surface: "qqbot", - MessageSid: event.messageId, - Timestamp: new Date(event.timestamp).getTime(), - OriginatingChannel: "qqbot", - OriginatingTo: toAddress, - QQChannelId: event.channelId, - QQGuildId: event.guildId, - QQGroupOpenid: event.groupOpenid, - QQVoiceAsrReferAvailable: hasAsrReferFallback, - QQVoiceTranscriptSources: voiceTranscriptSources, - QQVoiceAttachmentPaths: uniqueVoicePaths, - QQVoiceAttachmentUrls: uniqueVoiceUrls, - QQVoiceAsrReferTexts: uniqueVoiceAsrReferTexts, - QQVoiceInputStrategy: "prefer_audio_stt_then_asr_fallback", - CommandAuthorized: commandAuthorized, - ...(localMediaPaths.length > 0 - ? { - MediaPaths: localMediaPaths, - MediaPath: localMediaPaths[0], - MediaTypes: localMediaTypes, - MediaType: localMediaTypes[0], - } - : {}), - ...(remoteMediaUrls.length > 0 - ? { - MediaUrls: remoteMediaUrls, - MediaUrl: remoteMediaUrls[0], - } - : {}), - ...(replyToId - ? { - ReplyToId: replyToId, - ReplyToBody: replyToBody, - ReplyToSender: replyToSender, - ReplyToIsQuote: replyToIsQuote, - } - : {}), - }); - - const replyTarget: MessageTarget = { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - guildId: event.guildId, - groupOpenid: event.groupOpenid, - }; - const replyCtx: ReplyContext = { target: replyTarget, account, cfg, log }; - - const sendWithRetry = (sendFn: (token: string) => Promise) => - sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId); - - const sendErrorMessage = (errorText: string) => sendErrorToTarget(replyCtx, errorText); - - try { - const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig( - cfg, - route.agentId, - ); - - let hasResponse = false; - let hasBlockResponse = false; - let toolDeliverCount = 0; - const toolTexts: string[] = []; - const toolMediaUrls: string[] = []; - let toolFallbackSent = false; - const responseTimeout = 120000; - const toolOnlyTimeout = 60000; - const maxToolRenewals = 3; - let toolRenewalCount = 0; - let timeoutId: ReturnType | null = null; - let toolOnlyTimeoutId: ReturnType | null = null; - - const sendToolFallback = async (): Promise => { - if (toolMediaUrls.length > 0) { - log?.info( - `[qqbot:${account.accountId}] Tool fallback: forwarding ${toolMediaUrls.length} media URL(s) from tool deliver(s)`, - ); - const mediaTimeout = 45000; // Per-media timeout: 45s. - for (const mediaUrl of toolMediaUrls) { - const ac = new AbortController(); - try { - const result = await Promise.race([ - sendMediaAuto({ - to: qualifiedTarget, - text: "", - mediaUrl, - accountId: account.accountId, - replyToId: event.messageId, - account, - }).then((r) => { - if (ac.signal.aborted) { - log?.info( - `[qqbot:${account.accountId}] Tool fallback sendMedia completed after timeout, suppressing late delivery`, - ); - return { - channel: "qqbot", - error: "Media send completed after timeout (suppressed)", - } as typeof r; - } - return r; - }), - new Promise<{ channel: string; error: string }>((resolve) => - setTimeout(() => { - ac.abort(); - resolve({ - channel: "qqbot", - error: `Tool fallback media send timeout (${mediaTimeout / 1000}s)`, - }); - }, mediaTimeout), - ), - ]); - if (result.error) { - log?.error( - `[qqbot:${account.accountId}] Tool fallback sendMedia error: ${result.error}`, - ); - } - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - } - } - return; - } - if (toolTexts.length > 0) { - const text = toolTexts.slice(-3).join("\n---\n").slice(0, 2000); - log?.info( - `[qqbot:${account.accountId}] Tool fallback: forwarding tool text (${text.length} chars)`, - ); - await sendErrorMessage(text); - return; - } - log?.info( - `[qqbot:${account.accountId}] Tool fallback: no media or text collected from ${toolDeliverCount} tool deliver(s), silently dropping`, - ); - }; - - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - if (!hasResponse) { - reject(new Error("Response timeout")); - } - }, responseTimeout); - }); - - const dispatchPromise = - pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - responsePrefix: messagesConfig.responsePrefix, - deliver: async ( - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, - info: { kind: string }, - ) => { - hasResponse = true; - - log?.info( - `[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`, - ); - - 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); - } - log?.info( - `[qqbot:${account.accountId}] Collected tool deliver #${toolDeliverCount}: text=${toolText.length} chars, media=${toolMediaUrls.length} URLs`, - ); - - if (hasBlockResponse && toolMediaUrls.length > 0) { - log?.info( - `[qqbot:${account.accountId}] Block already sent, immediately forwarding ${toolMediaUrls.length} tool media URL(s)`, - ); - const urlsToSend = [...toolMediaUrls]; - toolMediaUrls.length = 0; - for (const mediaUrl of urlsToSend) { - try { - const result = await sendMediaAuto({ - to: qualifiedTarget, - text: "", - mediaUrl, - accountId: account.accountId, - replyToId: event.messageId, - account, - }); - if (result.error) { - log?.error( - `[qqbot:${account.accountId}] Tool media immediate forward error: ${result.error}`, - ); - } else { - log?.info( - `[qqbot:${account.accountId}] Forwarded tool media (post-block): ${mediaUrl.slice(0, 80)}...`, - ); - } - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] Tool media immediate forward failed: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - } - } - return; - } - - if (toolFallbackSent) { - return; - } - - if (toolOnlyTimeoutId) { - if (toolRenewalCount < maxToolRenewals) { - clearTimeout(toolOnlyTimeoutId); - toolRenewalCount++; - log?.info( - `[qqbot:${account.accountId}] Tool-only timer renewed (${toolRenewalCount}/${maxToolRenewals})`, - ); - } else { - log?.info( - `[qqbot:${account.accountId}] Tool-only timer renewal limit reached (${maxToolRenewals}), waiting for timeout`, - ); - return; - } - } - toolOnlyTimeoutId = setTimeout(async () => { - if (!hasBlockResponse && !toolFallbackSent) { - toolFallbackSent = true; - log?.error( - `[qqbot:${account.accountId}] Tool-only timeout: ${toolDeliverCount} tool deliver(s) but no block within ${toolOnlyTimeout / 1000}s, sending fallback`, - ); - try { - await sendToolFallback(); - } catch (sendErr) { - log?.error( - `[qqbot:${account.accountId}] Failed to send tool-only fallback: ${ - sendErr instanceof Error ? sendErr.message : JSON.stringify(sendErr) - }`, - ); - } - } - }, toolOnlyTimeout); - return; - } - - hasBlockResponse = true; - typing.keepAlive?.stop(); - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (toolOnlyTimeoutId) { - clearTimeout(toolOnlyTimeoutId); - toolOnlyTimeoutId = null; - } - if (toolDeliverCount > 0) { - log?.info( - `[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`, - ); - } - - 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: DeliverEventContext = { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - groupOpenid: event.groupOpenid, - msgIdx: event.msgIdx, - }; - const deliverActx: DeliverAccountContext = { account, qualifiedTarget, log }; - - const mediaResult = await parseAndSendMediaTags( - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - ); - if (mediaResult.handled) { - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "outbound", - }); - return; - } - replyText = mediaResult.normalizedText; - - const recordOutboundActivity = () => - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "outbound", - }); - const handled = await handleStructuredPayload( - replyCtx, - replyText, - recordOutboundActivity, - ); - if (handled) { - return; - } - - await sendPlainReply( - payload, - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - toolMediaUrls, - ); - - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "outbound", - }); - }, - onError: async (err: unknown) => { - const errMsg = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : JSON.stringify(err); - log?.error(`[qqbot:${account.accountId}] Dispatch error: ${errMsg}`); - hasResponse = true; - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) { - log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`); - } else { - log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`); - } - }, - }, - replyOptions: { - disableBlockStreaming: account.config.streaming?.mode === "off", - }, - }); - - try { - await Promise.race([dispatchPromise, timeoutPromise]); - } catch { - if (timeoutId) { - clearTimeout(timeoutId); - } - if (!hasResponse) { - log?.error(`[qqbot:${account.accountId}] No response within timeout`); - } - } finally { - if (toolOnlyTimeoutId) { - clearTimeout(toolOnlyTimeoutId); - toolOnlyTimeoutId = null; - } - if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) { - toolFallbackSent = true; - log?.error( - `[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`, - ); - await sendToolFallback(); - } - } - } catch (err) { - const errMsg = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : JSON.stringify(err); - log?.error(`[qqbot:${account.accountId}] Message processing failed: ${errMsg}`); - } finally { - typing.keepAlive?.stop(); - } - }; - - ws.on("open", () => { - log?.info(`[qqbot:${account.accountId}] WebSocket connected`); - isConnecting = false; - reconnectAttempts = 0; - lastConnectTime = Date.now(); - msgQueue.startProcessor(handleMessage); - startBackgroundTokenRefresh(account.appId, account.clientSecret, { - log: log as { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }, - }); - }); - - 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) { - lastSeq = s; - if (sessionId) { - saveSession({ - sessionId, - lastSeq, - lastConnectedAt: lastConnectTime, - intentLevelIndex: 0, - accountId: account.accountId, - savedAt: Date.now(), - appId: account.appId, - }); - } - } - - log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`); - - switch (op) { - case 10: // Hello - log?.info(`[qqbot:${account.accountId}] Hello received`); - - if (sessionId && lastSeq !== null) { - log?.info(`[qqbot:${account.accountId}] Attempting to resume session ${sessionId}`); - ws.send( - JSON.stringify({ - op: 6, // Resume - d: { - token: `QQBot ${accessToken}`, - session_id: sessionId, - seq: lastSeq, - }, - }), - ); - } else { - log?.info( - `[qqbot:${account.accountId}] Sending identify with intents: ${FULL_INTENTS} (${FULL_INTENTS_DESC})`, - ); - ws.send( - JSON.stringify({ - op: 2, - d: { - token: `QQBot ${accessToken}`, - intents: FULL_INTENTS, - shard: [0, 1], - }, - }), - ); - } - - const interval = (d as { heartbeat_interval: number }).heartbeat_interval; - if (heartbeatInterval) { - clearInterval(heartbeatInterval); - } - heartbeatInterval = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ op: 1, d: lastSeq })); - log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`); - } - }, interval); - break; - - case 0: // Dispatch - log?.info( - `[qqbot:${account.accountId}] 📩 Dispatch event: t=${t}, d=${JSON.stringify(d)}`, - ); - if (t === "READY") { - const readyData = d as { session_id: string }; - sessionId = readyData.session_id; - log?.info( - `[qqbot:${account.accountId}] Ready with ${FULL_INTENTS_DESC}, session: ${sessionId}`, - ); - saveSession({ - sessionId, - lastSeq, - lastConnectedAt: Date.now(), - intentLevelIndex: 0, - accountId: account.accountId, - savedAt: Date.now(), - appId: account.appId, - }); - onReady?.(d); - } else if (t === "RESUMED") { - log?.info(`[qqbot:${account.accountId}] Session resumed`); - onReady?.(d); // Notify the framework so health monitoring sees the connection as recovered. - if (sessionId) { - saveSession({ - sessionId, - lastSeq, - lastConnectedAt: Date.now(), - intentLevelIndex: 0, - accountId: account.accountId, - savedAt: Date.now(), - appId: account.appId, - }); - } - } else if (t === "C2C_MESSAGE_CREATE") { - const event = d as C2CMessageEvent; - recordKnownUser({ - openid: event.author.user_openid, - type: "c2c", - accountId: account.accountId, - }); - const c2cRefs = parseRefIndices(event.message_scene?.ext); - void trySlashCommandOrEnqueue({ - type: "c2c", - senderId: event.author.user_openid, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - attachments: event.attachments, - refMsgIdx: c2cRefs.refMsgIdx, - msgIdx: c2cRefs.msgIdx, - }); - } else if (t === "AT_MESSAGE_CREATE") { - const event = d as GuildMessageEvent; - // Guild users cannot receive proactive C2C messages — skip known-user recording. - const guildRefs = parseRefIndices(readOptionalMessageSceneExt(event)); - void trySlashCommandOrEnqueue({ - type: "guild", - senderId: event.author.id, - senderName: event.author.username, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - channelId: event.channel_id, - guildId: event.guild_id, - attachments: event.attachments, - refMsgIdx: guildRefs.refMsgIdx, - msgIdx: guildRefs.msgIdx, - }); - } else if (t === "DIRECT_MESSAGE_CREATE") { - const event = d as GuildMessageEvent; - // DM author.id is a guild-scoped ID, not a C2C openid — skip known-user recording. - const dmRefs = parseRefIndices(readOptionalMessageSceneExt(event)); - void trySlashCommandOrEnqueue({ - type: "dm", - senderId: event.author.id, - senderName: event.author.username, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - guildId: event.guild_id, - attachments: event.attachments, - refMsgIdx: dmRefs.refMsgIdx, - msgIdx: dmRefs.msgIdx, - }); - } else if (t === "GROUP_AT_MESSAGE_CREATE") { - const event = d as GroupMessageEvent; - recordKnownUser({ - openid: event.author.member_openid, - type: "group", - groupOpenid: event.group_openid, - accountId: account.accountId, - }); - const groupRefs = parseRefIndices(event.message_scene?.ext); - void trySlashCommandOrEnqueue({ - type: "group", - senderId: event.author.member_openid, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - groupOpenid: event.group_openid, - attachments: event.attachments, - refMsgIdx: groupRefs.refMsgIdx, - msgIdx: groupRefs.msgIdx, - }); - } - break; - - case 11: // Heartbeat ACK - log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`); - break; - - case 7: // Reconnect - log?.info(`[qqbot:${account.accountId}] Server requested reconnect`); - cleanup(); - scheduleReconnect(); - break; - - case 9: // Invalid Session - const canResume = d as boolean; - log?.error( - `[qqbot:${account.accountId}] Invalid session (${FULL_INTENTS_DESC}), can resume: ${canResume}, raw: ${rawData}`, - ); - - if (!canResume) { - sessionId = null; - lastSeq = null; - clearSession(account.accountId); - shouldRefreshToken = true; - log?.info( - `[qqbot:${account.accountId}] Will refresh token and retry with full intents (${FULL_INTENTS_DESC})`, - ); - } - cleanup(); - scheduleReconnect(3000); - break; - } - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] Message parse error: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - } - }); - - ws.on("close", (code, reason) => { - log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`); - isConnecting = false; // Release the connect lock. - - if (code === 4914 || code === 4915) { - log?.error( - `[qqbot:${account.accountId}] Bot is ${code === 4914 ? "offline/sandbox-only" : "banned"}. Please contact QQ platform.`, - ); - cleanup(); - return; - } - - if (code === 4004) { - log?.info( - `[qqbot:${account.accountId}] Invalid token (4004), will refresh token and reconnect`, - ); - shouldRefreshToken = true; - cleanup(); - if (!isAborted) { - scheduleReconnect(); - } - return; - } - - if (code === 4008) { - log?.info( - `[qqbot:${account.accountId}] Rate limited (4008), waiting ${RATE_LIMIT_DELAY}ms before reconnect`, - ); - cleanup(); - if (!isAborted) { - scheduleReconnect(RATE_LIMIT_DELAY); - } - return; - } - - if (code === 4006 || code === 4007 || code === 4009) { - const codeDesc: Record = { - 4006: "session no longer valid", - 4007: "invalid seq on resume", - 4009: "session timed out", - }; - log?.info( - `[qqbot:${account.accountId}] Error ${code} (${codeDesc[code]}), will re-identify`, - ); - sessionId = null; - lastSeq = null; - clearSession(account.accountId); - shouldRefreshToken = true; - } else if (code >= 4900 && code <= 4913) { - log?.info(`[qqbot:${account.accountId}] Internal error (${code}), will re-identify`); - sessionId = null; - lastSeq = null; - clearSession(account.accountId); - shouldRefreshToken = true; - } - - const connectionDuration = Date.now() - lastConnectTime; - if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && lastConnectTime > 0) { - quickDisconnectCount++; - log?.info( - `[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`, - ); - - if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) { - log?.error( - `[qqbot:${account.accountId}] Too many quick disconnects. This may indicate a permission issue.`, - ); - log?.error( - `[qqbot:${account.accountId}] Please check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform`, - ); - quickDisconnectCount = 0; - cleanup(); - if (!isAborted && code !== 1000) { - scheduleReconnect(RATE_LIMIT_DELAY); - } - return; - } - } else { - quickDisconnectCount = 0; - } - - cleanup(); - - if (!isAborted && code !== 1000) { - scheduleReconnect(); - } - }); - - ws.on("error", (err) => { - log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`); - onError?.(err); - }); - } catch (err) { - isConnecting = false; - const errMsg = err instanceof Error ? err.message : (JSON.stringify(err) ?? "Unknown error"); - log?.error(`[qqbot:${account.accountId}] Connection failed: ${errMsg}`); - // Back off more aggressively after rate-limit failures. - if (errMsg.includes("Too many requests") || errMsg.includes("100001")) { - log?.info( - `[qqbot:${account.accountId}] Rate limited, waiting ${RATE_LIMIT_DELAY}ms before retry`, - ); - scheduleReconnect(RATE_LIMIT_DELAY); - } else { - scheduleReconnect(); - } - } - }; - - await connect(); - - return new Promise((resolve) => { - abortSignal.addEventListener("abort", () => resolve()); - }); -} diff --git a/extensions/qqbot/src/outbound-deliver.test.ts b/extensions/qqbot/src/outbound-deliver.test.ts deleted file mode 100644 index 56dd0c48ad7..00000000000 --- a/extensions/qqbot/src/outbound-deliver.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const apiMocks = vi.hoisted(() => ({ - sendC2CMessage: vi.fn(), - sendDmMessage: vi.fn(), - sendGroupMessage: vi.fn(), - sendChannelMessage: vi.fn(), - sendC2CImageMessage: vi.fn(), - sendGroupImageMessage: vi.fn(), -})); - -const outboundMocks = vi.hoisted(() => ({ - sendPhoto: vi.fn(async () => ({})), - sendVoice: vi.fn(async () => ({})), - sendVideoMsg: vi.fn(async () => ({})), - sendDocument: vi.fn(async () => ({})), - sendMedia: vi.fn(async () => ({})), -})); - -const runtimeMocks = vi.hoisted(() => ({ - chunkMarkdownText: vi.fn((text: string) => [text]), -})); - -vi.mock("./api.js", () => ({ - sendC2CMessage: apiMocks.sendC2CMessage, - sendDmMessage: apiMocks.sendDmMessage, - sendGroupMessage: apiMocks.sendGroupMessage, - sendChannelMessage: apiMocks.sendChannelMessage, - sendC2CImageMessage: apiMocks.sendC2CImageMessage, - sendGroupImageMessage: apiMocks.sendGroupImageMessage, -})); - -vi.mock("./outbound.js", () => ({ - sendPhoto: outboundMocks.sendPhoto, - sendVoice: outboundMocks.sendVoice, - sendVideoMsg: outboundMocks.sendVideoMsg, - sendDocument: outboundMocks.sendDocument, - sendMedia: outboundMocks.sendMedia, -})); - -vi.mock("./runtime.js", () => ({ - getQQBotRuntime: () => ({ - channel: { - text: { - chunkMarkdownText: runtimeMocks.chunkMarkdownText, - }, - }, - }), -})); - -const imageSizeMocks = vi.hoisted(() => ({ - getImageSize: vi.fn(), - formatQQBotMarkdownImage: vi.fn(), - hasQQBotImageSize: vi.fn(), -})); - -vi.mock("./utils/image-size.js", () => ({ - getImageSize: (...args: unknown[]) => imageSizeMocks.getImageSize(...args), - formatQQBotMarkdownImage: (...args: unknown[]) => - imageSizeMocks.formatQQBotMarkdownImage(...args), - hasQQBotImageSize: (...args: unknown[]) => imageSizeMocks.hasQQBotImageSize(...args), -})); - -import { - parseAndSendMediaTags, - sendPlainReply, - type ConsumeQuoteRefFn, - type DeliverAccountContext, - type DeliverEventContext, - type SendWithRetryFn, -} from "./outbound-deliver.js"; - -function buildEvent(): DeliverEventContext { - return { - type: "c2c", - senderId: "user-1", - messageId: "msg-1", - }; -} - -function buildAccountContext(markdownSupport: boolean): DeliverAccountContext { - return { - qualifiedTarget: "qqbot:c2c:user-1", - account: { - accountId: "default", - appId: "app-id", - clientSecret: "secret", - markdownSupport, - config: {}, - } as DeliverAccountContext["account"], - log: { - info: vi.fn(), - error: vi.fn(), - }, - }; -} - -const sendWithRetry: SendWithRetryFn = async (sendFn) => await sendFn("token"); -const consumeQuoteRef: ConsumeQuoteRefFn = () => undefined; - -describe("qqbot outbound deliver", () => { - beforeEach(() => { - vi.clearAllMocks(); - runtimeMocks.chunkMarkdownText.mockImplementation((text: string) => [text]); - imageSizeMocks.getImageSize.mockResolvedValue(null); - imageSizeMocks.formatQQBotMarkdownImage.mockImplementation((url: string) => `![img](${url})`); - imageSizeMocks.hasQQBotImageSize.mockReturnValue(false); - }); - - it("sends plain replies through the shared text chunk sender", async () => { - await sendPlainReply( - {}, - "hello plain world", - buildEvent(), - buildAccountContext(false), - sendWithRetry, - consumeQuoteRef, - [], - ); - - expect(apiMocks.sendC2CMessage).toHaveBeenCalledWith( - "app-id", - "token", - "user-1", - "hello plain world", - "msg-1", - undefined, - ); - }); - - it("sends markdown replies through the shared text chunk sender", async () => { - await sendPlainReply( - {}, - "hello markdown world", - buildEvent(), - buildAccountContext(true), - sendWithRetry, - consumeQuoteRef, - [], - ); - - expect(apiMocks.sendC2CMessage).toHaveBeenCalledWith( - "app-id", - "token", - "user-1", - "hello markdown world", - "msg-1", - undefined, - ); - }); - - it("routes media-tag text segments through the shared chunk sender", async () => { - await parseAndSendMediaTags( - "beforehttps://example.com/a.pngafter", - buildEvent(), - buildAccountContext(false), - sendWithRetry, - consumeQuoteRef, - ); - - expect(apiMocks.sendC2CMessage).toHaveBeenNthCalledWith( - 1, - "app-id", - "token", - "user-1", - "before", - "msg-1", - undefined, - ); - expect(apiMocks.sendC2CMessage).toHaveBeenNthCalledWith( - 2, - "app-id", - "token", - "user-1", - "after", - "msg-1", - undefined, - ); - expect(outboundMocks.sendPhoto).toHaveBeenCalledTimes(1); - }); - - describe("private-network image URL degradation", () => { - it("sends markdown reply with fallback dimensions when getImageSize returns null", async () => { - imageSizeMocks.getImageSize.mockResolvedValue(null); - - await sendPlainReply( - {}, - "Look at this: ![photo](https://10.0.0.1/internal.png)", - buildEvent(), - buildAccountContext(true), - sendWithRetry, - consumeQuoteRef, - [], - ); - - // getImageSize was called with the private-network URL - expect(imageSizeMocks.getImageSize).toHaveBeenCalledWith("https://10.0.0.1/internal.png"); - // formatQQBotMarkdownImage was called with null size (triggers default dimensions) - expect(imageSizeMocks.formatQQBotMarkdownImage).toHaveBeenCalledWith( - "https://10.0.0.1/internal.png", - null, - ); - // Message was still sent (not crashed) - expect(apiMocks.sendC2CMessage).toHaveBeenCalled(); - }); - - it("sends markdown reply with fallback when getImageSize throws", async () => { - imageSizeMocks.getImageSize.mockRejectedValue(new Error("SSRF blocked")); - - await sendPlainReply( - {}, - "Check ![img](https://169.254.169.254/latest/meta-data/)", - buildEvent(), - buildAccountContext(true), - sendWithRetry, - consumeQuoteRef, - [], - ); - - // formatQQBotMarkdownImage still called with null (catch path in outbound-deliver) - expect(imageSizeMocks.formatQQBotMarkdownImage).toHaveBeenCalledWith( - "https://169.254.169.254/latest/meta-data/", - null, - ); - expect(apiMocks.sendC2CMessage).toHaveBeenCalled(); - }); - }); -}); diff --git a/extensions/qqbot/src/outbound.security.test.ts b/extensions/qqbot/src/outbound.security.test.ts deleted file mode 100644 index 6c922e949e2..00000000000 --- a/extensions/qqbot/src/outbound.security.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ResolvedQQBotAccount } from "./types.js"; -import { getQQBotDataDir, getQQBotMediaDir } from "./utils/platform.js"; - -const apiMocks = vi.hoisted(() => ({ - getAccessToken: vi.fn(async () => "token"), - sendC2CFileMessage: vi.fn(async () => ({ id: "msg-c2c-file", timestamp: "ts" })), - sendC2CImageMessage: vi.fn(async () => ({ id: "msg-c2c-image", timestamp: "ts" })), - sendC2CMessage: vi.fn(async () => ({ id: "msg-c2c-text", timestamp: "ts" })), - sendC2CVideoMessage: vi.fn(async () => ({ id: "msg-c2c-video", timestamp: "ts" })), - sendC2CVoiceMessage: vi.fn(async () => ({ id: "msg-c2c-voice", timestamp: "ts" })), - sendChannelMessage: vi.fn(async () => ({ id: "msg-channel", timestamp: "ts" })), - sendDmMessage: vi.fn(async () => ({ id: "msg-dm", timestamp: "ts" })), - sendGroupFileMessage: vi.fn(async () => ({ id: "msg-group-file", timestamp: "ts" })), - sendGroupImageMessage: vi.fn(async () => ({ id: "msg-group-image", timestamp: "ts" })), - sendGroupMessage: vi.fn(async () => ({ id: "msg-group-text", timestamp: "ts" })), - sendGroupVideoMessage: vi.fn(async () => ({ id: "msg-group-video", timestamp: "ts" })), - sendGroupVoiceMessage: vi.fn(async () => ({ id: "msg-group-voice", timestamp: "ts" })), - sendProactiveC2CMessage: vi.fn(async () => ({ id: "msg-proactive-c2c", timestamp: "ts" })), - sendProactiveGroupMessage: vi.fn(async () => ({ id: "msg-proactive-group", timestamp: "ts" })), -})); - -const audioConvertMocks = vi.hoisted(() => ({ - audioFileToSilkBase64: vi.fn(async () => "c2lsaw=="), - isAudioFile: vi.fn((filePath: string, mimeType?: string) => { - if (mimeType === "voice" || mimeType?.startsWith("audio/")) { - return true; - } - return ( - filePath.endsWith(".mp3") || - filePath.endsWith(".wav") || - filePath.endsWith(".amr") || - filePath.endsWith(".ogg") - ); - }), - shouldTranscodeVoice: vi.fn(() => false), - waitForFile: vi.fn(async (_filePath: string) => 1024), -})); - -const fileUtilsMocks = vi.hoisted(() => ({ - checkFileSize: vi.fn(() => ({ ok: true })), - downloadFile: vi.fn(), - fileExistsAsync: vi.fn(async () => true), - formatFileSize: vi.fn((size: number) => `${size}`), - readFileAsync: vi.fn(async () => Buffer.from("file-data")), -})); - -vi.mock("./api.js", () => apiMocks); - -vi.mock("./utils/audio-convert.js", () => ({ - audioFileToSilkBase64: audioConvertMocks.audioFileToSilkBase64, - isAudioFile: audioConvertMocks.isAudioFile, - shouldTranscodeVoice: audioConvertMocks.shouldTranscodeVoice, - waitForFile: audioConvertMocks.waitForFile, -})); - -vi.mock("./utils/file-utils.js", () => ({ - checkFileSize: fileUtilsMocks.checkFileSize, - downloadFile: fileUtilsMocks.downloadFile, - fileExistsAsync: fileUtilsMocks.fileExistsAsync, - formatFileSize: fileUtilsMocks.formatFileSize, - readFileAsync: fileUtilsMocks.readFileAsync, -})); - -vi.mock("./utils/debug-log.js", () => ({ - debugError: vi.fn(), - debugLog: vi.fn(), - debugWarn: vi.fn(), -})); - -import { - sendDocument, - sendMedia, - sendPhoto, - sendVideoMsg, - sendVoice, - type MediaOutboundContext, - type MediaTargetContext, - type OutboundResult, -} from "./outbound.js"; - -const createdRoots: string[] = []; - -const account: ResolvedQQBotAccount = { - accountId: "default", - enabled: true, - appId: "app-id", - clientSecret: "secret", - secretSource: "config", - markdownSupport: true, - config: {}, -}; - -function buildTarget(): MediaTargetContext { - return { - targetType: "c2c", - targetId: "user-1", - account, - replyToId: "msg-1", - logPrefix: "[qqbot:test]", - }; -} - -function buildMediaContext(mediaUrl: string): MediaOutboundContext { - return { - to: "qqbot:c2c:user-1", - text: "", - account, - mediaUrl, - replyToId: "msg-1", - }; -} - -function createOutsideFile(ext: string): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-security-")); - createdRoots.push(root); - const filePath = path.join(root, `payload${ext}`); - fs.writeFileSync(filePath, "payload", "utf8"); - return filePath; -} - -function createAllowedCommandDownloadPath(ext: string): string { - const root = fs.mkdtempSync(path.join(getQQBotDataDir("downloads"), "command-download-")); - createdRoots.push(root); - const filePath = path.join(root, `download${ext}`); - fs.writeFileSync(filePath, "payload", "utf8"); - return filePath; -} - -function createAllowedMediaPath( - ext: string, - options: { createFile?: boolean; content?: string } = {}, -): string { - const root = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-security-")); - createdRoots.push(root); - const filePath = path.join(root, `allowed${ext}`); - if (options.createFile !== false) { - fs.writeFileSync(filePath, options.content ?? "payload", "utf8"); - } - return filePath; -} - -function createDelayedMissingMediaPath(ext: string): string { - const root = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-delayed-security-")); - createdRoots.push(root); - return path.join(root, "pending", `delayed${ext}`); -} - -function createMissingSymlinkEscapePath(ext: string): string | null { - const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-symlink-outside-")); - createdRoots.push(outsideRoot); - - const inMediaRoot = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-symlink-")); - createdRoots.push(inMediaRoot); - - const linkPath = path.join(inMediaRoot, "link"); - try { - fs.symlinkSync(outsideRoot, linkPath, "dir"); - } catch { - return null; - } - - return path.join(linkPath, `delayed${ext}`); -} - -function writeFileWithParents(filePath: string, content: string = "payload"): number { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, content, "utf8"); - return fs.statSync(filePath).size; -} - -function installMissingSegmentSymlinkRace( - delayedVoicePath: string, - outsideRootPrefix: string, -): boolean { - const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), outsideRootPrefix)); - createdRoots.push(outsideRoot); - - const symlinkProbe = path.join(path.dirname(path.dirname(delayedVoicePath)), "probe-link"); - try { - fs.symlinkSync(outsideRoot, symlinkProbe, "dir"); - fs.unlinkSync(symlinkProbe); - } catch { - return false; - } - - audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) => { - const symlinkParent = path.dirname(candidatePath); - fs.symlinkSync(outsideRoot, symlinkParent, "dir"); - const outsideFile = path.join(outsideRoot, path.basename(candidatePath)); - return writeFileWithParents(outsideFile); - }); - - return true; -} - -function expectBlocked(result: OutboundResult, expectedError: string): void { - expect(result.channel).toBe("qqbot"); - expect(result.error).toBe(expectedError); - expect(apiMocks.getAccessToken).not.toHaveBeenCalled(); -} - -const nonDotRelativeTraversalPath = "src/../../../../etc/passwd"; - -afterEach(() => { - vi.clearAllMocks(); - for (const root of createdRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -describe("qqbot outbound local media path security", () => { - it("allows local image paths inside QQ Bot media storage", async () => { - const allowedPath = createAllowedMediaPath(".png"); - const result = await sendPhoto(buildTarget(), allowedPath); - - expect(result.error).toBeUndefined(); - expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1); - expect(apiMocks.sendC2CImageMessage).toHaveBeenCalledTimes(1); - }); - - it("blocks local image paths outside QQ Bot media storage", async () => { - const outsidePath = createOutsideFile(".png"); - const result = await sendPhoto(buildTarget(), outsidePath); - - expectBlocked(result, "Image path must be inside QQ Bot media storage"); - }); - - it("blocks local voice paths outside QQ Bot media storage", async () => { - const outsidePath = createOutsideFile(".mp3"); - const result = await sendVoice(buildTarget(), outsidePath, undefined, false); - - expectBlocked(result, "Voice path must be inside QQ Bot media storage"); - }); - - it("allows delayed local voice paths inside QQ Bot media storage", async () => { - const delayedVoicePath = createAllowedMediaPath(".mp3", { createFile: false }); - audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) => - writeFileWithParents(candidatePath), - ); - const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true); - - expect(result.error).toBeUndefined(); - expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1); - expect(apiMocks.sendC2CVoiceMessage).toHaveBeenCalledTimes(1); - }); - - it("blocks delayed voice paths when a missing segment is replaced by a symlink after precheck", async () => { - const delayedVoicePath = createDelayedMissingMediaPath(".mp3"); - if (!installMissingSegmentSymlinkRace(delayedVoicePath, "qqbot-outbound-race-outside-")) { - return; - } - - const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true); - - expectBlocked(result, "Voice path must be inside QQ Bot media storage"); - }); - - it("returns a blocked result when missing-path canonicalization cannot resolve root", async () => { - const originalExistsSync = fs.existsSync.bind(fs); - const originalRealpathSync = fs.realpathSync.bind(fs); - - const existsSpy = vi.spyOn(fs, "existsSync"); - existsSpy.mockImplementation((candidate: fs.PathLike) => { - const candidateText = typeof candidate === "string" ? candidate : candidate.toString(); - const root = path.parse(candidateText).root; - if (candidateText === root) { - return false; - } - return originalExistsSync(candidate); - }); - - const realpathSpy = vi.spyOn(fs, "realpathSync"); - realpathSpy.mockImplementation(((candidate: fs.PathLike) => { - const candidateText = typeof candidate === "string" ? candidate : candidate.toString(); - const root = path.parse(candidateText).root; - if (candidateText === root) { - throw new Error("missing-root"); - } - return originalRealpathSync(candidate); - }) as typeof fs.realpathSync); - - try { - const result = await sendVoice( - buildTarget(), - "/qqbot-missing-root/sub/path.mp3", - undefined, - true, - ); - expectBlocked(result, "Voice path must be inside QQ Bot media storage"); - } finally { - existsSpy.mockRestore(); - realpathSpy.mockRestore(); - } - }); - - it("blocks delayed voice paths that escape via symlinked parent directories", async () => { - const delayedVoicePath = createMissingSymlinkEscapePath(".mp3"); - if (!delayedVoicePath) { - return; - } - - const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true); - - expectBlocked(result, "Voice path must be inside QQ Bot media storage"); - }); - - it("blocks local video paths outside QQ Bot media storage", async () => { - const outsidePath = createOutsideFile(".mp4"); - const result = await sendVideoMsg(buildTarget(), outsidePath); - - expectBlocked(result, "Video path must be inside QQ Bot media storage"); - }); - - it("blocks local document paths outside QQ Bot media storage", async () => { - const outsidePath = createOutsideFile(".txt"); - const result = await sendDocument(buildTarget(), outsidePath); - - expectBlocked(result, "File path must be inside QQ Bot media storage"); - }); - - it("blocks QQ Bot command-download paths for sendDocument by default", async () => { - const commandDownloadPath = createAllowedCommandDownloadPath(".txt"); - const result = await sendDocument(buildTarget(), commandDownloadPath); - - expectBlocked(result, "File path must be inside QQ Bot media storage"); - }); - - it("allows QQ Bot command-download paths for sendDocument when explicitly enabled", async () => { - const commandDownloadPath = createAllowedCommandDownloadPath(".txt"); - const result = await sendDocument(buildTarget(), commandDownloadPath, { - allowQQBotDataDownloads: true, - }); - - expect(result.error).toBeUndefined(); - expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(2); - expect(apiMocks.sendC2CFileMessage).toHaveBeenCalledTimes(1); - }); - - it("blocks non-dot relative traversal paths for document sends", async () => { - const result = await sendDocument(buildTarget(), nonDotRelativeTraversalPath); - - expectBlocked(result, "File path must be inside QQ Bot media storage"); - }); - - it("blocks sendMedia local paths outside QQ Bot media storage", async () => { - const outsidePath = createOutsideFile(".txt"); - const result = await sendMedia(buildMediaContext(outsidePath)); - - expectBlocked(result, "Media path must be inside QQ Bot media storage"); - }); - - it("allows delayed local audio paths in sendMedia inside QQ Bot media storage", async () => { - const delayedVoicePath = createAllowedMediaPath(".mp3", { createFile: false }); - audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) => - writeFileWithParents(candidatePath), - ); - const result = await sendMedia(buildMediaContext(delayedVoicePath)); - - expect(result.error).toBeUndefined(); - expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1); - expect(apiMocks.sendC2CVoiceMessage).toHaveBeenCalledTimes(1); - }); - - it("blocks sendMedia delayed audio paths when a missing segment is replaced by a symlink", async () => { - const delayedVoicePath = createDelayedMissingMediaPath(".mp3"); - if (!installMissingSegmentSymlinkRace(delayedVoicePath, "qqbot-outbound-race-sendmedia-")) { - return; - } - - const result = await sendMedia(buildMediaContext(delayedVoicePath)); - - expectBlocked( - result, - "voice: Voice path must be inside QQ Bot media storage | fallback file: File path must be inside QQ Bot media storage", - ); - }); - - it("blocks sendMedia delayed audio paths that escape via symlinked parents", async () => { - const delayedVoicePath = createMissingSymlinkEscapePath(".mp3"); - if (!delayedVoicePath) { - return; - } - - const result = await sendMedia(buildMediaContext(delayedVoicePath)); - - expectBlocked(result, "Media path must be inside QQ Bot media storage"); - }); - - it("blocks non-dot relative traversal paths in sendMedia", async () => { - const result = await sendMedia(buildMediaContext(nonDotRelativeTraversalPath)); - - expectBlocked(result, "Media path must be inside QQ Bot media storage"); - }); -}); diff --git a/extensions/qqbot/src/proactive.test.ts b/extensions/qqbot/src/proactive.test.ts deleted file mode 100644 index 1b46cebfff2..00000000000 --- a/extensions/qqbot/src/proactive.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { sendProactive } from "./proactive.js"; - -const apiMocks = vi.hoisted(() => ({ - getAccessToken: vi.fn(), - sendProactiveC2CMessage: vi.fn(), -})); - -vi.mock("./api.js", () => ({ - getAccessToken: apiMocks.getAccessToken, - sendProactiveC2CMessage: apiMocks.sendProactiveC2CMessage, - sendProactiveGroupMessage: vi.fn(), - sendChannelMessage: vi.fn(), - sendC2CImageMessage: vi.fn(), - sendGroupImageMessage: vi.fn(), -})); - -describe("qqbot proactive sends", () => { - beforeEach(() => { - apiMocks.getAccessToken.mockReset(); - apiMocks.sendProactiveC2CMessage.mockReset(); - }); - - it("uses configured defaultAccount when accountId is omitted", async () => { - apiMocks.getAccessToken.mockResolvedValue("access-token"); - apiMocks.sendProactiveC2CMessage.mockResolvedValue({ - id: "msg-1", - timestamp: 123, - }); - - const cfg = { - channels: { - qqbot: { - defaultAccount: "bot2", - accounts: { - bot2: { - appId: "654321", - clientSecret: "secret-value", - }, - }, - }, - }, - } as OpenClawConfig; - - const result = await sendProactive( - { - to: "openid-1", - text: "hello", - }, - cfg, - ); - - expect(apiMocks.getAccessToken).toHaveBeenCalledWith("654321", "secret-value"); - expect(apiMocks.sendProactiveC2CMessage).toHaveBeenCalledWith( - "654321", - "access-token", - "openid-1", - "hello", - ); - expect(result.success).toBe(true); - expect(result.messageId).toBe("msg-1"); - }); -}); diff --git a/extensions/qqbot/src/proactive.ts b/extensions/qqbot/src/proactive.ts deleted file mode 100644 index d26b16f2a1e..00000000000 --- a/extensions/qqbot/src/proactive.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * QQ Bot proactive messaging helpers. - * - * This module sends proactive messages and manages known-user queries. - * Known-user storage is delegated to `./known-users.ts`. - */ - -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { - getAccessToken, - sendC2CImageMessage, - sendGroupImageMessage, - sendProactiveC2CMessage, - sendProactiveGroupMessage, -} from "./api.js"; -import { resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./config.js"; -import { - clearKnownUsers as clearKnownUsersImpl, - getKnownUser as getKnownUserImpl, - listKnownUsers as listKnownUsersImpl, - removeKnownUser as removeKnownUserImpl, -} from "./known-users.js"; -import type { ResolvedQQBotAccount } from "./types.js"; -import { debugError, debugLog } from "./utils/debug-log.js"; - -// Re-export known-user types and functions from the canonical module. -export { - clearKnownUsers as clearKnownUsersFromStore, - flushKnownUsers, - getKnownUser as getKnownUserFromStore, - listKnownUsers as listKnownUsersFromStore, - recordKnownUser, - removeKnownUser as removeKnownUserFromStore, -} from "./known-users.js"; -export type { KnownUser } from "./known-users.js"; - -/** Options for proactive message sending. */ -export interface ProactiveSendOptions { - to: string; - text: string; - type?: "c2c" | "group" | "channel"; - imageUrl?: string; - accountId?: string; -} - -/** Result returned from proactive sends. */ -export interface ProactiveSendResult { - success: boolean; - messageId?: string; - timestamp?: number | string; - error?: string; -} - -/** Filters for listing known users. */ -export interface ListKnownUsersOptions { - type?: "c2c" | "group" | "channel"; - accountId?: string; - sortByLastInteraction?: boolean; - limit?: number; -} - -/** Look up a known user entry (adapter for the old proactive API shape). */ -export function getKnownUser( - type: string, - openid: string, - accountId: string, -): ReturnType { - return getKnownUserImpl(accountId, openid, type as "c2c" | "group"); -} - -/** List known users with optional filtering and sorting (adapter). */ -export function listKnownUsers( - options?: ListKnownUsersOptions, -): ReturnType { - const type = options?.type; - return listKnownUsersImpl({ - type: type === "channel" ? undefined : type, - accountId: options?.accountId, - limit: options?.limit, - sortBy: options?.sortByLastInteraction !== false ? "lastSeenAt" : undefined, - sortOrder: "desc", - }); -} - -/** Remove one known user entry (adapter). */ -export function removeKnownUser(type: string, openid: string, accountId: string): boolean { - return removeKnownUserImpl(accountId, openid, type as "c2c" | "group"); -} - -/** Clear all known users, optionally scoped to a single account (adapter). */ -export function clearKnownUsers(accountId?: string): number { - return clearKnownUsersImpl(accountId); -} - -/** Resolve account config and send a proactive message. */ -export async function sendProactive( - options: ProactiveSendOptions, - cfg: OpenClawConfig, -): Promise { - const { - to, - text, - type = "c2c", - imageUrl, - accountId = resolveDefaultQQBotAccountId(cfg), - } = options; - - const account = resolveQQBotAccount(cfg, accountId); - - if (!account.appId || !account.clientSecret) { - return { - success: false, - error: "QQBot not configured (missing appId or clientSecret)", - }; - } - - try { - const accessToken = await getAccessToken(account.appId, account.clientSecret); - - if (imageUrl) { - try { - if (type === "c2c") { - await sendC2CImageMessage(account.appId, accessToken, to, imageUrl, undefined, undefined); - } else if (type === "group") { - await sendGroupImageMessage( - account.appId, - accessToken, - to, - imageUrl, - undefined, - undefined, - ); - } - debugLog(`[qqbot:proactive] Sent image to ${type}:${to}`); - } catch (err) { - debugError(`[qqbot:proactive] Failed to send image: ${String(err)}`); - } - } - - let result: { id: string; timestamp: number | string }; - - if (type === "c2c") { - result = await sendProactiveC2CMessage(account.appId, accessToken, to, text); - } else if (type === "group") { - result = await sendProactiveGroupMessage(account.appId, accessToken, to, text); - } else if (type === "channel") { - return { - success: false, - error: "Channel proactive messages are not supported. Please use group or c2c.", - }; - } else { - return { - success: false, - error: `Unknown message type: ${String(type)}`, - }; - } - - debugLog(`[qqbot:proactive] Sent message to ${type}:${to}, id: ${result.id}`); - - return { - success: true, - messageId: result.id, - timestamp: result.timestamp, - }; - } catch (err) { - const message = formatErrorMessage(err); - debugError(`[qqbot:proactive] Failed to send message: ${message}`); - - return { - success: false, - error: message, - }; - } -} - -/** Send one proactive message to each recipient. */ -export async function sendBulkProactiveMessage( - recipients: string[], - text: string, - type: "c2c" | "group", - cfg: OpenClawConfig, - accountId = resolveDefaultQQBotAccountId(cfg), -): Promise> { - const results: Array<{ to: string; result: ProactiveSendResult }> = []; - - for (const to of recipients) { - const result = await sendProactive({ to, text, type, accountId }, cfg); - results.push({ to, result }); - - // Add a small delay to reduce rate-limit pressure. - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - return results; -} - -/** - * Send a message to all known users. - * - * @param text Message content. - * @param cfg OpenClaw config. - * @param options Optional filters. - * @returns Aggregate send statistics. - */ -export async function broadcastMessage( - text: string, - cfg: OpenClawConfig, - options?: { - type?: "c2c" | "group"; - accountId?: string; - limit?: number; - }, -): Promise<{ - total: number; - success: number; - failed: number; - results: Array<{ to: string; result: ProactiveSendResult }>; -}> { - const users = listKnownUsers({ - type: options?.type, - accountId: options?.accountId, - limit: options?.limit, - sortByLastInteraction: true, - }); - - // Channel recipients do not support proactive sends. - const validUsers = users.filter((u) => u.type === "c2c" || u.type === "group"); - - const results: Array<{ to: string; result: ProactiveSendResult }> = []; - let success = 0; - let failed = 0; - - for (const user of validUsers) { - const targetId = user.type === "group" ? (user.groupOpenid ?? user.openid) : user.openid; - const result = await sendProactive( - { - to: targetId, - text, - type: user.type, - accountId: user.accountId, - }, - cfg, - ); - - results.push({ to: targetId, result }); - - if (result.success) { - success++; - } else { - failed++; - } - - // Add a small delay to reduce rate-limit pressure. - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - return { - total: validUsers.length, - success, - failed, - results, - }; -} - -// Helpers. - -/** - * Send a proactive message using a resolved account without a full config object. - * - * @param account Resolved account configuration. - * @param to Target openid. - * @param text Message content. - * @param type Message type. - */ -export async function sendProactiveMessageDirect( - account: ResolvedQQBotAccount, - to: string, - text: string, - type: "c2c" | "group" = "c2c", -): Promise { - if (!account.appId || !account.clientSecret) { - return { - success: false, - error: "QQBot not configured (missing appId or clientSecret)", - }; - } - - try { - const accessToken = await getAccessToken(account.appId, account.clientSecret); - - let result: { id: string; timestamp: number | string }; - - if (type === "c2c") { - result = await sendProactiveC2CMessage(account.appId, accessToken, to, text); - } else { - result = await sendProactiveGroupMessage(account.appId, accessToken, to, text); - } - - return { - success: true, - messageId: result.id, - timestamp: result.timestamp, - }; - } catch (err) { - return { - success: false, - error: formatErrorMessage(err), - }; - } -} - -/** - * Return known-user counts for the selected account. - */ -export function getKnownUsersStats(accountId?: string): { - total: number; - c2c: number; - group: number; - channel: number; -} { - const users = listKnownUsers({ accountId }); - - return { - total: users.length, - c2c: users.filter((u) => u.type === "c2c").length, - group: users.filter((u) => u.type === "group").length, - channel: 0, // Channel users are not tracked in known-users storage. - }; -} diff --git a/extensions/qqbot/src/reply-dispatcher.test.ts b/extensions/qqbot/src/reply-dispatcher.test.ts deleted file mode 100644 index 9731883e1b1..00000000000 --- a/extensions/qqbot/src/reply-dispatcher.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -const apiMocks = vi.hoisted(() => ({ - clearTokenCache: vi.fn(), - getAccessToken: vi.fn().mockResolvedValue("token"), - sendC2CFileMessage: vi.fn(), - sendC2CImageMessage: vi.fn(), - sendC2CMessage: vi.fn(), - sendC2CVideoMessage: vi.fn(), - sendC2CVoiceMessage: vi.fn(), - sendChannelMessage: vi.fn(), - sendDmMessage: vi.fn(), - sendGroupFileMessage: vi.fn(), - sendGroupImageMessage: vi.fn(), - sendGroupMessage: vi.fn(), - sendGroupVideoMessage: vi.fn(), - sendGroupVoiceMessage: vi.fn(), -})); - -vi.mock("./api.js", () => apiMocks); - -import { handleStructuredPayload, type ReplyContext } from "./reply-dispatcher.js"; - -function buildCtx(): ReplyContext { - return { - target: { - type: "c2c", - senderId: "user-1", - messageId: "msg-1", - }, - account: { - accountId: "default", - appId: "app-id", - clientSecret: "secret", - config: {}, - } as ReplyContext["account"], - cfg: {}, - log: { - info: vi.fn(), - error: vi.fn(), - }, - }; -} - -describe("qqbot reply dispatcher", () => { - it("allows inline data image URLs for structured image payloads", async () => { - const ctx = buildCtx(); - const recordActivity = vi.fn(); - const dataUrl = "data:image/png;base64,Zm9v"; - - const handled = await handleStructuredPayload( - ctx, - `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "image", - source: "url", - path: dataUrl, - })}`, - recordActivity, - ); - - expect(handled).toBe(true); - expect(recordActivity).toHaveBeenCalledTimes(1); - expect(apiMocks.sendC2CImageMessage).toHaveBeenCalledWith( - "app-id", - "token", - "user-1", - dataUrl, - "msg-1", - undefined, - undefined, - ); - }); -}); diff --git a/extensions/qqbot/src/reply-dispatcher.ts b/extensions/qqbot/src/reply-dispatcher.ts deleted file mode 100644 index 2c879cf2b55..00000000000 --- a/extensions/qqbot/src/reply-dispatcher.ts +++ /dev/null @@ -1,714 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { - getAccessToken, - sendC2CMessage, - sendChannelMessage, - sendDmMessage, - sendGroupMessage, - clearTokenCache, - sendC2CImageMessage, - sendGroupImageMessage, - sendC2CVoiceMessage, - sendGroupVoiceMessage, - sendC2CVideoMessage, - sendGroupVideoMessage, - sendC2CFileMessage, - sendGroupFileMessage, -} from "./api.js"; -import { getQQBotRuntime } from "./runtime.js"; -import type { ResolvedQQBotAccount } from "./types.js"; -import { - isGlobalTTSAvailable, - resolveTTSConfig, - textToSilk, - audioFileToSilkBase64, - formatDuration, -} from "./utils/audio-convert.js"; -import { MAX_UPLOAD_SIZE, formatFileSize } from "./utils/file-utils.js"; -import { - parseQQBotPayload, - encodePayloadForCron, - isCronReminderPayload, - isMediaPayload, - type MediaPayload, -} from "./utils/payload.js"; -import { - getQQBotDataDir, - normalizePath, - resolveQQBotPayloadLocalFilePath, - sanitizeFileName, -} from "./utils/platform.js"; - -export interface MessageTarget { - type: "c2c" | "guild" | "dm" | "group"; - senderId: string; - messageId: string; - channelId?: string; - guildId?: string; - groupOpenid?: string; -} - -export interface ReplyContext { - target: MessageTarget; - account: ResolvedQQBotAccount; - cfg: unknown; - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; -} - -/** Send a message and retry once if the token appears to have expired. */ -export async function sendWithTokenRetry( - appId: string, - clientSecret: string, - sendFn: (token: string) => Promise, - log?: ReplyContext["log"], - accountId?: string, -): Promise { - try { - const token = await getAccessToken(appId, clientSecret); - return await sendFn(token); - } catch (err) { - const errMsg = String(err); - if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) { - log?.info(`[qqbot:${accountId}] Token may be expired, refreshing...`); - clearTokenCache(appId); - const newToken = await getAccessToken(appId, clientSecret); - return await sendFn(newToken); - } else { - throw err; - } - } -} - -/** Route a text message to the correct QQ target type. */ -export async function sendTextToTarget( - ctx: ReplyContext, - text: string, - refIdx?: string, -): Promise { - const { target, account } = ctx; - await sendWithTokenRetry( - account.appId, - account.clientSecret, - async (token) => { - if (target.type === "c2c") { - await sendC2CMessage(account.appId, token, target.senderId, text, target.messageId, refIdx); - } else if (target.type === "group" && target.groupOpenid) { - await sendGroupMessage(account.appId, token, target.groupOpenid, text, target.messageId); - } else if (target.channelId) { - await sendChannelMessage(token, target.channelId, text, target.messageId); - } else if (target.type === "dm" && target.guildId) { - await sendDmMessage(token, target.guildId, text, target.messageId); - } - }, - ctx.log, - account.accountId, - ); -} - -/** Best-effort delivery for error text back to the user. */ -export async function sendErrorToTarget(ctx: ReplyContext, errorText: string): Promise { - try { - await sendTextToTarget(ctx, errorText); - } catch (sendErr) { - ctx.log?.error( - `[qqbot:${ctx.account.accountId}] Failed to send error message: ${String(sendErr)}`, - ); - } -} - -/** - * 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, -): Promise { - const { account, log } = ctx; - const payloadResult = parseQQBotPayload(replyText); - - if (!payloadResult.isPayload) { - return false; - } - - if (payloadResult.error) { - log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`); - return true; - } - - if (!payloadResult.payload) { - return true; - } - - const parsedPayload = payloadResult.payload; - const unknownPayload = payloadResult.payload as unknown; - log?.info( - `[qqbot:${account.accountId}] Detected structured payload, type: ${parsedPayload.type}`, - ); - - if (isCronReminderPayload(parsedPayload)) { - log?.info(`[qqbot:${account.accountId}] 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?.info( - `[qqbot:${account.accountId}] Cron reminder confirmation sent, cronMessage: ${cronMessage}`, - ); - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] Failed to send cron confirmation: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - } - recordActivity(); - return true; - } - - if (isMediaPayload(parsedPayload)) { - log?.info( - `[qqbot:${account.accountId}] Processing media payload, mediaType: ${parsedPayload.mediaType}`, - ); - - if (parsedPayload.mediaType === "image") { - await handleImagePayload(ctx, parsedPayload); - } else if (parsedPayload.mediaType === "audio") { - await handleAudioPayload(ctx, parsedPayload); - } else if (parsedPayload.mediaType === "video") { - await handleVideoPayload(ctx, parsedPayload); - } else if (parsedPayload.mediaType === "file") { - await handleFilePayload(ctx, parsedPayload); - } else { - log?.error( - `[qqbot:${account.accountId}] 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(`[qqbot:${account.accountId}] 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( - `[qqbot:${ctx.account.accountId}] 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 logUnsupportedStructuredMediaTarget( - ctx: ReplyContext, - mediaType: Exclude, -): void { - const label = formatMediaTypeLabel(mediaType); - if (ctx.target.type === "dm") { - ctx.log?.error(`[qqbot:${ctx.account.accountId}] ${label} not supported in DM`); - } else if (ctx.target.channelId) { - ctx.log?.error(`[qqbot:${ctx.account.accountId}] ${label} not supported in channel`); - } -} - -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 ""; - } - - 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 ""; - } -} - -async function readStructuredPayloadLocalFile(filePath: string): Promise { - 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 { - 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( - `[qqbot:${account.accountId}] 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 = { - ".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(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`); - return; - } - imageUrl = `data:${mimeType};base64,${base64Data}`; - log?.info( - `[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`, - ); - } catch (readErr) { - log?.error( - `[qqbot:${account.accountId}] Failed to read local image: ${ - readErr instanceof Error ? readErr.message : JSON.stringify(readErr) - }`, - ); - return; - } - } - - try { - await sendWithTokenRetry( - account.appId, - account.clientSecret, - async (token) => { - if (target.type === "c2c") { - await sendC2CImageMessage( - account.appId, - token, - target.senderId, - imageUrl, - target.messageId, - undefined, - originalImagePath, - ); - } else if (target.type === "group" && target.groupOpenid) { - await sendGroupImageMessage( - account.appId, - token, - target.groupOpenid, - imageUrl, - target.messageId, - ); - } else if (target.type === "dm" && target.guildId) { - // By design: DM only supports text/markdown; use markdown image syntax with the - // original path so the QQ client can attempt to render it. - await sendDmMessage(token, target.guildId, `![](${payload.path})`, target.messageId); - } else if (target.channelId) { - // By design: channel messages only support text/markdown, same approach as DM above. - await sendChannelMessage( - token, - target.channelId, - `![](${payload.path})`, - target.messageId, - ); - } - }, - log, - account.accountId, - ); - log?.info(`[qqbot:${account.accountId}] Sent image via media payload`); - - if (payload.caption) { - await sendTextToTarget(ctx, payload.caption); - } - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] Failed to send image: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - } -} - -async function handleAudioPayload(ctx: ReplyContext, payload: MediaPayload): Promise { - const { target, account, cfg, log } = ctx; - try { - const ttsText = payload.caption || payload.path; - if (!ttsText?.trim()) { - log?.error(`[qqbot:${account.accountId}] Voice missing text`); - return; - } - - let silkBase64: string | undefined; - let silkPath: string | undefined; - let duration: number | undefined; - let providerLabel: string | undefined; - - // Strategy 1: Plugin-specific TTS (OpenAI-compatible /audio/speech API). - const ttsCfg = resolveTTSConfig(cfg as Record); - if (ttsCfg) { - log?.info( - `[qqbot:${account.accountId}] TTS (plugin): "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`, - ); - const ttsDir = getQQBotDataDir("tts"); - const result = await textToSilk(ttsText, ttsCfg, ttsDir); - silkBase64 = result.silkBase64; - silkPath = result.silkPath; - duration = result.duration; - providerLabel = ttsCfg.model; - } else { - // Strategy 2: Fall back to global TTS provider registry (e.g. Edge TTS). - if (!isGlobalTTSAvailable(cfg as OpenClawConfig)) { - log?.error( - `[qqbot:${account.accountId}] TTS not configured (neither plugin channels.qqbot.tts nor global messages.tts)`, - ); - return; - } - log?.info(`[qqbot:${account.accountId}] TTS (global fallback): "${ttsText.slice(0, 50)}..."`); - const globalResult = await getQQBotRuntime().tts.textToSpeech({ - text: ttsText, - cfg: cfg as OpenClawConfig, - channel: "qqbot", - }); - if (!globalResult.success || !globalResult.audioPath) { - log?.error( - `[qqbot:${account.accountId}] Global TTS failed: ${globalResult.error ?? "unknown"}`, - ); - return; - } - log?.info( - `[qqbot:${account.accountId}] Global TTS returned: provider=${globalResult.provider}, format=${globalResult.outputFormat}, path=${globalResult.audioPath}`, - ); - providerLabel = globalResult.provider ?? "global"; - - // Convert the global TTS audio file to SILK for QQ upload. - const base64 = await audioFileToSilkBase64(globalResult.audioPath); - if (!base64) { - log?.error(`[qqbot:${account.accountId}] Failed to convert global TTS audio to SILK`); - return; - } - silkBase64 = base64; - silkPath = globalResult.audioPath; - duration = 0; // Duration unknown from global TTS; use 0 as fallback. - } - - if (!silkBase64) { - log?.error(`[qqbot:${account.accountId}] TTS produced no audio output`); - return; - } - - log?.info( - `[qqbot:${account.accountId}] TTS done (${providerLabel}): ${duration ? formatDuration(duration) : "N/A"}, file: ${silkPath ?? "N/A"}`, - ); - - await sendWithTokenRetry( - account.appId, - account.clientSecret, - async (token) => { - if (target.type === "c2c") { - await sendC2CVoiceMessage( - account.appId, - token, - target.senderId, - silkBase64, - undefined, - target.messageId, - ttsText, - silkPath, - ); - } else if (target.type === "group" && target.groupOpenid) { - await sendGroupVoiceMessage( - account.appId, - token, - target.groupOpenid, - silkBase64, - undefined, - target.messageId, - ); - } else if (target.type === "dm" && target.guildId) { - log?.error( - `[qqbot:${account.accountId}] Voice not supported in DM, sending text fallback`, - ); - await sendDmMessage(token, target.guildId, ttsText, target.messageId); - } else if (target.channelId) { - log?.error( - `[qqbot:${account.accountId}] Voice not supported in channel, sending text fallback`, - ); - await sendChannelMessage(token, target.channelId, ttsText, target.messageId); - } - }, - log, - account.accountId, - ); - log?.info(`[qqbot:${account.accountId}] Voice message sent`); - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] TTS/voice send failed: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - } -} - -async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise { - const { target, account, log } = ctx; - try { - const resolved = resolveStructuredPayloadPath(ctx, payload, "video"); - if (!resolved) { - return; - } - const videoPath = resolved.path; - const isHttpUrl = resolved.isHttpUrl; - - log?.info( - `[qqbot:${account.accountId}] Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`, - ); - - await sendWithTokenRetry( - account.appId, - account.clientSecret, - async (token) => { - if (isHttpUrl) { - if (target.type === "c2c") { - await sendC2CVideoMessage( - account.appId, - token, - target.senderId, - videoPath, - undefined, - target.messageId, - ); - } else if (target.type === "group" && target.groupOpenid) { - await sendGroupVideoMessage( - account.appId, - token, - target.groupOpenid, - videoPath, - undefined, - target.messageId, - ); - } else { - logUnsupportedStructuredMediaTarget(ctx, "video"); - } - } else { - const fileBuffer = await readStructuredPayloadLocalFile(videoPath); - const videoBase64 = fileBuffer.toString("base64"); - log?.info( - `[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${describeMediaTargetForLog(videoPath, false)}`, - ); - - if (target.type === "c2c") { - await sendC2CVideoMessage( - account.appId, - token, - target.senderId, - undefined, - videoBase64, - target.messageId, - undefined, - videoPath, - ); - } else if (target.type === "group" && target.groupOpenid) { - await sendGroupVideoMessage( - account.appId, - token, - target.groupOpenid, - undefined, - videoBase64, - target.messageId, - ); - } else { - logUnsupportedStructuredMediaTarget(ctx, "video"); - } - } - }, - log, - account.accountId, - ); - log?.info(`[qqbot:${account.accountId}] Video message sent`); - - if (payload.caption) { - await sendTextToTarget(ctx, payload.caption); - } - } catch (err) { - const errMsg = - err instanceof Error ? err.message : typeof err === "string" ? err : JSON.stringify(err); - log?.error(`[qqbot:${account.accountId}] Video send failed: ${errMsg}`); - } -} - -async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise { - 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?.info( - `[qqbot:${account.accountId}] File send: ${describeMediaTargetForLog(filePath, isHttpUrl)} (${isHttpUrl ? "URL" : "local"})`, - ); - - await sendWithTokenRetry( - account.appId, - account.clientSecret, - async (token) => { - if (isHttpUrl) { - if (target.type === "c2c") { - await sendC2CFileMessage( - account.appId, - token, - target.senderId, - undefined, - filePath, - target.messageId, - fileName, - ); - } else if (target.type === "group" && target.groupOpenid) { - await sendGroupFileMessage( - account.appId, - token, - target.groupOpenid, - undefined, - filePath, - target.messageId, - fileName, - ); - } else { - logUnsupportedStructuredMediaTarget(ctx, "file"); - } - } else { - const fileBuffer = await readStructuredPayloadLocalFile(filePath); - const fileBase64 = fileBuffer.toString("base64"); - if (target.type === "c2c") { - await sendC2CFileMessage( - account.appId, - token, - target.senderId, - fileBase64, - undefined, - target.messageId, - fileName, - filePath, - ); - } else if (target.type === "group" && target.groupOpenid) { - await sendGroupFileMessage( - account.appId, - token, - target.groupOpenid, - fileBase64, - undefined, - target.messageId, - fileName, - ); - } else { - logUnsupportedStructuredMediaTarget(ctx, "file"); - } - } - }, - log, - account.accountId, - ); - log?.info(`[qqbot:${account.accountId}] File message sent`); - } catch (err) { - const errMsg = - err instanceof Error ? err.message : typeof err === "string" ? err : JSON.stringify(err); - log?.error(`[qqbot:${account.accountId}] File send failed: ${errMsg}`); - } -} diff --git a/extensions/qqbot/src/runtime.ts b/extensions/qqbot/src/runtime.ts deleted file mode 100644 index 4d2db1314be..00000000000 --- a/extensions/qqbot/src/runtime.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; - -const { setRuntime: setQQBotRuntime, getRuntime: getQQBotRuntime } = - createPluginRuntimeStore({ - pluginId: "qqbot", - errorMessage: "QQBot runtime not initialized", - }); -export { getQQBotRuntime, setQQBotRuntime }; diff --git a/extensions/qqbot/src/session-store.test.ts b/extensions/qqbot/src/session-store.test.ts deleted file mode 100644 index e96151a68cd..00000000000 --- a/extensions/qqbot/src/session-store.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { SessionState } from "./session-store.js"; - -type SessionStoreModule = typeof import("./session-store.js"); - -async function loadSessionStore(testRoot: string): Promise { - vi.resetModules(); - vi.doMock("./utils/platform.js", () => ({ - getQQBotDataDir: (...subPaths: string[]) => { - const dir = path.join(testRoot, ...subPaths); - fs.mkdirSync(dir, { recursive: true }); - return dir; - }, - })); - return import("./session-store.js"); -} - -function buildSession(accountId: string, overrides: Partial = {}): SessionState { - return { - accountId, - intentLevelIndex: 0, - lastConnectedAt: 1_700_000_000_000, - lastSeq: 42, - savedAt: 1_700_000_000_000, - sessionId: `session-${accountId}`, - ...overrides, - }; -} - -describe("qqbot session store", () => { - const tempRoots: string[] = []; - - afterEach(() => { - vi.resetModules(); - vi.doUnmock("./utils/platform.js"); - vi.restoreAllMocks(); - for (const root of tempRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("keeps distinct sessions when account ids collide under the legacy filename sanitizer", async () => { - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-session-store-")); - tempRoots.push(testRoot); - const store = await loadSessionStore(testRoot); - - const colonAccount = "acct:one"; - const slashAccount = "acct/one"; - store.saveSession(buildSession(colonAccount, { lastSeq: 11, sessionId: "colon-session" })); - store.saveSession(buildSession(slashAccount, { lastSeq: 22, sessionId: "slash-session" })); - - expect(store.loadSession(colonAccount)).toMatchObject({ - accountId: colonAccount, - lastSeq: 11, - sessionId: "colon-session", - }); - expect(store.loadSession(slashAccount)).toMatchObject({ - accountId: slashAccount, - lastSeq: 22, - sessionId: "slash-session", - }); - - const sessionFiles = fs - .readdirSync(path.join(testRoot, "sessions")) - .filter((file) => file.startsWith("session-") && file.endsWith(".json")); - expect(sessionFiles).toHaveLength(2); - }); - - it("loads a legacy sanitized session file for backward compatibility", async () => { - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-session-store-")); - tempRoots.push(testRoot); - const store = await loadSessionStore(testRoot); - - const accountId = "legacy/account:id"; - const legacyPath = path.join( - testRoot, - "sessions", - `session-${accountId.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`, - ); - fs.mkdirSync(path.dirname(legacyPath), { recursive: true }); - fs.writeFileSync( - legacyPath, - JSON.stringify(buildSession(accountId, { savedAt: Date.now(), sessionId: "legacy" })), - ); - - expect(store.loadSession(accountId)).toMatchObject({ - accountId, - sessionId: "legacy", - }); - }); -}); diff --git a/extensions/qqbot/src/setup-surface.ts b/extensions/qqbot/src/setup-surface.ts deleted file mode 100644 index 18889e51355..00000000000 --- a/extensions/qqbot/src/setup-surface.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { - createStandardChannelSetupStatus, - hasConfiguredSecretInput, - setSetupChannelEnabled, -} from "openclaw/plugin-sdk/setup"; -import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { - DEFAULT_ACCOUNT_ID, - listQQBotAccountIds, - resolveQQBotAccount, - applyQQBotAccountConfig, -} from "./config.js"; - -const channel = "qqbot" as const; - -type QQBotEnvCredentialField = "appId" | "clientSecret"; -type QQBotSetupCredentialState = { - accountConfigured: boolean; - hasConfiguredSecretValue: boolean; - resolvedAppId?: string; - resolvedClientSecret?: string; -}; - -function resolveQQBotSetupCredentialState( - cfg: OpenClawConfig, - accountId: string, -): QQBotSetupCredentialState { - const resolved = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }); - const hasConfiguredSecretValue = Boolean( - hasConfiguredSecretInput(resolved.config.clientSecret) || - normalizeOptionalString(resolved.config.clientSecretFile) || - resolved.clientSecret, - ); - return { - accountConfigured: Boolean(resolved.appId && hasConfiguredSecretValue), - hasConfiguredSecretValue, - resolvedAppId: resolved.appId || undefined, - resolvedClientSecret: resolved.clientSecret || undefined, - }; -} - -/** - * Clear only the credential fields owned by the setup prompt that switched to - * env-backed resolution. This preserves mixed-source setups such as config - * AppID + env AppSecret. - */ -function clearQQBotCredentialField( - cfg: OpenClawConfig, - accountId: string, - field: QQBotEnvCredentialField, -): OpenClawConfig { - const next = { ...cfg }; - const qqbot = { ...(next.channels?.qqbot as Record | undefined) }; - - const clearField = (entry: Record) => { - 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> | undefined) }; - if (accounts[accountId]) { - const entry = { ...accounts[accountId] }; - clearField(entry); - accounts[accountId] = entry; - qqbot.accounts = accounts; - } - } - - next.channels = { ...next.channels, qqbot }; - return next; -} - -const QQBOT_SETUP_HELP_LINES = [ - "To create a QQ Bot, visit the QQ Open Platform:", - ` ${formatDocsLink("https://q.qq.com", "q.qq.com")}`, - "", - "1. Create an application and note the AppID.", - "2. Go to development settings to find the AppSecret.", -]; - -export const qqbotSetupWizard: ChannelSetupWizard = { - channel, - status: createStandardChannelSetupStatus({ - channelLabel: "QQ Bot", - configuredLabel: "configured", - unconfiguredLabel: "needs AppID + AppSecret", - configuredHint: "configured", - unconfiguredHint: "needs AppID + AppSecret", - configuredScore: 1, - unconfiguredScore: 6, - resolveConfigured: ({ cfg, accountId }) => - (accountId ? [accountId] : listQQBotAccountIds(cfg)).some((resolvedAccountId) => { - const account = resolveQQBotAccount(cfg, resolvedAccountId, { - allowUnresolvedSecretRef: true, - }); - return Boolean( - account.appId && - (Boolean(account.clientSecret) || - hasConfiguredSecretInput(account.config.clientSecret) || - Boolean(account.config.clientSecretFile?.trim())), - ); - }), - }), - credentials: [ - { - inputKey: "token", - providerHint: channel, - credentialLabel: "AppID", - preferredEnvVar: "QQBOT_APP_ID", - helpTitle: "QQ Bot AppID", - helpLines: QQBOT_SETUP_HELP_LINES, - envPrompt: "QQBOT_APP_ID detected. Use env var?", - keepPrompt: "QQ Bot AppID already configured. Keep it?", - inputPrompt: "Enter QQ Bot AppID", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const state = resolveQQBotSetupCredentialState(cfg, accountId); - return { - accountConfigured: state.accountConfigured, - hasConfiguredValue: Boolean(state.resolvedAppId), - resolvedValue: state.resolvedAppId, - envValue: - accountId === DEFAULT_ACCOUNT_ID - ? normalizeOptionalString(process.env.QQBOT_APP_ID) - : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }) => - clearQQBotCredentialField(applyQQBotAccountConfig(cfg, accountId, {}), accountId, "appId"), - applySet: ({ cfg, accountId, resolvedValue }) => - applyQQBotAccountConfig(cfg, accountId, { appId: resolvedValue }), - }, - { - inputKey: "password", - providerHint: "qqbot-secret", - credentialLabel: "AppSecret", - preferredEnvVar: "QQBOT_CLIENT_SECRET", - helpTitle: "QQ Bot AppSecret", - helpLines: QQBOT_SETUP_HELP_LINES, - envPrompt: "QQBOT_CLIENT_SECRET detected. Use env var?", - keepPrompt: "QQ Bot AppSecret already configured. Keep it?", - inputPrompt: "Enter QQ Bot AppSecret", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const state = resolveQQBotSetupCredentialState(cfg, accountId); - return { - accountConfigured: state.accountConfigured, - hasConfiguredValue: state.hasConfiguredSecretValue, - resolvedValue: state.resolvedClientSecret, - envValue: - accountId === DEFAULT_ACCOUNT_ID - ? normalizeOptionalString(process.env.QQBOT_CLIENT_SECRET) - : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }) => - clearQQBotCredentialField( - applyQQBotAccountConfig(cfg, accountId, {}), - accountId, - "clientSecret", - ), - applySet: ({ cfg, accountId, resolvedValue }) => - applyQQBotAccountConfig(cfg, accountId, { clientSecret: resolvedValue }), - }, - ], - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; diff --git a/extensions/qqbot/src/setup.test.ts b/extensions/qqbot/src/setup.test.ts deleted file mode 100644 index 4b0572bee1c..00000000000 --- a/extensions/qqbot/src/setup.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { describe, expect, it } from "vitest"; -import { createPluginSetupWizardStatus } from "../../../test/helpers/plugins/setup-wizard.js"; -import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./channel-config-shared.js"; -import { DEFAULT_ACCOUNT_ID } from "./config.js"; -import { makeQqbotDefaultAccountConfig, makeQqbotSecretRefConfig } from "./qqbot-test-support.js"; -import { qqbotSetupWizard } from "./setup-surface.js"; - -const qqbotSetupPlugin = { - id: "qqbot", - setupWizard: qqbotSetupWizard, - meta: { - ...qqbotMeta, - }, - config: { - ...qqbotConfigAdapter, - }, - setup: { - ...qqbotSetupAdapterShared, - }, -}; - -const getQQBotSetupStatus = createPluginSetupWizardStatus(qqbotSetupPlugin as never); - -describe("qqbot setup", () => { - it("treats SecretRef-backed default accounts as configured", () => { - const configured = qqbotSetupWizard.status.resolveConfigured?.({ - cfg: { - channels: { - qqbot: { - appId: "123456", - clientSecret: { - source: "env", - provider: "default", - id: "QQBOT_CLIENT_SECRET", - }, - }, - }, - } as OpenClawConfig, - }); - - expect(configured).toBe(true); - }); - - it("treats named accounts with clientSecretFile as configured", () => { - const configured = qqbotSetupWizard.status.resolveConfigured?.({ - cfg: { - channels: { - qqbot: { - accounts: { - bot2: { - appId: "654321", - clientSecretFile: "/tmp/qqbot-secret.txt", - }, - }, - }, - }, - } as OpenClawConfig, - }); - - expect(configured).toBe(true); - }); - - it("setup status honors the selected named account", async () => { - const status = await getQQBotSetupStatus({ - cfg: { - channels: { - qqbot: { - appId: "123456", - clientSecret: { - source: "env", - provider: "default", - id: "QQBOT_CLIENT_SECRET", - }, - accounts: { - bot2: { - appId: "654321", - }, - }, - }, - }, - } as OpenClawConfig, - accountOverrides: { - qqbot: "bot2", - }, - }); - - expect(status.configured).toBe(false); - expect(status.statusLines).toEqual(["QQ Bot: needs AppID + AppSecret"]); - }); - - it("marks unresolved SecretRef accounts as configured in setup-only plugin status", () => { - const cfg = makeQqbotSecretRefConfig(); - - const account = qqbotSetupPlugin.config.resolveAccount?.(cfg, DEFAULT_ACCOUNT_ID); - - expect(account?.clientSecret).toBe(""); - expect(qqbotSetupPlugin.config.isConfigured?.(account)).toBe(true); - expect(qqbotSetupPlugin.config.describeAccount?.(account)?.configured).toBe(true); - }); - - it("keeps the sibling credential when switching only AppSecret to env mode", async () => { - const cfg = { - channels: { - qqbot: { - appId: "123456", - clientSecret: "secret-from-config", - }, - }, - } as OpenClawConfig; - - const next = await qqbotSetupWizard.credentials[1].applyUseEnv!({ - cfg, - accountId: DEFAULT_ACCOUNT_ID, - }); - - expect(next.channels?.qqbot).toMatchObject({ - appId: "123456", - }); - expect("clientSecret" in (next.channels?.qqbot ?? {})).toBe(false); - expect("clientSecretFile" in (next.channels?.qqbot ?? {})).toBe(false); - }); - - it("normalizes account ids to lowercase", () => { - const setup = qqbotSetupPlugin.setup; - expect(setup).toBeDefined(); - - expect( - setup.resolveAccountId?.({ - accountId: " Bot2 ", - } as never), - ).toBe("bot2"); - }); - - it("uses configured defaultAccount when setup accountId is omitted", () => { - const setup = qqbotSetupPlugin.setup; - expect(setup).toBeDefined(); - - expect( - setup.resolveAccountId?.({ - cfg: makeQqbotDefaultAccountConfig(), - accountId: undefined, - } as never), - ).toBe("bot2"); - }); -}); diff --git a/extensions/qqbot/src/slash-commands.test.ts b/extensions/qqbot/src/slash-commands.test.ts deleted file mode 100644 index c1de7aaef64..00000000000 --- a/extensions/qqbot/src/slash-commands.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import fs from "node:fs"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getFrameworkCommands, - matchSlashCommand, - type SlashCommandContext, -} from "./slash-commands.js"; - -/** Build a minimal SlashCommandContext for testing. */ -function buildCtx(overrides: Partial = {}): SlashCommandContext { - return { - type: "c2c", - senderId: "test-user-001", - messageId: "msg-001", - eventTimestamp: new Date().toISOString(), - receivedAt: Date.now(), - rawContent: "/bot-ping", - args: "", - accountId: "default", - appId: "000000", - commandAuthorized: true, - queueSnapshot: { - totalPending: 0, - activeUsers: 0, - maxConcurrentUsers: 10, - senderPending: 0, - }, - ...overrides, - }; -} - -function stubEmptyLogFilesystem() { - vi.spyOn(fs, "existsSync").mockReturnValue(false); - vi.spyOn(fs, "readdirSync").mockReturnValue([] as never); - vi.spyOn(fs, "statSync").mockImplementation(() => { - throw new Error("missing"); - }); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("slash command authorization", () => { - // ---- /bot-logs (moved to framework registerCommand) ---- - // /bot-logs is registered with the framework via registerCommand() so that - // resolveCommandAuthorization() enforces commands.allowFrom.qqbot precedence - // and qqbot: prefix normalization. It is no longer in the pre-dispatch - // slash-command registry, so matchSlashCommand returns null and lets the - // normal inbound queue handle it. - - it("passes /bot-logs through to the framework (returns null)", async () => { - const ctx = buildCtx({ rawContent: "/bot-logs", commandAuthorized: false }); - expect(await matchSlashCommand(ctx)).toBeNull(); - }); - - it("passes /bot-logs ? through to the framework (returns null)", async () => { - const ctx = buildCtx({ rawContent: "/bot-logs ?", commandAuthorized: false }); - expect(await matchSlashCommand(ctx)).toBeNull(); - }); - - // ---- /bot-ping (no requireAuth) ---- - - it("allows /bot-ping for unauthorized sender", async () => { - const ctx = buildCtx({ - rawContent: "/bot-ping", - commandAuthorized: false, - }); - const result = await matchSlashCommand(ctx); - expect(result).toBeTypeOf("string"); - expect(result as string).toContain("pong"); - }); - - it("allows /bot-ping for authorized sender", async () => { - const ctx = buildCtx({ - rawContent: "/bot-ping", - commandAuthorized: true, - }); - const result = await matchSlashCommand(ctx); - expect(result).toBeTypeOf("string"); - expect(result as string).toContain("pong"); - }); - - // ---- /bot-help (no requireAuth) ---- - - it("allows /bot-help for unauthorized sender", async () => { - const ctx = buildCtx({ - rawContent: "/bot-help", - commandAuthorized: false, - }); - const result = await matchSlashCommand(ctx); - expect(result).toBeTypeOf("string"); - expect(result as string).toContain("QQBot"); - }); - - // ---- /bot-version (no requireAuth) ---- - - it("allows /bot-version for unauthorized sender", async () => { - const ctx = buildCtx({ - rawContent: "/bot-version", - commandAuthorized: false, - }); - const result = await matchSlashCommand(ctx); - expect(result).toBeTypeOf("string"); - expect(result as string).toContain("OpenClaw"); - }); - - // ---- unknown commands ---- - - it("returns null for unknown slash commands", async () => { - const ctx = buildCtx({ - rawContent: "/unknown-command", - commandAuthorized: false, - }); - const result = await matchSlashCommand(ctx); - expect(result).toBeNull(); - }); - - it("returns null for non-slash messages", async () => { - const ctx = buildCtx({ - rawContent: "hello", - commandAuthorized: false, - }); - const result = await matchSlashCommand(ctx); - expect(result).toBeNull(); - }); - - // ---- usage query (?) for remaining pre-dispatch commands ---- -}); - -describe("/bot-logs framework command hardening", () => { - function getBotLogsHandler() { - const command = getFrameworkCommands().find((item) => item.name === "bot-logs"); - expect(command).toBeDefined(); - return command!.handler; - } - - it("rejects /bot-logs when allowFrom is wildcard", async () => { - const handler = getBotLogsHandler(); - const result = await handler(buildCtx({ accountConfig: { allowFrom: ["*"] } })); - expect(result).toBeTypeOf("string"); - expect(result as string).toContain("权限不足"); - }); - - it("rejects /bot-logs when allowFrom mixes wildcard and explicit entries", async () => { - const handler = getBotLogsHandler(); - const result = await handler(buildCtx({ accountConfig: { allowFrom: ["*", "qqbot:user-1"] } })); - expect(result).toBeTypeOf("string"); - expect(result as string).toContain("权限不足"); - }); - - it("rejects /bot-logs when allowFrom uses qqbot:* wildcard form", async () => { - const handler = getBotLogsHandler(); - const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot:*"] } })); - expect(result).toBeTypeOf("string"); - expect(result as string).toContain("权限不足"); - }); - - it("rejects /bot-logs when allowFrom uses qqbot: * wildcard form", async () => { - const handler = getBotLogsHandler(); - const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot: *"] } })); - expect(result).toBeTypeOf("string"); - expect(result as string).toContain("权限不足"); - }); - - it("allows /bot-logs when allowFrom contains numeric sender ids", async () => { - stubEmptyLogFilesystem(); - const handler = getBotLogsHandler(); - const accountConfig = { allowFrom: [12345] } as unknown as SlashCommandContext["accountConfig"]; - const result = await handler(buildCtx({ accountConfig })); - expect(result).toContain("未找到日志文件"); - }); - - it("allows /bot-logs execution when allowFrom is explicit", async () => { - stubEmptyLogFilesystem(); - const handler = getBotLogsHandler(); - const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot:user-1"] } })); - expect(result).toContain("未找到日志文件"); - }); -}); diff --git a/extensions/qqbot/src/slash-commands.ts b/extensions/qqbot/src/slash-commands.ts deleted file mode 100644 index c75c2c99593..00000000000 --- a/extensions/qqbot/src/slash-commands.ts +++ /dev/null @@ -1,649 +0,0 @@ -/** - * QQBot plugin-level slash command handler. - * - * Design goals: - * 1. Intercept plugin commands before messages enter the AI queue. - * 2. Let unmatched "/" messages continue through the normal framework path. - * 3. Keep command registration small and explicit. - */ - -import fs from "node:fs"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime"; -import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import type { QQBotAccountConfig } from "./types.js"; -import { debugLog } from "./utils/debug-log.js"; -import { getHomeDir, getQQBotDataDir, isWindows } from "./utils/platform.js"; -const require = createRequire(import.meta.url); - -// Read the package version from package.json. -const PLUGIN_VERSION = readPluginPackageVersion({ require }); - -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"; - -// ============ 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?: QQBotAccountConfig; - /** 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 { - /** Total pending messages across all sender queues. */ - totalPending: number; - /** Number of senders currently being processed. */ - activeUsers: number; - /** Maximum concurrent sender count. */ - maxConcurrentUsers: number; - /** Pending messages for the current sender. */ - 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. */ -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; -} - -/** Framework command definition for commands that require authorization. */ -export interface QQBotFrameworkCommand { - name: string; - description: string; - usage?: string; - handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise; -} - -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?: QQBotAccountConfig): 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 !== "*"; - }); -} - -// ============ Command registry ============ - -// Pre-dispatch commands (requireAuth: false) — handled immediately before queuing. -const commands: Map = new Map(); - -// Framework commands (requireAuth: true) — registered via api.registerCommand() so that -// resolveCommandAuthorization() applies commands.allowFrom.qqbot precedence and -// qqbot: prefix normalization before the handler runs. -const frameworkCommands: Map = new Map(); - -function registerCommand(cmd: SlashCommand): void { - if (cmd.requireAuth) { - frameworkCommands.set(normalizeLowercaseStringOrEmpty(cmd.name), cmd); - } else { - commands.set(normalizeLowercaseStringOrEmpty(cmd.name), cmd); - } -} - -/** - * Return all commands that require authorization, for registration with the - * framework via api.registerCommand() in registerFull(). - */ -export function getFrameworkCommands(): QQBotFrameworkCommand[] { - return Array.from(frameworkCommands.values()).map((cmd) => ({ - name: cmd.name, - description: cmd.description, - usage: cmd.usage, - handler: cmd.handler, - })); -} - -// ============ 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 the OpenClaw framework version. - */ -registerCommand({ - name: "bot-version", - description: "查看 OpenClaw 框架版本", - usage: [`/bot-version`, ``, `查看当前 OpenClaw 框架版本。`].join("\n"), - handler: async () => { - const frameworkVersion = resolveRuntimeServiceVersion(); - const lines = [`🦞 OpenClaw 版本:${frameworkVersion}`]; - lines.push(`🌟 官方 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: () => { - const lines = [`### QQBot 内置命令`, ``]; - for (const [name, cmd] of commands) { - lines.push(` ${cmd.description}`); - } - for (const [name, cmd] of frameworkCommands) { - lines.push(` ${cmd.description}`); - } - 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(); - - 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([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(); - 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(); - - 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(); - }, -}); - -// Slash command entry point. - -/** - * 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 { - const content = ctx.rawContent.trim(); - if (!content.startsWith("/")) { - return null; - } - - // Parse the command name and trailing arguments. - const spaceIdx = content.indexOf(" "); - const cmdName = normalizeLowercaseStringOrEmpty( - spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx), - ); - const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim(); - - const cmd = commands.get(cmdName); - if (!cmd) { - return null; - } - - // Gate sensitive commands behind the allowFrom authorization check. - if (cmd.requireAuth && !ctx.commandAuthorized) { - debugLog( - `[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; - const result = await cmd.handler(ctx); - return result; -} - -/** Return the plugin version for external callers. */ -export function getPluginVersion(): string { - return PLUGIN_VERSION; -} diff --git a/extensions/qqbot/src/text-utils.ts b/extensions/qqbot/src/text-utils.ts deleted file mode 100644 index e05e110eb15..00000000000 --- a/extensions/qqbot/src/text-utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getQQBotRuntime } from "./runtime.js"; - -/** Maximum text length for a single QQ Bot message. */ -export const TEXT_CHUNK_LIMIT = 5000; - -/** - * Markdown-aware text chunking. - * - * Delegates to the SDK chunker so code fences and bracket balance stay intact. - */ -export function chunkText(text: string, limit: number): string[] { - const runtime = getQQBotRuntime(); - return runtime.channel.text.chunkMarkdownText(text, limit); -} diff --git a/extensions/qqbot/src/tools/channel.ts b/extensions/qqbot/src/tools/channel.ts deleted file mode 100644 index 87b61414a6c..00000000000 --- a/extensions/qqbot/src/tools/channel.ts +++ /dev/null @@ -1,256 +0,0 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { getAccessToken } from "../api.js"; -import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js"; -import { debugError, debugLog } from "../utils/debug-log.js"; -import { jsonToolResult as json } from "./result.js"; - -const API_BASE = "https://api.sgroup.qq.com"; -const DEFAULT_TIMEOUT_MS = 30000; - -interface ChannelApiParams { - method: string; - path: string; - body?: Record; - query?: Record; -} - -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; - -function buildUrl(path: string, query?: Record): 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; -} - -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; -} - -/** - * 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) { - debugLog("[qqbot-channel-api] No config available, skipping"); - return; - } - - const accountIds = listQQBotAccountIds(cfg); - if (accountIds.length === 0) { - debugLog("[qqbot-channel-api] No QQBot accounts configured, skipping"); - return; - } - - const firstAccountId = accountIds[0]; - const account = resolveQQBotAccount(cfg, firstAccountId); - - if (!account.appId || !account.clientSecret) { - debugLog("[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 p = params as ChannelApiParams; - if (!p.method) { - return json({ error: "method is required" }); - } - if (!p.path) { - return json({ error: "path is required" }); - } - - const method = p.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(p.path); - if (pathError) { - return json({ error: pathError }); - } - - if ((method === "GET" || method === "DELETE") && p.body && Object.keys(p.body).length > 0) { - debugLog(`[qqbot-channel-api] ${method} request with body, body will be ignored`); - } - - try { - const accessToken = await getAccessToken(account.appId, account.clientSecret); - const url = buildUrl(p.path, p.query); - const headers: Record = { - Authorization: `QQBot ${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 (p.body && ["POST", "PUT", "PATCH"].includes(method)) { - fetchOptions.body = JSON.stringify(p.body); - } - - debugLog(`[qqbot-channel-api] >>> ${method} ${url} (timeout: ${DEFAULT_TIMEOUT_MS}ms)`); - - let res: Response; - let release = async () => {}; - try { - const guarded = await fetchWithSsrFGuard({ - url, - init: fetchOptions, - auditContext: `qqbot.channel-api${p.path}`, - }); - res = guarded.response; - release = guarded.release; - } 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: p.path, - }); - } - debugError("[qqbot-channel-api] <<< Network error:", err); - return json({ - error: `Network error: ${formatErrorMessage(err)}`, - path: p.path, - }); - } finally { - clearTimeout(timeoutId); - } - - debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`); - - try { - const rawBody = await res.text(); - if (!rawBody || rawBody.trim() === "") { - if (res.ok) { - return json({ success: true, status: res.status, path: p.path }); - } - return json({ - error: `API returned ${res.status} ${res.statusText}`, - status: res.status, - path: p.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} ${p.path}]: ${errMsg}`); - return json({ - error: errMsg, - status: res.status, - path: p.path, - details: parsed, - }); - } - - return json({ - success: true, - status: res.status, - path: p.path, - data: parsed, - }); - } finally { - await release(); - } - } catch (err) { - return json({ - error: formatErrorMessage(err), - path: p.path, - }); - } - }, - }, - { name: "qqbot_channel_api" }, - ); -} diff --git a/extensions/qqbot/src/tools/remind.ts b/extensions/qqbot/src/tools/remind.ts deleted file mode 100644 index 5130ed6a82c..00000000000 --- a/extensions/qqbot/src/tools/remind.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { jsonToolResult as json } from "./result.js"; - -interface RemindParams { - action: "add" | "list" | "remove"; - content?: string; - to?: string; - time?: string; - timezone?: string; - name?: string; - jobId?: string; -} - -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: - "Delivery target from the `[QQBot] to=` context value. " + - "Direct-message format: qqbot:c2c:user_openid. Group format: qqbot:group:group_openid. Required when action=add.", - }, - 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; - -function parseRelativeTime(timeStr: string): number | null { - const s = normalizeLowercaseStringOrEmpty(timeStr); - 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; -} - -function isCronExpression(timeStr: string): boolean { - const parts = timeStr.trim().split(/\s+/); - if (parts.length < 3 || parts.length > 6) { - return false; - } - // Each cron field must start with a digit, *, or a cron-special character. - return parts.every((p) => /^[0-9*?/,LW#-]/.test(p)); -} - -function generateJobName(content: string): string { - const trimmed = content.trim(); - const short = trimmed.length > 20 ? `${trimmed.slice(0, 20)}…` : trimmed; - return `Reminder: ${short}`; -} - -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.` - ); -} - -function buildOnceJob(params: RemindParams, delayMs: number) { - const atMs = Date.now() + delayMs; - const to = params.to!; - 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), - deliver: true, - channel: "qqbot", - to, - }, - }, - }; -} - -function buildCronJob(params: RemindParams) { - const to = params.to!; - 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), - deliver: true, - channel: "qqbot", - to, - }, - }, - }; -} - -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`; -} - -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, to=target, time=schedule\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 p = params as RemindParams; - - if (p.action === "list") { - return json({ - _instruction: "Use the cron tool immediately with the following parameters.", - cronParams: { action: "list" }, - }); - } - - if (p.action === "remove") { - if (!p.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: p.jobId }, - }); - } - - if (!p.content) { - return json({ error: "content is required when action=add" }); - } - if (!p.to) { - return json({ error: "to is required when action=add" }); - } - if (!p.time) { - return json({ error: "time is required when action=add" }); - } - - if (isCronExpression(p.time)) { - return json({ - _instruction: - "Use the cron tool immediately with the following parameters, then tell the user the reminder has been scheduled.", - cronParams: buildCronJob(p), - summary: `⏰ Recurring reminder: "${p.content}" (${p.time}, tz=${p.timezone || "Asia/Shanghai"})`, - }); - } - - const delayMs = parseRelativeTime(p.time); - if (delayMs == null) { - return json({ - error: `Could not parse time format: ${p.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, then tell the user the reminder has been scheduled.", - cronParams: buildOnceJob(p, delayMs), - summary: `⏰ Reminder in ${formatDelay(delayMs)}: "${p.content}"`, - }); - }, - }, - { name: "qqbot_remind" }, - ); -} diff --git a/extensions/qqbot/src/types.ts b/extensions/qqbot/src/types.ts index 77aa2c89b9d..391f6d7539d 100644 --- a/extensions/qqbot/src/types.ts +++ b/extensions/qqbot/src/types.ts @@ -1,4 +1,7 @@ import type { SecretInput } from "openclaw/plugin-sdk/secret-input"; +import type { QQBotDmPolicy, QQBotGroupPolicy } from "./engine/access/index.js"; + +export type { QQBotDmPolicy, QQBotGroupPolicy }; /** QQ Bot base config. */ export interface QQBotConfig { @@ -22,6 +25,15 @@ export interface ResolvedQQBotAccount { config: QQBotAccountConfig; } +/** QQBot-native exec approval delivery + approver authorization. */ +export interface QQBotExecApprovalConfig { + enabled?: boolean | "auto"; + approvers?: string[]; + agentFilter?: string[]; + sessionFilter?: string[]; + target?: "dm" | "channel" | "both"; +} + /** QQ Bot account config from user settings. */ export interface QQBotAccountConfig { enabled?: boolean; @@ -29,11 +41,45 @@ export interface QQBotAccountConfig { appId?: string; clientSecret?: SecretInput; clientSecretFile?: string; + /** + * Sender allowlist for direct-message access control and command + * authorization. Entries accept raw openids, `qqbot:OPENID` prefixed + * form, and the `"*"` wildcard. Matching is case-insensitive. + * + * Semantics depend on {@link dmPolicy}: + * - `dmPolicy="open"` (default when allowFrom is empty or contains `"*"`) + * — everyone can DM the bot; the list only influences command gating. + * - `dmPolicy="allowlist"` (default when a non-wildcard list is configured) + * — only listed openids may DM the bot; other DMs are dropped. + * - `dmPolicy="disabled"` — all DMs are dropped regardless of this list. + * + * For group access, see {@link groupAllowFrom} / {@link groupPolicy}. + */ allowFrom?: string[]; + /** + * Group-scoped sender allowlist. If omitted, group access falls back to + * {@link allowFrom}. Set explicitly when the group whitelist needs to + * differ from the DM whitelist. + */ + groupAllowFrom?: string[]; + /** + * DM access policy. Defaults: + * - omitted + allowFrom empty/wildcard → `"open"` + * - omitted + allowFrom non-wildcard → `"allowlist"` + */ + dmPolicy?: QQBotDmPolicy; + /** + * Group access policy. Defaults mirror {@link dmPolicy}: if either + * `groupAllowFrom` or `allowFrom` has a non-wildcard entry the policy + * is `"allowlist"`, otherwise `"open"`. + */ + groupPolicy?: QQBotGroupPolicy; /** Optional system prompt prepended to user messages. */ systemPrompt?: string; /** Whether markdown output is enabled. Defaults to true. */ markdownSupport?: boolean; + /** QQBot-native exec approval delivery + approver authorization. */ + execApprovals?: QQBotExecApprovalConfig; /** * @deprecated Use audioFormatPolicy.uploadDirectFormats instead. * Legacy list of formats that can upload directly without SILK conversion. diff --git a/extensions/qqbot/src/types/silk-wasm.d.ts b/extensions/qqbot/src/types/silk-wasm.d.ts deleted file mode 100644 index 834e1bf082f..00000000000 --- a/extensions/qqbot/src/types/silk-wasm.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module "silk-wasm" { - export type SilkCodecResult = { - data: Uint8Array; - duration: number; - }; - - export function isSilk(input: Uint8Array): boolean; - - export function decode(input: Uint8Array, sampleRate: number): Promise; - - export function encode(input: Uint8Array, sampleRate: number): Promise; -} diff --git a/extensions/qqbot/src/utils/debug-log.ts b/extensions/qqbot/src/utils/debug-log.ts deleted file mode 100644 index a7276389682..00000000000 --- a/extensions/qqbot/src/utils/debug-log.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Debug logging utility for QQBot plugin. - * - * Only outputs when QQBOT_DEBUG environment variable is set. - * Prevents leaking user message content in production logs. - */ - -const isDebug = () => !!process.env.QQBOT_DEBUG; - -export function debugLog(...args: unknown[]): void { - if (isDebug()) { - console.log(...args); - } -} - -export function debugWarn(...args: unknown[]): void { - if (isDebug()) { - console.warn(...args); - } -} - -export function debugError(...args: unknown[]): void { - if (isDebug()) { - console.error(...args); - } -} diff --git a/extensions/qqbot/src/utils/text-parsing.ts b/extensions/qqbot/src/utils/text-parsing.ts deleted file mode 100644 index 5cec13dda9a..00000000000 --- a/extensions/qqbot/src/utils/text-parsing.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { estimateBase64DecodedBytes } from "openclaw/plugin-sdk/media-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import type { RefAttachmentSummary } from "../ref-index-store.js"; - -const MAX_FACE_EXT_BYTES = 64 * 1024; - -/** Replace QQ face tags with readable text labels. */ -export function parseFaceTags(text: string | undefined | null): string { - if (!text) { - return ""; - } - - return text.replace(//g, (_match, ext: string) => { - try { - if (estimateBase64DecodedBytes(ext) > MAX_FACE_EXT_BYTES) { - return "[Emoji: unknown emoji]"; - } - const decoded = Buffer.from(ext, "base64").toString("utf-8"); - const parsed = JSON.parse(decoded); - const faceName = parsed.text || "unknown emoji"; - return `[Emoji: ${faceName}]`; - } catch { - return _match; - } - }); -} - -/** Remove internal framework markers before sending text outward. */ -export function filterInternalMarkers(text: string | undefined | null): string { - if (!text) { - return ""; - } - - let result = text.replace(/\[\[[a-z_]+:\s*[^\]]*\]\]/gi, ""); - result = result.replace(/@(?:image|voice|video|file):[a-zA-Z0-9_.-]+/g, ""); - result = result.replace(/\n{3,}/g, "\n\n").trim(); - - return result; -} - -/** Parse quote-related ref indices from `message_scene.ext`. */ -export function parseRefIndices(ext?: string[]): { refMsgIdx?: string; msgIdx?: string } { - if (!ext || ext.length === 0) { - return {}; - } - let refMsgIdx: string | undefined; - let msgIdx: string | undefined; - for (const item of ext) { - if (item.startsWith("ref_msg_idx=")) { - refMsgIdx = item.slice("ref_msg_idx=".length); - } else if (item.startsWith("msg_idx=")) { - msgIdx = item.slice("msg_idx=".length); - } - } - return { refMsgIdx, msgIdx }; -} - -/** Build attachment summaries for ref-index caching. */ -export function buildAttachmentSummaries( - attachments?: Array<{ - content_type: string; - url: string; - filename?: string; - voice_wav_url?: string; - }>, - localPaths?: Array, -): RefAttachmentSummary[] | undefined { - if (!attachments || attachments.length === 0) { - return undefined; - } - return attachments.map((att, idx) => { - const ct = normalizeLowercaseStringOrEmpty(att.content_type); - let type: RefAttachmentSummary["type"] = "unknown"; - if (ct.startsWith("image/")) { - type = "image"; - } else if ( - ct === "voice" || - ct.startsWith("audio/") || - ct.includes("silk") || - ct.includes("amr") - ) { - type = "voice"; - } else if (ct.startsWith("video/")) { - type = "video"; - } else if (ct.startsWith("application/") || ct.startsWith("text/")) { - type = "file"; - } - return { - type, - filename: att.filename, - contentType: att.content_type, - localPath: localPaths?.[idx] ?? undefined, - }; - }); -} diff --git a/package.json b/package.json index 930ef9745c2..033a8fa4aba 100644 --- a/package.json +++ b/package.json @@ -1532,6 +1532,7 @@ "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.49", + "@tencent-connect/qqbot-connector": "^1.1.0", "ajv": "^8.18.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aeac6f62d89..e316acc2d8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: '@sinclair/typebox': specifier: 0.34.49 version: 0.34.49 + '@tencent-connect/qqbot-connector': + specifier: ^1.1.0 + version: 1.1.0 ajv: specifier: ^8.18.0 version: 8.18.0 @@ -1043,6 +1046,9 @@ importers: extensions/qqbot: dependencies: + '@tencent-connect/qqbot-connector': + specifier: ^1.1.0 + version: 1.1.0 mpg123-decoder: specifier: ^1.0.3 version: 1.0.3 @@ -4091,6 +4097,10 @@ packages: '@telegraf/types@7.1.0': resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==} + '@tencent-connect/qqbot-connector@1.1.0': + resolution: {integrity: sha512-3nQ2mdyzPRKpBHjd3QiKZDwNzw1F7fBN+rSq8Xms2gg+JWZR4SY2Zdf+doqTyXdyVjG4Y0QM7IA4U42zT9xxzw==} + engines: {node: '>=18.0.0'} + '@thi.ng/bitstream@2.4.46': resolution: {integrity: sha512-p2cZshqkY/YX8EtNZEw29wbNF2vAJfi6A+yTkLUzERMpYIIdQz6bIt8rSJFyPqZcB5cI+tauXWwBXFbbsXuqKg==} engines: {node: '>=18'} @@ -11062,6 +11072,10 @@ snapshots: '@telegraf/types@7.1.0': {} + '@tencent-connect/qqbot-connector@1.1.0': + dependencies: + qrcode-terminal: 0.12.0 + '@thi.ng/bitstream@2.4.46': dependencies: '@thi.ng/errors': 2.6.8 diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index c8a7dbf6327..5c9b8dcec8b 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -54,11 +54,10 @@ const allowedRawFetchCallsites = new Set([ bundledPluginCallsite("qa-lab", "web/src/app.ts", 21), bundledPluginCallsite("qa-lab", "web/src/app.ts", 29), bundledPluginCallsite("qa-lab", "web/src/app.ts", 37), - bundledPluginCallsite("qqbot", "src/api.ts", 102), - bundledPluginCallsite("qqbot", "src/api.ts", 237), - bundledPluginCallsite("qqbot", "src/stt.ts", 81), - bundledPluginCallsite("qqbot", "src/tools/channel.ts", 180), - bundledPluginCallsite("qqbot", "src/utils/audio-convert.ts", 377), + bundledPluginCallsite("qqbot", "src/engine/api/api-client.ts", 108), + bundledPluginCallsite("qqbot", "src/engine/api/token.ts", 211), + bundledPluginCallsite("qqbot", "src/engine/tools/channel-api.ts", 178), + bundledPluginCallsite("qqbot", "src/engine/utils/stt.ts", 87), bundledPluginCallsite("signal", "src/install-signal-cli.ts", 224), bundledPluginCallsite("slack", "src/monitor/media.ts", 99), bundledPluginCallsite("slack", "src/monitor/media.ts", 118), diff --git a/src/canvas-host/a2ui/a2ui.bundle.js b/src/canvas-host/a2ui/a2ui.bundle.js index b9984a59141..4193fb64411 100644 --- a/src/canvas-host/a2ui/a2ui.bundle.js +++ b/src/canvas-host/a2ui/a2ui.bundle.js @@ -1,11 +1,11 @@ -var __defProp$2 = Object.defineProperty; +var __defProp$1 = Object.defineProperty; var __exportAll = (all, no_symbols) => { let target = {}; - for (var name in all) __defProp$2(target, name, { + for (var name in all) __defProp$1(target, name, { get: all[name], enumerable: true }); - if (!no_symbols) __defProp$2(target, Symbol.toStringTag, { value: "Module" }); + if (!no_symbols) __defProp$1(target, Symbol.toStringTag, { value: "Module" }); return target; }; /** @@ -1889,943 +1889,6 @@ var A2uiMessageProcessor = class A2uiMessageProcessor { return value; } }; -var __defProp$1 = Object.defineProperty; -var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, { - enumerable: true, - configurable: true, - writable: true, - value -}) : obj[key] = value; -var __publicField$1 = (obj, key, value) => { - __defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value); - return value; -}; -var __accessCheck$1 = (obj, member, msg) => { - if (!member.has(obj)) throw TypeError("Cannot " + msg); -}; -var __privateIn$1 = (member, obj) => { - if (Object(obj) !== obj) throw TypeError("Cannot use the \"in\" operator on this value"); - return member.has(obj); -}; -var __privateAdd$1 = (obj, member, value) => { - if (member.has(obj)) throw TypeError("Cannot add the same private member more than once"); - member instanceof WeakSet ? member.add(obj) : member.set(obj, value); -}; -var __privateMethod$1 = (obj, member, method) => { - __accessCheck$1(obj, member, "access private method"); - return method; -}; -/** -* @license -* Copyright Google LLC All Rights Reserved. -* -* Use of this source code is governed by an MIT-style license that can be -* found in the LICENSE file at https://angular.io/license -*/ -function defaultEquals$1(a, b) { - return Object.is(a, b); -} -/** -* @license -* Copyright Google LLC All Rights Reserved. -* -* Use of this source code is governed by an MIT-style license that can be -* found in the LICENSE file at https://angular.io/license -*/ -let activeConsumer$1 = null; -let inNotificationPhase$1 = false; -let epoch$1 = 1; -const SIGNAL$1 = /* @__PURE__ */ Symbol("SIGNAL"); -function setActiveConsumer$1(consumer) { - const prev = activeConsumer$1; - activeConsumer$1 = consumer; - return prev; -} -function getActiveConsumer$1() { - return activeConsumer$1; -} -function isInNotificationPhase$1() { - return inNotificationPhase$1; -} -const REACTIVE_NODE$1 = { - version: 0, - lastCleanEpoch: 0, - dirty: false, - producerNode: void 0, - producerLastReadVersion: void 0, - producerIndexOfThis: void 0, - nextProducerIndex: 0, - liveConsumerNode: void 0, - liveConsumerIndexOfThis: void 0, - consumerAllowSignalWrites: false, - consumerIsAlwaysLive: false, - producerMustRecompute: () => false, - producerRecomputeValue: () => {}, - consumerMarkedDirty: () => {}, - consumerOnSignalRead: () => {} -}; -function producerAccessed$1(node) { - if (inNotificationPhase$1) throw new Error(typeof ngDevMode !== "undefined" && ngDevMode ? `Assertion error: signal read during notification phase` : ""); - if (activeConsumer$1 === null) return; - activeConsumer$1.consumerOnSignalRead(node); - const idx = activeConsumer$1.nextProducerIndex++; - assertConsumerNode$1(activeConsumer$1); - if (idx < activeConsumer$1.producerNode.length && activeConsumer$1.producerNode[idx] !== node) { - if (consumerIsLive$1(activeConsumer$1)) { - const staleProducer = activeConsumer$1.producerNode[idx]; - producerRemoveLiveConsumerAtIndex$1(staleProducer, activeConsumer$1.producerIndexOfThis[idx]); - } - } - if (activeConsumer$1.producerNode[idx] !== node) { - activeConsumer$1.producerNode[idx] = node; - activeConsumer$1.producerIndexOfThis[idx] = consumerIsLive$1(activeConsumer$1) ? producerAddLiveConsumer$1(node, activeConsumer$1, idx) : 0; - } - activeConsumer$1.producerLastReadVersion[idx] = node.version; -} -function producerIncrementEpoch$1() { - epoch$1++; -} -function producerUpdateValueVersion$1(node) { - if (!node.dirty && node.lastCleanEpoch === epoch$1) return; - if (!node.producerMustRecompute(node) && !consumerPollProducersForChange$1(node)) { - node.dirty = false; - node.lastCleanEpoch = epoch$1; - return; - } - node.producerRecomputeValue(node); - node.dirty = false; - node.lastCleanEpoch = epoch$1; -} -function producerNotifyConsumers$1(node) { - if (node.liveConsumerNode === void 0) return; - const prev = inNotificationPhase$1; - inNotificationPhase$1 = true; - try { - for (const consumer of node.liveConsumerNode) if (!consumer.dirty) consumerMarkDirty$1(consumer); - } finally { - inNotificationPhase$1 = prev; - } -} -function producerUpdatesAllowed$1() { - return (activeConsumer$1 == null ? void 0 : activeConsumer$1.consumerAllowSignalWrites) !== false; -} -function consumerMarkDirty$1(node) { - var _a; - node.dirty = true; - producerNotifyConsumers$1(node); - (_a = node.consumerMarkedDirty) == null || _a.call(node.wrapper ?? node); -} -function consumerBeforeComputation$1(node) { - node && (node.nextProducerIndex = 0); - return setActiveConsumer$1(node); -} -function consumerAfterComputation$1(node, prevConsumer) { - setActiveConsumer$1(prevConsumer); - if (!node || node.producerNode === void 0 || node.producerIndexOfThis === void 0 || node.producerLastReadVersion === void 0) return; - if (consumerIsLive$1(node)) for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) producerRemoveLiveConsumerAtIndex$1(node.producerNode[i], node.producerIndexOfThis[i]); - while (node.producerNode.length > node.nextProducerIndex) { - node.producerNode.pop(); - node.producerLastReadVersion.pop(); - node.producerIndexOfThis.pop(); - } -} -function consumerPollProducersForChange$1(node) { - assertConsumerNode$1(node); - for (let i = 0; i < node.producerNode.length; i++) { - const producer = node.producerNode[i]; - const seenVersion = node.producerLastReadVersion[i]; - if (seenVersion !== producer.version) return true; - producerUpdateValueVersion$1(producer); - if (seenVersion !== producer.version) return true; - } - return false; -} -function producerAddLiveConsumer$1(node, consumer, indexOfThis) { - var _a; - assertProducerNode$1(node); - assertConsumerNode$1(node); - if (node.liveConsumerNode.length === 0) { - (_a = node.watched) == null || _a.call(node.wrapper); - for (let i = 0; i < node.producerNode.length; i++) node.producerIndexOfThis[i] = producerAddLiveConsumer$1(node.producerNode[i], node, i); - } - node.liveConsumerIndexOfThis.push(indexOfThis); - return node.liveConsumerNode.push(consumer) - 1; -} -function producerRemoveLiveConsumerAtIndex$1(node, idx) { - var _a; - assertProducerNode$1(node); - assertConsumerNode$1(node); - if (typeof ngDevMode !== "undefined" && ngDevMode && idx >= node.liveConsumerNode.length) throw new Error(`Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`); - if (node.liveConsumerNode.length === 1) { - (_a = node.unwatched) == null || _a.call(node.wrapper); - for (let i = 0; i < node.producerNode.length; i++) producerRemoveLiveConsumerAtIndex$1(node.producerNode[i], node.producerIndexOfThis[i]); - } - const lastIdx = node.liveConsumerNode.length - 1; - node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx]; - node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx]; - node.liveConsumerNode.length--; - node.liveConsumerIndexOfThis.length--; - if (idx < node.liveConsumerNode.length) { - const idxProducer = node.liveConsumerIndexOfThis[idx]; - const consumer = node.liveConsumerNode[idx]; - assertConsumerNode$1(consumer); - consumer.producerIndexOfThis[idxProducer] = idx; - } -} -function consumerIsLive$1(node) { - var _a; - return node.consumerIsAlwaysLive || (((_a = node == null ? void 0 : node.liveConsumerNode) == null ? void 0 : _a.length) ?? 0) > 0; -} -function assertConsumerNode$1(node) { - node.producerNode ?? (node.producerNode = []); - node.producerIndexOfThis ?? (node.producerIndexOfThis = []); - node.producerLastReadVersion ?? (node.producerLastReadVersion = []); -} -function assertProducerNode$1(node) { - node.liveConsumerNode ?? (node.liveConsumerNode = []); - node.liveConsumerIndexOfThis ?? (node.liveConsumerIndexOfThis = []); -} -/** -* @license -* Copyright Google LLC All Rights Reserved. -* -* Use of this source code is governed by an MIT-style license that can be -* found in the LICENSE file at https://angular.io/license -*/ -function computedGet$1(node) { - producerUpdateValueVersion$1(node); - producerAccessed$1(node); - if (node.value === ERRORED$1) throw node.error; - return node.value; -} -function createComputed$1(computation) { - const node = Object.create(COMPUTED_NODE$1); - node.computation = computation; - const computed = () => computedGet$1(node); - computed[SIGNAL$1] = node; - return computed; -} -const UNSET$1 = /* @__PURE__ */ Symbol("UNSET"); -const COMPUTING$1 = /* @__PURE__ */ Symbol("COMPUTING"); -const ERRORED$1 = /* @__PURE__ */ Symbol("ERRORED"); -const COMPUTED_NODE$1 = { - ...REACTIVE_NODE$1, - value: UNSET$1, - dirty: true, - error: null, - equal: defaultEquals$1, - producerMustRecompute(node) { - return node.value === UNSET$1 || node.value === COMPUTING$1; - }, - producerRecomputeValue(node) { - if (node.value === COMPUTING$1) throw new Error("Detected cycle in computations."); - const oldValue = node.value; - node.value = COMPUTING$1; - const prevConsumer = consumerBeforeComputation$1(node); - let newValue; - let wasEqual = false; - try { - newValue = node.computation.call(node.wrapper); - wasEqual = oldValue !== UNSET$1 && oldValue !== ERRORED$1 && node.equal.call(node.wrapper, oldValue, newValue); - } catch (err) { - newValue = ERRORED$1; - node.error = err; - } finally { - consumerAfterComputation$1(node, prevConsumer); - } - if (wasEqual) { - node.value = oldValue; - return; - } - node.value = newValue; - node.version++; - } -}; -/** -* @license -* Copyright Google LLC All Rights Reserved. -* -* Use of this source code is governed by an MIT-style license that can be -* found in the LICENSE file at https://angular.io/license -*/ -function defaultThrowError$1() { - throw new Error(); -} -let throwInvalidWriteToSignalErrorFn$1 = defaultThrowError$1; -function throwInvalidWriteToSignalError$1() { - throwInvalidWriteToSignalErrorFn$1(); -} -/** -* @license -* Copyright Google LLC All Rights Reserved. -* -* Use of this source code is governed by an MIT-style license that can be -* found in the LICENSE file at https://angular.io/license -*/ -function createSignal$1(initialValue) { - const node = Object.create(SIGNAL_NODE$1); - node.value = initialValue; - const getter = () => { - producerAccessed$1(node); - return node.value; - }; - getter[SIGNAL$1] = node; - return getter; -} -function signalGetFn$1() { - producerAccessed$1(this); - return this.value; -} -function signalSetFn$1(node, newValue) { - if (!producerUpdatesAllowed$1()) throwInvalidWriteToSignalError$1(); - if (!node.equal.call(node.wrapper, node.value, newValue)) { - node.value = newValue; - signalValueChanged$1(node); - } -} -const SIGNAL_NODE$1 = { - ...REACTIVE_NODE$1, - equal: defaultEquals$1, - value: void 0 -}; -function signalValueChanged$1(node) { - node.version++; - producerIncrementEpoch$1(); - producerNotifyConsumers$1(node); -} -/** -* @license -* Copyright 2024 Bloomberg Finance L.P. -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -*/ -const NODE$1 = Symbol("node"); -var Signal$1; -((Signal2) => { - var _a, _brand, _b, _brand2; - class State { - constructor(initialValue, options = {}) { - __privateAdd$1(this, _brand); - __publicField$1(this, _a); - const node = createSignal$1(initialValue)[SIGNAL$1]; - this[NODE$1] = node; - node.wrapper = this; - if (options) { - const equals = options.equals; - if (equals) node.equal = equals; - node.watched = options[Signal2.subtle.watched]; - node.unwatched = options[Signal2.subtle.unwatched]; - } - } - get() { - if (!(0, Signal2.isState)(this)) throw new TypeError("Wrong receiver type for Signal.State.prototype.get"); - return signalGetFn$1.call(this[NODE$1]); - } - set(newValue) { - if (!(0, Signal2.isState)(this)) throw new TypeError("Wrong receiver type for Signal.State.prototype.set"); - if (isInNotificationPhase$1()) throw new Error("Writes to signals not permitted during Watcher callback"); - const ref = this[NODE$1]; - signalSetFn$1(ref, newValue); - } - } - _a = NODE$1; - _brand = /* @__PURE__ */ new WeakSet(); - Signal2.isState = (s) => typeof s === "object" && __privateIn$1(_brand, s); - Signal2.State = State; - class Computed { - constructor(computation, options) { - __privateAdd$1(this, _brand2); - __publicField$1(this, _b); - const node = createComputed$1(computation)[SIGNAL$1]; - node.consumerAllowSignalWrites = true; - this[NODE$1] = node; - node.wrapper = this; - if (options) { - const equals = options.equals; - if (equals) node.equal = equals; - node.watched = options[Signal2.subtle.watched]; - node.unwatched = options[Signal2.subtle.unwatched]; - } - } - get() { - if (!(0, Signal2.isComputed)(this)) throw new TypeError("Wrong receiver type for Signal.Computed.prototype.get"); - return computedGet$1(this[NODE$1]); - } - } - _b = NODE$1; - _brand2 = /* @__PURE__ */ new WeakSet(); - Signal2.isComputed = (c) => typeof c === "object" && __privateIn$1(_brand2, c); - Signal2.Computed = Computed; - ((subtle2) => { - var _a2, _brand3, _assertSignals, assertSignals_fn; - function untrack(cb) { - let output; - let prevActiveConsumer = null; - try { - prevActiveConsumer = setActiveConsumer$1(null); - output = cb(); - } finally { - setActiveConsumer$1(prevActiveConsumer); - } - return output; - } - subtle2.untrack = untrack; - function introspectSources(sink) { - var _a3; - if (!(0, Signal2.isComputed)(sink) && !(0, Signal2.isWatcher)(sink)) throw new TypeError("Called introspectSources without a Computed or Watcher argument"); - return ((_a3 = sink[NODE$1].producerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? []; - } - subtle2.introspectSources = introspectSources; - function introspectSinks(signal) { - var _a3; - if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) throw new TypeError("Called introspectSinks without a Signal argument"); - return ((_a3 = signal[NODE$1].liveConsumerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? []; - } - subtle2.introspectSinks = introspectSinks; - function hasSinks(signal) { - if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) throw new TypeError("Called hasSinks without a Signal argument"); - const liveConsumerNode = signal[NODE$1].liveConsumerNode; - if (!liveConsumerNode) return false; - return liveConsumerNode.length > 0; - } - subtle2.hasSinks = hasSinks; - function hasSources(signal) { - if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isWatcher)(signal)) throw new TypeError("Called hasSources without a Computed or Watcher argument"); - const producerNode = signal[NODE$1].producerNode; - if (!producerNode) return false; - return producerNode.length > 0; - } - subtle2.hasSources = hasSources; - class Watcher { - constructor(notify) { - __privateAdd$1(this, _brand3); - __privateAdd$1(this, _assertSignals); - __publicField$1(this, _a2); - let node = Object.create(REACTIVE_NODE$1); - node.wrapper = this; - node.consumerMarkedDirty = notify; - node.consumerIsAlwaysLive = true; - node.consumerAllowSignalWrites = false; - node.producerNode = []; - this[NODE$1] = node; - } - watch(...signals) { - if (!(0, Signal2.isWatcher)(this)) throw new TypeError("Called unwatch without Watcher receiver"); - __privateMethod$1(this, _assertSignals, assertSignals_fn).call(this, signals); - const node = this[NODE$1]; - node.dirty = false; - const prev = setActiveConsumer$1(node); - for (const signal of signals) producerAccessed$1(signal[NODE$1]); - setActiveConsumer$1(prev); - } - unwatch(...signals) { - if (!(0, Signal2.isWatcher)(this)) throw new TypeError("Called unwatch without Watcher receiver"); - __privateMethod$1(this, _assertSignals, assertSignals_fn).call(this, signals); - const node = this[NODE$1]; - assertConsumerNode$1(node); - for (let i = node.producerNode.length - 1; i >= 0; i--) if (signals.includes(node.producerNode[i].wrapper)) { - producerRemoveLiveConsumerAtIndex$1(node.producerNode[i], node.producerIndexOfThis[i]); - const lastIdx = node.producerNode.length - 1; - node.producerNode[i] = node.producerNode[lastIdx]; - node.producerIndexOfThis[i] = node.producerIndexOfThis[lastIdx]; - node.producerNode.length--; - node.producerIndexOfThis.length--; - node.nextProducerIndex--; - if (i < node.producerNode.length) { - const idxConsumer = node.producerIndexOfThis[i]; - const producer = node.producerNode[i]; - assertProducerNode$1(producer); - producer.liveConsumerIndexOfThis[idxConsumer] = i; - } - } - } - getPending() { - if (!(0, Signal2.isWatcher)(this)) throw new TypeError("Called getPending without Watcher receiver"); - return this[NODE$1].producerNode.filter((n) => n.dirty).map((n) => n.wrapper); - } - } - _a2 = NODE$1; - _brand3 = /* @__PURE__ */ new WeakSet(); - _assertSignals = /* @__PURE__ */ new WeakSet(); - assertSignals_fn = function(signals) { - for (const signal of signals) if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) throw new TypeError("Called watch/unwatch without a Computed or State argument"); - }; - Signal2.isWatcher = (w) => __privateIn$1(_brand3, w); - subtle2.Watcher = Watcher; - function currentComputed() { - var _a3; - return (_a3 = getActiveConsumer$1()) == null ? void 0 : _a3.wrapper; - } - subtle2.currentComputed = currentComputed; - subtle2.watched = Symbol("watched"); - subtle2.unwatched = Symbol("unwatched"); - })(Signal2.subtle || (Signal2.subtle = {})); -})(Signal$1 || (Signal$1 = {})); -/** -* equality check here is always false so that we can dirty the storage -* via setting to _anything_ -* -* -* This is for a pattern where we don't *directly* use signals to back the values used in collections -* so that instanceof checks and getters and other native features "just work" without having -* to do nested proxying. -* -* (though, see deep.ts for nested / deep behavior) -*/ -const createStorage = (initial = null) => new Signal$1.State(initial, { equals: () => false }); -const ARRAY_GETTER_METHODS = new Set([ - Symbol.iterator, - "concat", - "entries", - "every", - "filter", - "find", - "findIndex", - "flat", - "flatMap", - "forEach", - "includes", - "indexOf", - "join", - "keys", - "lastIndexOf", - "map", - "reduce", - "reduceRight", - "slice", - "some", - "values" -]); -const ARRAY_WRITE_THEN_READ_METHODS = new Set([ - "fill", - "push", - "unshift" -]); -function convertToInt(prop) { - if (typeof prop === "symbol") return null; - const num = Number(prop); - if (isNaN(num)) return null; - return num % 1 === 0 ? num : null; -} -var SignalArray = class SignalArray { - /** - * Creates an array from an iterable object. - * @param iterable An iterable object to convert to an array. - */ - /** - * Creates an array from an iterable object. - * @param iterable An iterable object to convert to an array. - * @param mapfn A mapping function to call on every element of the array. - * @param thisArg Value of 'this' used to invoke the mapfn. - */ - static from(iterable, mapfn, thisArg) { - return mapfn ? new SignalArray(Array.from(iterable, mapfn, thisArg)) : new SignalArray(Array.from(iterable)); - } - static of(...arr) { - return new SignalArray(arr); - } - constructor(arr = []) { - let clone = arr.slice(); - let self = this; - let boundFns = /* @__PURE__ */ new Map(); - /** - Flag to track whether we have *just* intercepted a call to `.push()` or - `.unshift()`, since in those cases (and only those cases!) the `Array` - itself checks `.length` to return from the function call. - */ - let nativelyAccessingLengthFromPushOrUnshift = false; - return new Proxy(clone, { - get(target, prop) { - let index = convertToInt(prop); - if (index !== null) { - self.#readStorageFor(index); - self.#collection.get(); - return target[index]; - } - if (prop === "length") { - if (nativelyAccessingLengthFromPushOrUnshift) nativelyAccessingLengthFromPushOrUnshift = false; - else self.#collection.get(); - return target[prop]; - } - if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) nativelyAccessingLengthFromPushOrUnshift = true; - if (ARRAY_GETTER_METHODS.has(prop)) { - let fn = boundFns.get(prop); - if (fn === void 0) { - fn = (...args) => { - self.#collection.get(); - return target[prop](...args); - }; - boundFns.set(prop, fn); - } - return fn; - } - return target[prop]; - }, - set(target, prop, value) { - target[prop] = value; - let index = convertToInt(prop); - if (index !== null) { - self.#dirtyStorageFor(index); - self.#collection.set(null); - } else if (prop === "length") self.#collection.set(null); - return true; - }, - getPrototypeOf() { - return SignalArray.prototype; - } - }); - } - #collection = createStorage(); - #storages = /* @__PURE__ */ new Map(); - #readStorageFor(index) { - let storage = this.#storages.get(index); - if (storage === void 0) { - storage = createStorage(); - this.#storages.set(index, storage); - } - storage.get(); - } - #dirtyStorageFor(index) { - const storage = this.#storages.get(index); - if (storage) storage.set(null); - } -}; -Object.setPrototypeOf(SignalArray.prototype, Array.prototype); -var SignalMap = class { - collection = createStorage(); - storages = /* @__PURE__ */ new Map(); - vals; - readStorageFor(key) { - const { storages } = this; - let storage = storages.get(key); - if (storage === void 0) { - storage = createStorage(); - storages.set(key, storage); - } - storage.get(); - } - dirtyStorageFor(key) { - const storage = this.storages.get(key); - if (storage) storage.set(null); - } - constructor(existing) { - this.vals = existing ? new Map(existing) : /* @__PURE__ */ new Map(); - } - get(key) { - this.readStorageFor(key); - return this.vals.get(key); - } - has(key) { - this.readStorageFor(key); - return this.vals.has(key); - } - entries() { - this.collection.get(); - return this.vals.entries(); - } - keys() { - this.collection.get(); - return this.vals.keys(); - } - values() { - this.collection.get(); - return this.vals.values(); - } - forEach(fn) { - this.collection.get(); - this.vals.forEach(fn); - } - get size() { - this.collection.get(); - return this.vals.size; - } - [Symbol.iterator]() { - this.collection.get(); - return this.vals[Symbol.iterator](); - } - get [Symbol.toStringTag]() { - return this.vals[Symbol.toStringTag]; - } - set(key, value) { - this.dirtyStorageFor(key); - this.collection.set(null); - this.vals.set(key, value); - return this; - } - delete(key) { - this.dirtyStorageFor(key); - this.collection.set(null); - return this.vals.delete(key); - } - clear() { - this.storages.forEach((s) => s.set(null)); - this.collection.set(null); - this.vals.clear(); - } -}; -Object.setPrototypeOf(SignalMap.prototype, Map.prototype); -/** -* Create a reactive Object, backed by Signals, using a Proxy. -* This allows dynamic creation and deletion of signals using the object primitive -* APIs that most folks are familiar with -- the only difference is instantiation. -* ```js -* const obj = new SignalObject({ foo: 123 }); -* -* obj.foo // 123 -* obj.foo = 456 -* obj.foo // 456 -* obj.bar = 2 -* obj.bar // 2 -* ``` -*/ -const SignalObject = class SignalObjectImpl { - static fromEntries(entries) { - return new SignalObjectImpl(Object.fromEntries(entries)); - } - #storages = /* @__PURE__ */ new Map(); - #collection = createStorage(); - constructor(obj = {}) { - let proto = Object.getPrototypeOf(obj); - let descs = Object.getOwnPropertyDescriptors(obj); - let clone = Object.create(proto); - for (let prop in descs) Object.defineProperty(clone, prop, descs[prop]); - let self = this; - return new Proxy(clone, { - get(target, prop, receiver) { - self.#readStorageFor(prop); - return Reflect.get(target, prop, receiver); - }, - has(target, prop) { - self.#readStorageFor(prop); - return prop in target; - }, - ownKeys(target) { - self.#collection.get(); - return Reflect.ownKeys(target); - }, - set(target, prop, value, receiver) { - let result = Reflect.set(target, prop, value, receiver); - self.#dirtyStorageFor(prop); - self.#dirtyCollection(); - return result; - }, - deleteProperty(target, prop) { - if (prop in target) { - delete target[prop]; - self.#dirtyStorageFor(prop); - self.#dirtyCollection(); - } - return true; - }, - getPrototypeOf() { - return SignalObjectImpl.prototype; - } - }); - } - #readStorageFor(key) { - let storage = this.#storages.get(key); - if (storage === void 0) { - storage = createStorage(); - this.#storages.set(key, storage); - } - storage.get(); - } - #dirtyStorageFor(key) { - const storage = this.#storages.get(key); - if (storage) storage.set(null); - } - #dirtyCollection() { - this.#collection.set(null); - } -}; -var SignalSet = class { - collection = createStorage(); - storages = /* @__PURE__ */ new Map(); - vals; - storageFor(key) { - const storages = this.storages; - let storage = storages.get(key); - if (storage === void 0) { - storage = createStorage(); - storages.set(key, storage); - } - return storage; - } - dirtyStorageFor(key) { - const storage = this.storages.get(key); - if (storage) storage.set(null); - } - constructor(existing) { - this.vals = new Set(existing); - } - has(value) { - this.storageFor(value).get(); - return this.vals.has(value); - } - entries() { - this.collection.get(); - return this.vals.entries(); - } - keys() { - this.collection.get(); - return this.vals.keys(); - } - values() { - this.collection.get(); - return this.vals.values(); - } - forEach(fn) { - this.collection.get(); - this.vals.forEach(fn); - } - get size() { - this.collection.get(); - return this.vals.size; - } - [Symbol.iterator]() { - this.collection.get(); - return this.vals[Symbol.iterator](); - } - get [Symbol.toStringTag]() { - return this.vals[Symbol.toStringTag]; - } - add(value) { - this.dirtyStorageFor(value); - this.collection.set(null); - this.vals.add(value); - return this; - } - delete(value) { - this.dirtyStorageFor(value); - this.collection.set(null); - return this.vals.delete(value); - } - clear() { - this.storages.forEach((s) => s.set(null)); - this.collection.set(null); - this.vals.clear(); - } -}; -Object.setPrototypeOf(SignalSet.prototype, Set.prototype); -function create() { - return new A2uiMessageProcessor({ - arrayCtor: SignalArray, - mapCtor: SignalMap, - objCtor: SignalObject, - setCtor: SignalSet - }); -} -const Data = { - createSignalA2uiMessageProcessor: create, - A2uiMessageProcessor, - Guards: guards_exports -}; -/** -* @license -* Copyright 2017 Google LLC -* SPDX-License-Identifier: BSD-3-Clause -*/ -const t$1 = (t) => (e, o) => { - void 0 !== o ? o.addInitializer(() => { - customElements.define(t, e); - }) : customElements.define(t, e); -}; -/** -* @license -* Copyright 2017 Google LLC -* SPDX-License-Identifier: BSD-3-Clause -*/ const o$9 = { - attribute: !0, - type: String, - converter: u$3, - reflect: !1, - hasChanged: f$3 -}, r$7 = (t = o$9, e, r) => { - const { kind: n, metadata: i } = r; - let s = globalThis.litPropertyMetadata.get(i); - if (void 0 === s && globalThis.litPropertyMetadata.set(i, s = /* @__PURE__ */ new Map()), "setter" === n && ((t = Object.create(t)).wrapped = !0), s.set(r.name, t), "accessor" === n) { - const { name: o } = r; - return { - set(r) { - const n = e.get.call(this); - e.set.call(this, r), this.requestUpdate(o, n, t, !0, r); - }, - init(e) { - return void 0 !== e && this.C(o, void 0, t, e), e; - } - }; - } - if ("setter" === n) { - const { name: o } = r; - return function(r) { - const n = this[o]; - e.call(this, r), this.requestUpdate(o, n, t, !0, r); - }; - } - throw Error("Unsupported decorator location: " + n); -}; -function n$6(t) { - return (e, o) => "object" == typeof o ? r$7(t, e, o) : ((t, e, o) => { - const r = e.hasOwnProperty(o); - return e.constructor.createProperty(o, t), r ? Object.getOwnPropertyDescriptor(e, o) : void 0; - })(t, e, o); -} -/** -* @license -* Copyright 2017 Google LLC -* SPDX-License-Identifier: BSD-3-Clause -*/ function r$6(r) { - return n$6({ - ...r, - state: !0, - attribute: !1 - }); -} -/** -* @license -* Copyright 2017 Google LLC -* SPDX-License-Identifier: BSD-3-Clause -*/ -const e$6 = (e, t, c) => (c.configurable = !0, c.enumerable = !0, Reflect.decorate && "object" != typeof t && Object.defineProperty(e, t, c), c); -/** -* @license -* Copyright 2017 Google LLC -* SPDX-License-Identifier: BSD-3-Clause -*/ function e$5(e, r) { - return (n, s, i) => { - const o = (t) => t.renderRoot?.querySelector(e) ?? null; - if (r) { - const { get: e, set: r } = "object" == typeof s ? n : i ?? (() => { - const t = Symbol(); - return { - get() { - return this[t]; - }, - set(e) { - this[t] = e; - } - }; - })(); - return e$6(n, s, { get() { - let t = e.call(this); - return void 0 === t && (t = o(this), (null !== t || this.hasUpdated) && r.call(this, t)), t; - } }); - } - return e$6(n, s, { get() { - return o(this); - } }); - }; -} var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, @@ -3309,6 +2372,461 @@ var Signal; })(Signal2.subtle || (Signal2.subtle = {})); })(Signal || (Signal = {})); /** +* equality check here is always false so that we can dirty the storage +* via setting to _anything_ +* +* +* This is for a pattern where we don't *directly* use signals to back the values used in collections +* so that instanceof checks and getters and other native features "just work" without having +* to do nested proxying. +* +* (though, see deep.ts for nested / deep behavior) +*/ +const createStorage = (initial = null) => new Signal.State(initial, { equals: () => false }); +const ARRAY_GETTER_METHODS = new Set([ + Symbol.iterator, + "concat", + "entries", + "every", + "filter", + "find", + "findIndex", + "flat", + "flatMap", + "forEach", + "includes", + "indexOf", + "join", + "keys", + "lastIndexOf", + "map", + "reduce", + "reduceRight", + "slice", + "some", + "values" +]); +const ARRAY_WRITE_THEN_READ_METHODS = new Set([ + "fill", + "push", + "unshift" +]); +function convertToInt(prop) { + if (typeof prop === "symbol") return null; + const num = Number(prop); + if (isNaN(num)) return null; + return num % 1 === 0 ? num : null; +} +var SignalArray = class SignalArray { + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + */ + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + * @param mapfn A mapping function to call on every element of the array. + * @param thisArg Value of 'this' used to invoke the mapfn. + */ + static from(iterable, mapfn, thisArg) { + return mapfn ? new SignalArray(Array.from(iterable, mapfn, thisArg)) : new SignalArray(Array.from(iterable)); + } + static of(...arr) { + return new SignalArray(arr); + } + constructor(arr = []) { + let clone = arr.slice(); + let self = this; + let boundFns = /* @__PURE__ */ new Map(); + /** + Flag to track whether we have *just* intercepted a call to `.push()` or + `.unshift()`, since in those cases (and only those cases!) the `Array` + itself checks `.length` to return from the function call. + */ + let nativelyAccessingLengthFromPushOrUnshift = false; + return new Proxy(clone, { + get(target, prop) { + let index = convertToInt(prop); + if (index !== null) { + self.#readStorageFor(index); + self.#collection.get(); + return target[index]; + } + if (prop === "length") { + if (nativelyAccessingLengthFromPushOrUnshift) nativelyAccessingLengthFromPushOrUnshift = false; + else self.#collection.get(); + return target[prop]; + } + if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) nativelyAccessingLengthFromPushOrUnshift = true; + if (ARRAY_GETTER_METHODS.has(prop)) { + let fn = boundFns.get(prop); + if (fn === void 0) { + fn = (...args) => { + self.#collection.get(); + return target[prop](...args); + }; + boundFns.set(prop, fn); + } + return fn; + } + return target[prop]; + }, + set(target, prop, value) { + target[prop] = value; + let index = convertToInt(prop); + if (index !== null) { + self.#dirtyStorageFor(index); + self.#collection.set(null); + } else if (prop === "length") self.#collection.set(null); + return true; + }, + getPrototypeOf() { + return SignalArray.prototype; + } + }); + } + #collection = createStorage(); + #storages = /* @__PURE__ */ new Map(); + #readStorageFor(index) { + let storage = this.#storages.get(index); + if (storage === void 0) { + storage = createStorage(); + this.#storages.set(index, storage); + } + storage.get(); + } + #dirtyStorageFor(index) { + const storage = this.#storages.get(index); + if (storage) storage.set(null); + } +}; +Object.setPrototypeOf(SignalArray.prototype, Array.prototype); +var SignalMap = class { + collection = createStorage(); + storages = /* @__PURE__ */ new Map(); + vals; + readStorageFor(key) { + const { storages } = this; + let storage = storages.get(key); + if (storage === void 0) { + storage = createStorage(); + storages.set(key, storage); + } + storage.get(); + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) storage.set(null); + } + constructor(existing) { + this.vals = existing ? new Map(existing) : /* @__PURE__ */ new Map(); + } + get(key) { + this.readStorageFor(key); + return this.vals.get(key); + } + has(key) { + this.readStorageFor(key); + return this.vals.has(key); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + set(key, value) { + this.dirtyStorageFor(key); + this.collection.set(null); + this.vals.set(key, value); + return this; + } + delete(key) { + this.dirtyStorageFor(key); + this.collection.set(null); + return this.vals.delete(key); + } + clear() { + this.storages.forEach((s) => s.set(null)); + this.collection.set(null); + this.vals.clear(); + } +}; +Object.setPrototypeOf(SignalMap.prototype, Map.prototype); +/** +* Create a reactive Object, backed by Signals, using a Proxy. +* This allows dynamic creation and deletion of signals using the object primitive +* APIs that most folks are familiar with -- the only difference is instantiation. +* ```js +* const obj = new SignalObject({ foo: 123 }); +* +* obj.foo // 123 +* obj.foo = 456 +* obj.foo // 456 +* obj.bar = 2 +* obj.bar // 2 +* ``` +*/ +const SignalObject = class SignalObjectImpl { + static fromEntries(entries) { + return new SignalObjectImpl(Object.fromEntries(entries)); + } + #storages = /* @__PURE__ */ new Map(); + #collection = createStorage(); + constructor(obj = {}) { + let proto = Object.getPrototypeOf(obj); + let descs = Object.getOwnPropertyDescriptors(obj); + let clone = Object.create(proto); + for (let prop in descs) Object.defineProperty(clone, prop, descs[prop]); + let self = this; + return new Proxy(clone, { + get(target, prop, receiver) { + self.#readStorageFor(prop); + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + self.#readStorageFor(prop); + return prop in target; + }, + ownKeys(target) { + self.#collection.get(); + return Reflect.ownKeys(target); + }, + set(target, prop, value, receiver) { + let result = Reflect.set(target, prop, value, receiver); + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + return result; + }, + deleteProperty(target, prop) { + if (prop in target) { + delete target[prop]; + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + } + return true; + }, + getPrototypeOf() { + return SignalObjectImpl.prototype; + } + }); + } + #readStorageFor(key) { + let storage = this.#storages.get(key); + if (storage === void 0) { + storage = createStorage(); + this.#storages.set(key, storage); + } + storage.get(); + } + #dirtyStorageFor(key) { + const storage = this.#storages.get(key); + if (storage) storage.set(null); + } + #dirtyCollection() { + this.#collection.set(null); + } +}; +var SignalSet = class { + collection = createStorage(); + storages = /* @__PURE__ */ new Map(); + vals; + storageFor(key) { + const storages = this.storages; + let storage = storages.get(key); + if (storage === void 0) { + storage = createStorage(); + storages.set(key, storage); + } + return storage; + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) storage.set(null); + } + constructor(existing) { + this.vals = new Set(existing); + } + has(value) { + this.storageFor(value).get(); + return this.vals.has(value); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + add(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + this.vals.add(value); + return this; + } + delete(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + return this.vals.delete(value); + } + clear() { + this.storages.forEach((s) => s.set(null)); + this.collection.set(null); + this.vals.clear(); + } +}; +Object.setPrototypeOf(SignalSet.prototype, Set.prototype); +function create() { + return new A2uiMessageProcessor({ + arrayCtor: SignalArray, + mapCtor: SignalMap, + objCtor: SignalObject, + setCtor: SignalSet + }); +} +const Data = { + createSignalA2uiMessageProcessor: create, + A2uiMessageProcessor, + Guards: guards_exports +}; +/** +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ +const t$1 = (t) => (e, o) => { + void 0 !== o ? o.addInitializer(() => { + customElements.define(t, e); + }) : customElements.define(t, e); +}; +/** +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const o$9 = { + attribute: !0, + type: String, + converter: u$3, + reflect: !1, + hasChanged: f$3 +}, r$7 = (t = o$9, e, r) => { + const { kind: n, metadata: i } = r; + let s = globalThis.litPropertyMetadata.get(i); + if (void 0 === s && globalThis.litPropertyMetadata.set(i, s = /* @__PURE__ */ new Map()), "setter" === n && ((t = Object.create(t)).wrapped = !0), s.set(r.name, t), "accessor" === n) { + const { name: o } = r; + return { + set(r) { + const n = e.get.call(this); + e.set.call(this, r), this.requestUpdate(o, n, t, !0, r); + }, + init(e) { + return void 0 !== e && this.C(o, void 0, t, e), e; + } + }; + } + if ("setter" === n) { + const { name: o } = r; + return function(r) { + const n = this[o]; + e.call(this, r), this.requestUpdate(o, n, t, !0, r); + }; + } + throw Error("Unsupported decorator location: " + n); +}; +function n$6(t) { + return (e, o) => "object" == typeof o ? r$7(t, e, o) : ((t, e, o) => { + const r = e.hasOwnProperty(o); + return e.constructor.createProperty(o, t), r ? Object.getOwnPropertyDescriptor(e, o) : void 0; + })(t, e, o); +} +/** +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function r$6(r) { + return n$6({ + ...r, + state: !0, + attribute: !1 + }); +} +/** +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ +const e$6 = (e, t, c) => (c.configurable = !0, c.enumerable = !0, Reflect.decorate && "object" != typeof t && Object.defineProperty(e, t, c), c); +/** +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function e$5(e, r) { + return (n, s, i) => { + const o = (t) => t.renderRoot?.querySelector(e) ?? null; + if (r) { + const { get: e, set: r } = "object" == typeof s ? n : i ?? (() => { + const t = Symbol(); + return { + get() { + return this[t]; + }, + set(e) { + this[t] = e; + } + }; + })(); + return e$6(n, s, { get() { + let t = e.call(this); + return void 0 === t && (t = o(this), (null !== t || this.hasUpdated) && r.call(this, t)), t; + } }); + } + return e$6(n, s, { get() { + return o(this); + } }); + }; +} +/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: BSD-3-Clause @@ -3498,7 +3016,7 @@ function* o$3(o, f) { } } let pending = false; -let watcher = new Signal$1.subtle.Watcher(() => { +let watcher = new Signal.subtle.Watcher(() => { if (!pending) { pending = true; queueMicrotask(() => { @@ -3516,7 +3034,7 @@ function flushPending() { * This will produce a memory leak. */ function effect(cb) { - let c = new Signal$1.Computed(() => cb()); + let c = new Signal.Computed(() => cb()); watcher.watch(c); c.get(); return () => {