diff --git a/.github/labeler.yml b/.github/labeler.yml index 946413e75a2..733e8579afd 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -59,6 +59,11 @@ - any-glob-to-any-file: - "extensions/nostr/**" - "docs/channels/nostr.md" +"channel: qqbot": + - changed-files: + - any-glob-to-any-file: + - "extensions/qqbot/**" + - "docs/channels/qqbot.md" "channel: signal": - changed-files: - any-glob-to-any-file: diff --git a/CHANGELOG.md b/CHANGELOG.md index 35efd48db4f..58ecb9b2abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -430,7 +430,7 @@ Docs: https://docs.openclaw.ai - Security/session policy: require sender ownership for `/send` policy changes so command-authorized non-owners cannot rewrite owner-only session delivery policy. - Security/bash stop: route `/bash stop` through the hardened process-tree killer so invalid or attacker-influenced SIGKILL targets cannot escape the intended bash-session scope. - Security/installer: hide staged project `.npmrc` files during skill and package installs so npm registry and git settings inside the stage directory cannot hijack trusted installs. -- Agents/tool-call repair: recover malformed Kimi/OpenRouter tool-call argument streams when provider preambles appear before JSON payloads, and fail closed on non-tool leading text so fragment strings do not leak into filesystem path arguments during sub-agent runs. (#56560) Thanks @Originalwhite. +- Channels/QQ Bot: add QQ Bot as a bundled first-party channel plugin for the official QQ Bot API, including multi-account setup, SecretRef-aware credentials, QQ-specific slash commands, media send/receive support, and bundled-channel integration fixes for config schema, version/help surfaces, and local media delivery. ## 2026.3.23 diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index f8aa5cf2481..dcc09128d92 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -1689,7 +1689,7 @@ "tags": [ "automation" ], - "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.", + "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, qqbot, synology-chat, tlon, twitch, zalo, zalouser.", "hasChildren": false }, { @@ -2228,74 +2228,6 @@ "help": "Selects the embedding backend used to build/query memory vectors: \"openai\", \"gemini\", \"voyage\", \"mistral\", \"ollama\", or \"local\". Keep your most reliable provider here and configure fallback for resilience.", "hasChildren": false }, - { - "path": "agents.defaults.memorySearch.qmd", - "kind": "core", - "type": "object", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [ - "advanced" - ], - "label": "Memory Search QMD Collections", - "help": "Use this when one agent should query another agent's transcript collections; QMD-specific extra collections let you opt into cross-agent memory search without flattening everything into one shared namespace.", - "hasChildren": true - }, - { - "path": "agents.defaults.memorySearch.qmd.extraCollections", - "kind": "core", - "type": "array", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [ - "advanced" - ], - "label": "QMD Extra Collections", - "help": "Use this when you need directional transcript search across agents; add collections here to scope QMD recalls without creating a shared global transcript namespace.", - "hasChildren": true - }, - { - "path": "agents.defaults.memorySearch.qmd.extraCollections.*", - "kind": "core", - "type": "object", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": true - }, - { - "path": "agents.defaults.memorySearch.qmd.extraCollections.*.name", - "kind": "core", - "type": "string", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, - { - "path": "agents.defaults.memorySearch.qmd.extraCollections.*.path", - "kind": "core", - "type": "string", - "required": true, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, - { - "path": "agents.defaults.memorySearch.qmd.extraCollections.*.pattern", - "kind": "core", - "type": "string", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "agents.defaults.memorySearch.query", "kind": "core", @@ -4464,7 +4396,7 @@ "tags": [ "automation" ], - "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.", + "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, qqbot, synology-chat, tlon, twitch, zalo, zalouser.", "hasChildren": false }, { @@ -4821,66 +4753,6 @@ "tags": [], "hasChildren": false }, - { - "path": "agents.list.*.memorySearch.qmd", - "kind": "core", - "type": "object", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": true - }, - { - "path": "agents.list.*.memorySearch.qmd.extraCollections", - "kind": "core", - "type": "array", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": true - }, - { - "path": "agents.list.*.memorySearch.qmd.extraCollections.*", - "kind": "core", - "type": "object", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": true - }, - { - "path": "agents.list.*.memorySearch.qmd.extraCollections.*.name", - "kind": "core", - "type": "string", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, - { - "path": "agents.list.*.memorySearch.qmd.extraCollections.*.path", - "kind": "core", - "type": "string", - "required": true, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, - { - "path": "agents.list.*.memorySearch.qmd.extraCollections.*.pattern", - "kind": "core", - "type": "string", - "required": false, - "deprecated": false, - "sensitive": false, - "tags": [], - "hasChildren": false - }, { "path": "agents.list.*.memorySearch.query", "kind": "core", @@ -26681,6 +26553,553 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.qqbot", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "channels", + "network" + ], + "label": "QQ Bot", + "help": "connect to QQ via official QQ Bot API with group chat and direct message support.", + "hasChildren": true + }, + { + "path": "channels.qqbot.accounts", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.accounts.*", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.accounts.*.allowFrom", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.accounts.*.allowFrom.*", + "kind": "channel", + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.appId", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.audioFormatPolicy", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.accounts.*.audioFormatPolicy.sttDirectFormats", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.accounts.*.audioFormatPolicy.sttDirectFormats.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.audioFormatPolicy.transcodeEnabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.audioFormatPolicy.uploadDirectFormats", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.accounts.*.audioFormatPolicy.uploadDirectFormats.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.clientSecret", + "kind": "channel", + "type": [ + "object", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": true, + "tags": [ + "auth", + "channels", + "network", + "security" + ], + "hasChildren": true + }, + { + "path": "channels.qqbot.accounts.*.clientSecret.id", + "kind": "channel", + "type": "string", + "required": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.clientSecret.provider", + "kind": "channel", + "type": "string", + "required": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.clientSecret.source", + "kind": "channel", + "type": "string", + "required": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.clientSecretFile", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": true, + "tags": [ + "auth", + "channels", + "network", + "security", + "storage" + ], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.markdownSupport", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.name", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.systemPrompt", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.upgradeMode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "doc", + "hot-reload" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.upgradeUrl", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.urlDirectUpload", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.accounts.*.voiceDirectUploadFormats", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.accounts.*.voiceDirectUploadFormats.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.allowFrom", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.allowFrom.*", + "kind": "channel", + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.appId", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.audioFormatPolicy", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.audioFormatPolicy.sttDirectFormats", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.audioFormatPolicy.sttDirectFormats.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.audioFormatPolicy.transcodeEnabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.audioFormatPolicy.uploadDirectFormats", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.audioFormatPolicy.uploadDirectFormats.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.clientSecret", + "kind": "channel", + "type": [ + "object", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": true, + "tags": [ + "auth", + "channels", + "network", + "security" + ], + "hasChildren": true + }, + { + "path": "channels.qqbot.clientSecret.id", + "kind": "channel", + "type": "string", + "required": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.clientSecret.provider", + "kind": "channel", + "type": "string", + "required": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.clientSecret.source", + "kind": "channel", + "type": "string", + "required": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.clientSecretFile", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": true, + "tags": [ + "auth", + "channels", + "network", + "security", + "storage" + ], + "hasChildren": false + }, + { + "path": "channels.qqbot.defaultAccount", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.markdownSupport", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.name", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.systemPrompt", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.upgradeMode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "doc", + "hot-reload" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.upgradeUrl", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.urlDirectUpload", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.qqbot.voiceDirectUploadFormats", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.qqbot.voiceDirectUploadFormats.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.signal", "kind": "channel", @@ -34177,7 +34596,7 @@ "network" ], "label": "Telegram Exec Approval Approvers", - "help": "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.", + "help": "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from channels.telegram.allowFrom and direct-message defaultTo when possible.", "hasChildren": true }, { @@ -54187,6 +54606,305 @@ "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", "hasChildren": false }, + { + "path": "plugins.entries.qqbot", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/qqbot", + "help": "OpenClaw QQ Bot channel plugin (plugin: qqbot)", + "hasChildren": true + }, + { + "path": "plugins.entries.qqbot.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/qqbot Config", + "help": "Plugin-defined config payload for qqbot.", + "hasChildren": true + }, + { + "path": "plugins.entries.qqbot.config.accounts", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "plugins.entries.qqbot.config.accounts.*", + "kind": "plugin", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.allowFrom", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "plugins.entries.qqbot.config.allowFrom.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.appId", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.audioFormatPolicy", + "kind": "plugin", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.clientSecret", + "kind": "plugin", + "required": false, + "deprecated": false, + "sensitive": true, + "tags": [ + "auth", + "security" + ], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.clientSecretFile", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": true, + "tags": [ + "auth", + "security", + "storage" + ], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.markdownSupport", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.name", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.systemPrompt", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.upgradeMode", + "kind": "plugin", + "type": "string", + "required": false, + "enumValues": [ + "doc", + "hot-reload" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.upgradeUrl", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.urlDirectUpload", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.config.voiceDirectUploadFormats", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "plugins.entries.qqbot.config.voiceDirectUploadFormats.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/qqbot", + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.qqbot.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.qqbot.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.qqbot.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.qqbot.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.sglang", "kind": "plugin", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index ce1a91d2632..cb3a1f2b9ac 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5637} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5701} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -144,7 +144,7 @@ {"recordType":"path","path":"agents.defaults.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false} -{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false} +{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, qqbot, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.defaults.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Human Delay Max (ms)","help":"Maximum delay in ms for custom humanDelay (default: 2500).","hasChildren":false} @@ -187,12 +187,6 @@ {"recordType":"path","path":"agents.defaults.memorySearch.multimodal.modalities.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.memorySearch.outputDimensionality","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search Output Dimensionality","help":"Gemini embedding-2 only: chooses the output vector size for memory embeddings. Use 768, 1536, or 3072 (default), and expect a full reindex when you change it because stored vector dimensions must stay consistent.","hasChildren":false} {"recordType":"path","path":"agents.defaults.memorySearch.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search Provider","help":"Selects the embedding backend used to build/query memory vectors: \"openai\", \"gemini\", \"voyage\", \"mistral\", \"ollama\", or \"local\". Keep your most reliable provider here and configure fallback for resilience.","hasChildren":false} -{"recordType":"path","path":"agents.defaults.memorySearch.qmd","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search QMD Collections","help":"Use this when one agent should query another agent's transcript collections; QMD-specific extra collections let you opt into cross-agent memory search without flattening everything into one shared namespace.","hasChildren":true} -{"recordType":"path","path":"agents.defaults.memorySearch.qmd.extraCollections","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"QMD Extra Collections","help":"Use this when you need directional transcript search across agents; add collections here to scope QMD recalls without creating a shared global transcript namespace.","hasChildren":true} -{"recordType":"path","path":"agents.defaults.memorySearch.qmd.extraCollections.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} -{"recordType":"path","path":"agents.defaults.memorySearch.qmd.extraCollections.*.name","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"agents.defaults.memorySearch.qmd.extraCollections.*.path","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"agents.defaults.memorySearch.qmd.extraCollections.*.pattern","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.memorySearch.query","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.defaults.memorySearch.query.hybrid","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.defaults.memorySearch.query.hybrid.candidateMultiplier","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search Hybrid Candidate Multiplier","help":"Expands the candidate pool before reranking (default: 4). Raise this for better recall on noisy corpora, but expect more compute and slightly slower searches.","hasChildren":false} @@ -386,7 +380,7 @@ {"recordType":"path","path":"agents.list.*.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Agent Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false} -{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false} +{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, qqbot, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -422,12 +416,6 @@ {"recordType":"path","path":"agents.list.*.memorySearch.multimodal.modalities.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.memorySearch.outputDimensionality","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.memorySearch.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"agents.list.*.memorySearch.qmd","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} -{"recordType":"path","path":"agents.list.*.memorySearch.qmd.extraCollections","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} -{"recordType":"path","path":"agents.list.*.memorySearch.qmd.extraCollections.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} -{"recordType":"path","path":"agents.list.*.memorySearch.qmd.extraCollections.*.name","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"agents.list.*.memorySearch.qmd.extraCollections.*.path","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"agents.list.*.memorySearch.qmd.extraCollections.*.pattern","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.memorySearch.query","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.memorySearch.query.hybrid","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.memorySearch.query.hybrid.candidateMultiplier","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2391,6 +2379,56 @@ {"recordType":"path","path":"channels.nostr.profile.website","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.nostr.relays","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.nostr.relays.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"QQ Bot","help":"connect to QQ via official QQ Bot API with group chat and direct message support.","hasChildren":true} +{"recordType":"path","path":"channels.qqbot.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.accounts.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.audioFormatPolicy","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.accounts.*.audioFormatPolicy.sttDirectFormats","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.accounts.*.audioFormatPolicy.sttDirectFormats.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.audioFormatPolicy.transcodeEnabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.audioFormatPolicy.uploadDirectFormats","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.accounts.*.audioFormatPolicy.uploadDirectFormats.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.clientSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.accounts.*.clientSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.clientSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.clientSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.clientSecretFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security","storage"],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.markdownSupport","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.upgradeMode","kind":"channel","type":"string","required":false,"enumValues":["doc","hot-reload"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.upgradeUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.urlDirectUpload","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.accounts.*.voiceDirectUploadFormats","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.accounts.*.voiceDirectUploadFormats.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.audioFormatPolicy","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.audioFormatPolicy.sttDirectFormats","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.audioFormatPolicy.sttDirectFormats.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.audioFormatPolicy.transcodeEnabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.audioFormatPolicy.uploadDirectFormats","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.audioFormatPolicy.uploadDirectFormats.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.clientSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security"],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.clientSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.clientSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.clientSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.clientSecretFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","channels","network","security","storage"],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.markdownSupport","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.upgradeMode","kind":"channel","type":"string","required":false,"enumValues":["doc","hot-reload"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.upgradeUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.urlDirectUpload","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.qqbot.voiceDirectUploadFormats","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.qqbot.voiceDirectUploadFormats.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.signal","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Signal","help":"signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").","hasChildren":true} {"recordType":"path","path":"channels.signal.account","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","hasChildren":false} {"recordType":"path","path":"channels.signal.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -3070,7 +3108,7 @@ {"recordType":"path","path":"channels.telegram.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.","hasChildren":true} {"recordType":"path","path":"channels.telegram.execApprovals.agentFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\"main\", \"ops-agent\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.","hasChildren":true} {"recordType":"path","path":"channels.telegram.execApprovals.agentFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.telegram.execApprovals.approvers","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.","hasChildren":true} +{"recordType":"path","path":"channels.telegram.execApprovals.approvers","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from channels.telegram.allowFrom and direct-message defaultTo when possible.","hasChildren":true} {"recordType":"path","path":"channels.telegram.execApprovals.approvers.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals Enabled","help":"Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.","hasChildren":false} {"recordType":"path","path":"channels.telegram.execApprovals.sessionFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","storage"],"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.","hasChildren":true} @@ -4659,6 +4697,32 @@ {"recordType":"path","path":"plugins.entries.qianfan.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} {"recordType":"path","path":"plugins.entries.qianfan.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"plugins.entries.qianfan.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/qqbot","help":"OpenClaw QQ Bot channel plugin (plugin: qqbot)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qqbot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/qqbot Config","help":"Plugin-defined config payload for qqbot.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qqbot.config.accounts","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"plugins.entries.qqbot.config.accounts.*","kind":"plugin","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.allowFrom","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"plugins.entries.qqbot.config.allowFrom.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.appId","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.audioFormatPolicy","kind":"plugin","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.clientSecret","kind":"plugin","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.clientSecretFile","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","storage"],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.markdownSupport","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.name","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.systemPrompt","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.upgradeMode","kind":"plugin","type":"string","required":false,"enumValues":["doc","hot-reload"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.upgradeUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.urlDirectUpload","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.config.voiceDirectUploadFormats","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"plugins.entries.qqbot.config.voiceDirectUploadFormats.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/qqbot","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qqbot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qqbot.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qqbot.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.qqbot.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.sglang","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/sglang-provider","help":"OpenClaw SGLang provider plugin (plugin: sglang)","hasChildren":true} {"recordType":"path","path":"plugins.entries.sglang.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/sglang-provider Config","help":"Plugin-defined config payload for sglang.","hasChildren":false} {"recordType":"path","path":"plugins.entries.sglang.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/sglang-provider","hasChildren":false} diff --git a/docs/channels/index.md b/docs/channels/index.md index d1dd7a2dfb3..4cab5b321ff 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -25,6 +25,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). - [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately). +- [QQ Bot](/channels/qqbot) — QQ Bot API; private chat, group chat, and rich media (plugin, installed separately). - [Signal](/channels/signal) — signal-cli; privacy-focused. - [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately). diff --git a/docs/channels/qqbot.md b/docs/channels/qqbot.md new file mode 100644 index 00000000000..d6384c07f84 --- /dev/null +++ b/docs/channels/qqbot.md @@ -0,0 +1,160 @@ +--- +summary: "QQ Bot channel plugin setup, config, and usage" +read_when: + - You want to connect OpenClaw to QQ + - You need QQ Bot credential setup + - You want QQ Bot group or private chat support +title: QQ Bot +--- + +# QQ Bot (plugin) + +QQ Bot connects to OpenClaw via the official QQ Bot API (WebSocket gateway). The +plugin supports C2C private chat, group @messages, and guild channel messages with +rich media (images, voice, video, files). + +Status: supported via plugin. Direct messages, group chats, guild channels, and +media are supported. Reactions and threads are not supported. + +## Plugin required + +Install the QQ Bot plugin: + +```bash +openclaw plugins install @openclaw/qqbot +``` + +Local checkout (when running from a git repo): + +```bash +openclaw plugins install ./extensions/qqbot +``` + +## Setup + +1. Go to the [QQ Open Platform](https://q.qq.com/) and scan the QR code with your + phone QQ to register / log in. +2. Click **Create Bot** to create a new QQ bot. +3. Find **AppID** and **AppSecret** on the bot's settings page and copy them. + +> AppSecret is not stored in plaintext — if you leave the page without saving it, +> you'll have to regenerate a new one. + +4. Add the channel: + +```bash +openclaw channels add --channel qqbot --token "AppID:AppSecret" +``` + +5. Restart the Gateway. + +## Configure + +Minimal config: + +```json5 +{ + channels: { + qqbot: { + enabled: true, + appId: "YOUR_APP_ID", + clientSecret: "YOUR_APP_SECRET", + }, + }, +} +``` + +### Multi-account setup + +Run multiple QQ bots under a single OpenClaw instance: + +```json5 +{ + channels: { + qqbot: { + enabled: true, + appId: "111111111", + clientSecret: "secret-of-bot-1", + accounts: { + bot2: { + enabled: true, + appId: "222222222", + clientSecret: "secret-of-bot-2", + }, + }, + }, + }, +} +``` + +Each account launches its own WebSocket connection and maintains an independent +token cache (isolated by `appId`). + +Add a second bot via CLI: + +```bash +openclaw channels add --channel qqbot --account bot2 --token "222222222:secret-of-bot-2" +``` + +### Voice (STT / TTS) + +STT and TTS support two-level configuration with priority fallback: + +| Setting | Plugin-specific | Framework fallback | +| ------- | -------------------- | ----------------------------- | +| STT | `channels.qqbot.stt` | `tools.media.audio.models[0]` | +| TTS | `channels.qqbot.tts` | `messages.tts` | + +```json5 +{ + channels: { + qqbot: { + stt: { + provider: "your-provider", + model: "your-stt-model", + }, + tts: { + provider: "your-provider", + model: "your-tts-model", + voice: "your-voice", + }, + }, + }, +} +``` + +Set `enabled: false` on either to disable. + +## Target formats + +| Format | Description | +| -------------------------- | ------------------ | +| `qqbot:c2c:OPENID` | Private chat (C2C) | +| `qqbot:group:GROUP_OPENID` | Group chat | +| `qqbot:channel:CHANNEL_ID` | Guild channel | + +> Each bot has its own set of user OpenIDs. An OpenID received by Bot A **cannot** +> be used to send messages via Bot B. + +## Slash commands + +Built-in commands intercepted before the AI queue: + +| Command | Description | +| -------------- | ------------------------------------ | +| `/bot-ping` | Latency test | +| `/bot-version` | Show the OpenClaw framework version | +| `/bot-help` | List all commands | +| `/bot-upgrade` | Show the QQBot upgrade guide link | +| `/bot-logs` | Export recent gateway logs as a file | + +Append `?` to any command for usage help (for example `/bot-upgrade ?`). + +## Troubleshooting + +- **Bot replies "gone to Mars":** credentials not configured or Gateway not started. +- **No inbound messages:** verify `appId` and `clientSecret` are correct, and the + bot is enabled on the QQ Open Platform. +- **Proactive messages not arriving:** QQ may intercept bot-initiated messages if + the user hasn't interacted recently. +- **Voice not transcribed:** ensure STT is configured and the provider is reachable. diff --git a/docs/docs.json b/docs/docs.json index 446486a43d0..44503345279 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -984,6 +984,7 @@ "channels/msteams", "channels/nextcloud-talk", "channels/nostr", + "channels/qqbot", "channels/signal", "channels/slack", "channels/synology-chat", diff --git a/extensions/qqbot/api.ts b/extensions/qqbot/api.ts new file mode 100644 index 00000000000..291b2d5941b --- /dev/null +++ b/extensions/qqbot/api.ts @@ -0,0 +1,4 @@ +export * from "./src/types.js"; +export * from "./src/config.js"; +export * from "./src/outbound.js"; +export * from "./src/proactive.js"; diff --git a/extensions/qqbot/index.ts b/extensions/qqbot/index.ts new file mode 100644 index 00000000000..a00e9ba052d --- /dev/null +++ b/extensions/qqbot/index.ts @@ -0,0 +1,113 @@ +import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; +import { qqbotPlugin } from "./src/channel.js"; +import { resolveQQBotAccount } from "./src/config.js"; +import { sendDocument, type MediaTargetContext } from "./src/outbound.js"; +import { setQQBotRuntime } from "./src/runtime.js"; +import { getFrameworkCommands } from "./src/slash-commands.js"; +import { registerChannelTool } from "./src/tools/channel.js"; +import { registerRemindTool } from "./src/tools/remind.js"; + +export { qqbotPlugin } from "./src/channel.js"; +export { setQQBotRuntime, getQQBotRuntime } from "./src/runtime.js"; + +export default defineChannelPluginEntry({ + id: "qqbot", + name: "QQ Bot", + description: "QQ Bot channel plugin", + plugin: qqbotPlugin as ChannelPlugin, + setRuntime: 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) => { + // 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"; + + // 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: ctx.accountId ?? "default", + // appId is not available from PluginCommandContext directly; handlers + // that need it should call resolveQQBotAccount(ctx.config, ctx.accountId). + appId: "", + 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 && "filePath" in result) { + try { + const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined); + const mediaCtx: MediaTargetContext = { + targetType, + targetId, + account, + logPrefix: `[qqbot:${account.accountId}]`, + }; + await sendDocument(mediaCtx, result.filePath); + } catch { + // File send failed; the text summary is still returned below. + } + return { text: result.text }; + } + + return { text: "⚠️ 命令返回了意外结果。" }; + }, + }); + } + }, +}); diff --git a/extensions/qqbot/openclaw.plugin.json b/extensions/qqbot/openclaw.plugin.json new file mode 100644 index 00000000000..9e8a8c5b9a1 --- /dev/null +++ b/extensions/qqbot/openclaw.plugin.json @@ -0,0 +1,100 @@ +{ + "id": "qqbot", + "channels": ["qqbot"], + "skills": ["./skills"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "$defs": { + "audioFormatPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "sttDirectFormats": { + "type": "array", + "items": { "type": "string" } + }, + "uploadDirectFormats": { + "type": "array", + "items": { "type": "string" } + }, + "transcodeEnabled": { "type": "boolean" } + } + }, + "secretRef": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "enum": ["env", "file", "exec"] + }, + "provider": { "type": "string" }, + "id": { "type": "string" } + }, + "required": ["source", "provider", "id"] + }, + "secretInput": { + "anyOf": [{ "type": "string", "minLength": 1 }, { "$ref": "#/$defs/secretRef" }] + }, + "account": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "name": { "type": "string" }, + "appId": { "type": "string" }, + "clientSecret": { "$ref": "#/$defs/secretInput" }, + "clientSecretFile": { "type": "string" }, + "allowFrom": { + "type": "array", + "items": { "type": "string" } + }, + "systemPrompt": { "type": "string" }, + "markdownSupport": { "type": "boolean" }, + "voiceDirectUploadFormats": { + "type": "array", + "items": { "type": "string" } + }, + "audioFormatPolicy": { "$ref": "#/$defs/audioFormatPolicy" }, + "urlDirectUpload": { "type": "boolean" }, + "upgradeUrl": { "type": "string" }, + "upgradeMode": { + "type": "string", + "enum": ["doc", "hot-reload"] + } + } + } + }, + "properties": { + "enabled": { "type": "boolean" }, + "name": { "type": "string" }, + "appId": { "type": "string" }, + "clientSecret": { "$ref": "#/$defs/secretInput" }, + "clientSecretFile": { "type": "string" }, + "allowFrom": { + "type": "array", + "items": { "type": "string" } + }, + "systemPrompt": { "type": "string" }, + "markdownSupport": { "type": "boolean" }, + "voiceDirectUploadFormats": { + "type": "array", + "items": { "type": "string" } + }, + "audioFormatPolicy": { "$ref": "#/$defs/audioFormatPolicy" }, + "urlDirectUpload": { "type": "boolean" }, + "upgradeUrl": { "type": "string" }, + "upgradeMode": { + "type": "string", + "enum": ["doc", "hot-reload"] + }, + "accounts": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/account" + } + } + } + } +} diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json new file mode 100644 index 00000000000..72845192885 --- /dev/null +++ b/extensions/qqbot/package.json @@ -0,0 +1,52 @@ +{ + "name": "@openclaw/qqbot", + "version": "2026.3.22", + "private": true, + "description": "OpenClaw QQ Bot channel plugin", + "type": "module", + "dependencies": { + "mpg123-decoder": "^1.0.3", + "silk-wasm": "^3.7.1", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.0", + "openclaw": "workspace:*" + }, + "peerDependencies": { + "openclaw": ">=2026.3.22" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "setupEntry": "./setup-entry.ts", + "channel": { + "id": "qqbot", + "label": "QQ Bot", + "selectionLabel": "QQ Bot (Official API)", + "detailLabel": "QQ Bot", + "docsPath": "/channels/qqbot", + "docsLabel": "qqbot", + "blurb": "connect to QQ via official QQ Bot API with group chat and direct message support.", + "systemImage": "bubble.left.and.bubble.right" + }, + "install": { + "npmSpec": "@openclaw/qqbot", + "localPath": "extensions/qqbot", + "defaultChoice": "npm", + "minHostVersion": ">=2026.3.22" + }, + "bundle": { + "stageRuntimeDependencies": true + }, + "release": { + "publishToNpm": true + } + } +} diff --git a/extensions/qqbot/runtime-api.ts b/extensions/qqbot/runtime-api.ts new file mode 100644 index 00000000000..c864cbfdbff --- /dev/null +++ b/extensions/qqbot/runtime-api.ts @@ -0,0 +1,9 @@ +export type { ChannelPlugin, OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/core"; +export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +export type { + OpenClawPluginService, + OpenClawPluginServiceContext, + PluginLogger, +} from "openclaw/plugin-sdk/core"; +export type { ResolvedQQBotAccount, QQBotAccountConfig } from "./src/types.js"; +export { getQQBotRuntime, setQQBotRuntime } from "./src/runtime.js"; diff --git a/extensions/qqbot/setup-entry.ts b/extensions/qqbot/setup-entry.ts new file mode 100644 index 00000000000..d3ffb490076 --- /dev/null +++ b/extensions/qqbot/setup-entry.ts @@ -0,0 +1,6 @@ +import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; +import { qqbotSetupPlugin } from "./src/channel.setup.js"; + +export { qqbotSetupPlugin } from "./src/channel.setup.js"; + +export default defineSetupPluginEntry(qqbotSetupPlugin); diff --git a/extensions/qqbot/skills/qqbot-channel/SKILL.md b/extensions/qqbot/skills/qqbot-channel/SKILL.md new file mode 100644 index 00000000000..593e2a6f336 --- /dev/null +++ b/extensions/qqbot/skills/qqbot-channel/SKILL.md @@ -0,0 +1,262 @@ +--- +name: qqbot-channel +description: QQ 频道管理技能。查询频道列表、子频道、成员、发帖、公告、日程等操作。使用 qqbot_channel_api 工具代理 QQ 开放平台 HTTP 接口,自动处理 Token 鉴权。当用户需要查看频道、管理子频道、查询成员、发布帖子/公告/日程时使用。 +metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qqbot"] } } } +--- + +# QQ 频道 API 请求指导 + +`qqbot_channel_api` 是一个 QQ 开放平台 HTTP 代理工具,**自动填充鉴权 Token**。你只需要指定 HTTP 方法、API 路径、请求体和查询参数。 + +## 📚 详细参考文档 + +每个接口的完整参数说明、返回值结构和枚举值定义: + +- `references/api_references.md` + +--- + +## 🔧 工具参数 + +| 参数 | 类型 | 必填 | 说明 | +| -------- | ------ | ---- | ---------------------------------------------------------------------------- | +| `method` | string | 是 | HTTP 方法:`GET`, `POST`, `PUT`, `PATCH`, `DELETE` | +| `path` | string | 是 | API 路径(不含域名),如 `/guilds/{guild_id}/channels`,需替换占位符为实际值 | +| `body` | object | 否 | 请求体 JSON(POST/PUT/PATCH 使用) | +| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 | + +> 基础 URL:`https://api.sgroup.qq.com`,鉴权头 `Authorization: QQBot {token}` 由工具自动填充。 + +--- + +## ⭐ 接口速查 + +### 频道(Guild) + +| 操作 | 方法 | 路径 | 参数说明 | +| ----------------- | ----- | ----------------------------------- | ------------------------------------------ | +| 获取频道列表 | `GET` | `/users/@me/guilds` | query: `before`, `after`, `limit`(最大100) | +| 获取频道 API 权限 | `GET` | `/guilds/{guild_id}/api_permission` | — | + +### 子频道(Channel) + +| 操作 | 方法 | 路径 | 参数说明 | +| -------------- | -------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 获取子频道列表 | `GET` | `/guilds/{guild_id}/channels` | — | +| 获取子频道详情 | `GET` | `/channels/{channel_id}` | — | +| 创建子频道 | `POST` | `/guilds/{guild_id}/channels` | body: `name`\*, `type`\*, `position`\*, `sub_type`, `parent_id`, `private_type`, `private_user_ids`, `speak_permission`, `application_id` | +| 修改子频道 | `PATCH` | `/channels/{channel_id}` | body: `name`, `position`, `parent_id`, `private_type`, `speak_permission`(至少一个) | +| 删除子频道 | `DELETE` | `/channels/{channel_id}` | ⚠️ 不可逆 | + +**子频道类型(type)**:`0`=文字, `2`=语音, `4`=分组(position≥2), `10005`=直播, `10006`=应用, `10007`=论坛 + +### 成员(Member) + +| 操作 | 方法 | 路径 | 参数说明 | +| ------------------ | ----- | -------------------------------------------- | --------------------------------------------- | +| 获取成员列表 | `GET` | `/guilds/{guild_id}/members` | query: `after`(首次填0), `limit`(1-400) | +| 获取成员详情 | `GET` | `/guilds/{guild_id}/members/{user_id}` | — | +| 获取身份组成员列表 | `GET` | `/guilds/{guild_id}/roles/{role_id}/members` | query: `start_index`(首次填0), `limit`(1-400) | +| 获取在线成员数 | `GET` | `/channels/{channel_id}/online_nums` | — | + +### 公告(Announces) + +| 操作 | 方法 | 路径 | 参数说明 | +| -------- | -------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| 创建公告 | `POST` | `/guilds/{guild_id}/announces` | body: `message_id`, `channel_id`, `announces_type`(0=成员,1=欢迎), `recommend_channels`(最多3条) | +| 删除公告 | `DELETE` | `/guilds/{guild_id}/announces/{message_id}` | message_id 设 `all` 删除所有 | + +### 论坛(Forum)— 仅私域机器人 + +| 操作 | 方法 | 路径 | 参数说明 | +| ------------ | -------- | ---------------------------------------------------- | ------------------------------------------------------------------------------ | +| 获取帖子列表 | `GET` | `/channels/{channel_id}/threads` | — | +| 获取帖子详情 | `GET` | `/channels/{channel_id}/threads/{thread_id}` | — | +| 发表帖子 | `PUT` | `/channels/{channel_id}/threads` | body: `title`\*, `content`\*, `format`(1=文本,2=HTML,3=Markdown,4=JSON,默认3) | +| 删除帖子 | `DELETE` | `/channels/{channel_id}/threads/{thread_id}` | ⚠️ 不可逆 | +| 发表评论 | `POST` | `/channels/{channel_id}/threads/{thread_id}/comment` | body: `thread_author`\*, `content`\*, `thread_create_time`, `image` | + +### 日程(Schedule) + +| 操作 | 方法 | 路径 | 参数说明 | +| -------- | -------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| 创建日程 | `POST` | `/channels/{channel_id}/schedules` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` | +| 修改日程 | `PATCH` | `/channels/{channel_id}/schedules/{schedule_id}` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` | +| 删除日程 | `DELETE` | `/channels/{channel_id}/schedules/{schedule_id}` | ⚠️ 不可逆 | + +**提醒类型(remind_type)**:`"0"`=不提醒, `"1"`=开始时, `"2"`=5分钟前, `"3"`=15分钟前, `"4"`=30分钟前, `"5"`=60分钟前 + +> `*` 表示必填参数 + +--- + +## 💡 调用示例 + +### 获取频道列表 + +```json +{ + "method": "GET", + "path": "/users/@me/guilds", + "query": { "limit": "100" } +} +``` + +### 获取子频道列表 + +```json +{ + "method": "GET", + "path": "/guilds/123456/channels" +} +``` + +### 创建子频道 + +```json +{ + "method": "POST", + "path": "/guilds/123456/channels", + "body": { + "name": "新频道", + "type": 0, + "position": 1, + "sub_type": 0 + } +} +``` + +### 获取成员列表(分页) + +```json +{ + "method": "GET", + "path": "/guilds/123456/members", + "query": { "after": "0", "limit": "100" } +} +``` + +### 发表论坛帖子 + +```json +{ + "method": "PUT", + "path": "/channels/789012/threads", + "body": { + "title": "公告标题", + "content": "# 标题\n\n公告内容", + "format": 3 + } +} +``` + +### 创建日程 + +```json +{ + "method": "POST", + "path": "/channels/456789/schedules", + "body": { + "schedule": { + "name": "周会", + "start_timestamp": "1770733800000", + "end_timestamp": "1770737400000", + "remind_type": "2" + } + } +} +``` + +### 创建推荐子频道公告 + +```json +{ + "method": "POST", + "path": "/guilds/123456/announces", + "body": { + "announces_type": 0, + "recommend_channels": [{ "channel_id": "789012", "introduce": "欢迎来到攻略频道" }] + } +} +``` + +### 删除所有公告 + +```json +{ + "method": "DELETE", + "path": "/guilds/123456/announces/all" +} +``` + +--- + +## 🔄 常用操作流程 + +### 获取频道和子频道信息 + +``` +1. GET /users/@me/guilds → 获取频道列表,拿到 guild_id +2. GET /guilds/{guild_id}/channels → 获取子频道列表,拿到 channel_id +3. GET /channels/{channel_id} → 获取子频道详情 +``` + +### 论坛发帖 + 评论 + +``` +1. GET /guilds/{guild_id}/channels → 找到论坛子频道(type=10007) +2. PUT /channels/{channel_id}/threads → 发表帖子 +3. GET /channels/{channel_id}/threads → 获取帖子列表 +4. GET /channels/{channel_id}/threads/{thread_id} → 获取帖子详情(含 author_id) +5. POST /channels/{channel_id}/threads/{thread_id}/comment → 发表评论 +``` + +### 成员管理 + +``` +1. GET /users/@me/guilds → 获取 guild_id +2. GET /guilds/{guild_id}/members?after=0&limit=100 → 获取成员列表 + 翻页:用上次最后一个 user.id 作为 after,直到返回空数组 +3. GET /guilds/{guild_id}/members/{user_id} → 获取指定成员详情 +``` + +### 展示成员头像 + +成员详情返回的 `user.avatar` 是头像 URL,**必须使用 Markdown 图片语法展示**,让用户直接看到头像图片,而非纯文本链接: + +``` +成员信息: +· 昵称:{nick} +· 头像: +![头像]({user.avatar}) +``` + +> **禁止**将头像 URL 作为纯文本或超链接展示(如 `查看头像`),必须用 `![描述](URL)` 语法内联显示。频道的 `icon` 字段同理。 + +--- + +## 🚨 错误码处理 + +| 错误码 | 说明 | 解决方案 | +| ---------- | ---------------- | ------------------------------------------------------------------------------------- | +| **401** | Token 鉴权失败 | 检查 AppID 和 ClientSecret 配置 | +| **11241** | 频道 API 无权限 | 前往 QQ 开放平台申请权限,或调用 `GET /guilds/{guild_id}/api_permission` 查看可用权限 | +| **11242** | 仅私域机器人可用 | 需在 QQ 开放平台将机器人切换为私域模式 | +| **11243** | 需要管理频道权限 | 确保机器人拥有管理权限 | +| **11281** | 日程频率限制 | 单管理员/天限 10 次,单频道/天限 100 次 | +| **304023** | 推荐子频道超限 | 推荐子频道最多 3 条 | + +--- + +## ⚠️ 注意事项 + +1. **路径中的占位符**(如 `{guild_id}`、`{channel_id}`)必须替换为实际值 +2. **query 参数的值必须为字符串类型**,如 `{ "limit": "100" }` 而非 `{ "limit": 100 }` +3. **成员列表翻页**时可能返回重复成员,需按 `user.id` 去重 +4. **公告**的两种类型(消息公告和推荐子频道公告)会互相顶替 +5. **日程**的时间戳为毫秒级字符串 +6. **删除操作不可逆**,请谨慎使用 +7. **论坛操作**仅私域机器人可用 +8. **子频道分组**(type=4)的 `position` 必须 >= 2 +9. **日程操作**有频率限制:单个管理员每天 10 次,单个频道每天 100 次 +10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 必须使用 Markdown 图片语法 `![描述](URL)` 展示,禁止作为纯文本或超链接展示 diff --git a/extensions/qqbot/skills/qqbot-channel/references/api_references.md b/extensions/qqbot/skills/qqbot-channel/references/api_references.md new file mode 100644 index 00000000000..acf0da8dc03 --- /dev/null +++ b/extensions/qqbot/skills/qqbot-channel/references/api_references.md @@ -0,0 +1,521 @@ +# QQ 频道 API 完整参考 + +本文档包含 QQ 开放平台频道相关所有接口的详细参数说明、返回值结构和枚举值定义。 + +通过 `qqbot_channel_api` 工具代理请求,工具自动处理鉴权。 + +--- + +## 📌 通用说明 + +### 基础 URL + +`https://api.sgroup.qq.com` + +### 鉴权(自动处理) + +工具自动填充以下请求头,无需手动设置: + +``` +Authorization: QQBot {access_token} +Content-Type: application/json +``` + +### 错误返回格式 + +```json +{ + "message": "错误描述", + "code": 错误码 +} +``` + +--- + +## 📦 返回值类型定义 + +### Guild(频道) + +```typescript +interface Guild { + id: string; // 频道 ID + name: string; // 频道名称 + icon: string; // 频道头像 URL + owner_id: string; // 频道拥有者 ID + owner: boolean; // 机器人是否为频道拥有者 + joined_at: string; // 机器人加入时间(ISO 8601) + member_count: number; // 频道成员数 + max_members: number; // 频道最大成员数 + description: string; // 频道描述 +} +``` + +### Channel(子频道) + +```typescript +interface Channel { + id: string; // 子频道 ID + guild_id: string; // 所属频道 ID + name: string; // 子频道名称 + type: number; // 子频道类型(见枚举) + position: number; // 排序位置 + parent_id: string; // 所属分组 ID + owner_id: string; // 创建者 ID + sub_type: number; // 子类型(见枚举) + private_type?: number; // 私密类型(见枚举) + speak_permission?: number; // 发言权限(见枚举) + application_id?: string; // 应用子频道 AppID +} +``` + +### User(用户) + +```typescript +interface User { + id: string; // 用户 ID + username: string; // 用户名 + avatar: string; // 头像 URL + bot: boolean; // 是否为机器人 + union_openid?: string; // 特殊关联应用的 openid + union_user_account?: string; // 特殊关联应用的用户信息 +} +``` + +### Member(成员) + +```typescript +interface Member { + user: User; // 用户基本信息 + nick: string; // 在频道中的昵称 + roles: string[]; // 身份组 ID 列表 + joined_at: string; // 加入频道时间(ISO 8601) + deaf?: boolean; // 是否被禁言 + mute?: boolean; // 是否被闭麦 + pending?: boolean; // 是否待审核 +} +``` + +### APIPermission(API 权限) + +```typescript +interface APIPermission { + path: string; // 接口路径 + method: string; // 请求方法 + desc: string; // 接口描述 + auth_status: number; // 授权状态:0=未授权, 1=已授权 +} +``` + +### AnnouncesResult(公告结果) + +```typescript +interface AnnouncesResult { + guild_id: string; + channel_id: string; + message_id: string; + announces_type: number; + recommend_channels: RecommendChannel[]; +} + +interface RecommendChannel { + channel_id: string; // 推荐的子频道 ID + introduce: string; // 推荐语 +} +``` + +### ThreadDetail(帖子详情) + +```typescript +interface ThreadDetail { + thread: { + guild_id: string; + channel_id: string; + author_id: string; + thread_info: { + thread_id: string; + title: string; + content: string; + date_time: string; + }; + }; +} +``` + +### ThreadListResult(帖子列表) + +```typescript +interface ThreadListResult { + threads: Array<{ + guild_id: string; + channel_id: string; + author_id: string; + thread_info: { + thread_id: string; + title: string; + content: string; + date_time: string; + }; + }>; + is_finish: number; // 1=已到底, 0=还有更多 +} +``` + +### Schedule(日程) + +```typescript +interface Schedule { + id?: string; + name: string; + start_timestamp: string; // 毫秒级时间戳 + end_timestamp: string; + jump_channel_id?: string; + remind_type?: string; + creator?: { + user: { id: string; username: string; bot: boolean }; + nick: string; + joined_at: string; + }; +} +``` + +--- + +## 📋 枚举值定义 + +### 子频道类型(Channel type) + +| 值 | 名称 | 说明 | +| ------- | ---------- | -------------------------------- | +| `0` | 文字子频道 | 普通文字聊天 | +| `2` | 语音子频道 | 语音聊天 | +| `4` | 子频道分组 | 组织子频道的分组(position ≥ 2) | +| `10005` | 直播子频道 | 直播功能 | +| `10006` | 应用子频道 | 需 application_id | +| `10007` | 论坛子频道 | 论坛功能 | + +### 子频道子类型(Channel sub_type) + +| 值 | 名称 | +| --- | ---- | +| `0` | 闲聊 | +| `1` | 公告 | +| `2` | 攻略 | +| `3` | 开黑 | + +### 子频道私密类型(Channel private_type) + +| 值 | 说明 | +| --- | -------------------- | +| `0` | 公开子频道 | +| `1` | 管理员和指定成员可见 | +| `2` | 仅管理员可见 | + +### 子频道发言权限(Channel speak_permission) + +| 值 | 说明 | +| --- | ------------------------------------------ | +| `0` | 无效(仅创建公告子频道时有效,此时为只读) | +| `1` | 所有人可发言 | +| `2` | 仅管理员和指定成员可发言 | + +### 公告类型(announces_type) + +| 值 | 说明 | +| --- | -------- | +| `0` | 成员公告 | +| `1` | 欢迎公告 | + +### 帖子格式(format) + +| 值 | 格式 | +| --- | -------------------- | +| `1` | 纯文本 | +| `2` | HTML | +| `3` | Markdown(**默认**) | +| `4` | JSON(RichText) | + +### 日程提醒类型(remind_type) + +| 值 | 说明 | +| ----- | -------------- | +| `"0"` | 不提醒 | +| `"1"` | 开始时提醒 | +| `"2"` | 开始前 5 分钟 | +| `"3"` | 开始前 15 分钟 | +| `"4"` | 开始前 30 分钟 | +| `"5"` | 开始前 60 分钟 | + +### API 权限授权状态(auth_status) + +| 值 | 说明 | +| --- | ------ | +| `0` | 未授权 | +| `1` | 已授权 | + +--- + +## 📖 各接口详细说明 + +### GET /users/@me/guilds — 获取频道列表 + +**查询参数**: + +| 参数 | 类型 | 必填 | 说明 | +| -------- | ------ | ---- | ---------------------------------------------------- | +| `before` | string | 否 | 读此 guild id 之前的数据 | +| `after` | string | 否 | 读此 guild id 之后的数据(与 before 同时设置时无效) | +| `limit` | string | 否 | 每次拉取条数,默认 100,最大 100 | + +**返回**: `Guild[]` + +**调用示例**: + +```json +{ "method": "GET", "path": "/users/@me/guilds", "query": { "limit": "100" } } +``` + +--- + +### GET /guilds/{guild_id}/api_permission — 获取频道 API 权限 + +**返回**: `{ apis: APIPermission[] }` + +**调用示例**: + +```json +{ "method": "GET", "path": "/guilds/123456/api_permission" } +``` + +--- + +### GET /guilds/{guild_id}/channels — 获取子频道列表 + +**返回**: `Channel[]` + +**调用示例**: + +```json +{ "method": "GET", "path": "/guilds/123456/channels" } +``` + +--- + +### GET /channels/{channel_id} — 获取子频道详情 + +**返回**: `Channel` + +--- + +### POST /guilds/{guild_id}/channels — 创建子频道 + +> ⚠️ 仅私域机器人可用,需管理频道权限 + +**请求体**: + +| 参数 | 类型 | 必填 | 说明 | +| ------------------ | -------- | ---- | ------------------------------------- | +| `name` | string | 是 | 子频道名称 | +| `type` | number | 是 | 子频道类型 | +| `position` | number | 是 | 排序位置(type=4 时 ≥ 2) | +| `sub_type` | number | 否 | 子类型 | +| `parent_id` | string | 否 | 所属分组 ID | +| `private_type` | number | 否 | 私密类型 | +| `private_user_ids` | string[] | 否 | 私密成员列表(private_type=1 时有效) | +| `speak_permission` | number | 否 | 发言权限 | +| `application_id` | string | 否 | 应用 AppID(type=10006 时需要) | + +**返回**: `Channel` + +--- + +### PATCH /channels/{channel_id} — 修改子频道 + +> ⚠️ 仅私域机器人可用 + +**请求体**(至少一个): + +| 参数 | 类型 | 说明 | +| ------------------ | ------ | -------- | +| `name` | string | 名称 | +| `position` | number | 排序位置 | +| `parent_id` | string | 分组 ID | +| `private_type` | number | 私密类型 | +| `speak_permission` | number | 发言权限 | + +**返回**: `Channel` + +--- + +### DELETE /channels/{channel_id} — 删除子频道 + +> ⚠️ 不可逆!仅私域机器人可用 + +--- + +### GET /guilds/{guild_id}/members — 获取成员列表 + +> 仅私域机器人可用 + +**查询参数**: + +| 参数 | 类型 | 说明 | +| ------- | ------ | ---------------------------------- | +| `after` | string | 上次最后一个 user.id,首次填 `"0"` | +| `limit` | string | 分页大小 1-400,默认 1 | + +**返回**: `Member[]` + +> 翻页:用最后一个 `user.id` 作为 `after`,直到返回空数组。可能返回重复成员,需按 `user.id` 去重。 + +--- + +### GET /guilds/{guild_id}/members/{user_id} — 获取成员详情 + +**返回**: `Member` + +--- + +### GET /guilds/{guild_id}/roles/{role_id}/members — 获取身份组成员列表 + +> 仅私域机器人可用 + +**查询参数**: + +| 参数 | 类型 | 说明 | +| ------------- | ------ | ---------------------- | +| `start_index` | string | 分页标识,首次填 `"0"` | +| `limit` | string | 分页大小 1-400,默认 1 | + +**返回**: `{ data: Member[], next: string }` + +> 翻页:用 `next` 作为 `start_index`,直到 `data` 为空。 + +--- + +### GET /channels/{channel_id}/online_nums — 获取在线成员数 + +**返回**: `{ online_nums: number }` + +--- + +### POST /guilds/{guild_id}/announces — 创建频道公告 + +**请求体**: + +| 参数 | 类型 | 必填 | 说明 | +| -------------------- | ------ | ---- | --------------------------------------------------- | +| `message_id` | string | 否 | 消息 ID(有值时创建消息公告,此时 channel_id 必填) | +| `channel_id` | string | 否 | 子频道 ID | +| `announces_type` | number | 否 | 0=成员公告,1=欢迎公告 | +| `recommend_channels` | array | 否 | 推荐子频道列表(最多 3 条,message_id 为空时生效) | + +> 两种公告类型会互相顶替 + +**返回**: `AnnouncesResult` + +--- + +### DELETE /guilds/{guild_id}/announces/{message_id} — 删除公告 + +> `message_id` 设为 `all` 删除所有公告 + +--- + +### GET /channels/{channel_id}/threads — 获取帖子列表 + +> 仅私域机器人可用,channel_id 须为论坛子频道(type=10007) + +**返回**: `ThreadListResult` + +--- + +### GET /channels/{channel_id}/threads/{thread_id} — 获取帖子详情 + +> 仅私域机器人可用 + +**返回**: `ThreadDetail` + +--- + +### PUT /channels/{channel_id}/threads — 发表帖子 + +> 仅私域机器人可用 + +**请求体**: + +| 参数 | 类型 | 必填 | 说明 | +| --------- | ------ | ---- | ------------------------------------------ | +| `title` | string | 是 | 帖子标题 | +| `content` | string | 是 | 帖子内容 | +| `format` | number | 否 | 1=文本, 2=HTML, 3=Markdown(默认), 4=JSON | + +**返回**: `{ task_id: string, create_time: string }` + +--- + +### DELETE /channels/{channel_id}/threads/{thread_id} — 删除帖子 + +> ⚠️ 不可逆!仅私域机器人可用 + +--- + +### POST /channels/{channel_id}/threads/{thread_id}/comment — 发表评论 + +> 仅私域机器人可用 + +**请求体**: + +| 参数 | 类型 | 必填 | 说明 | +| -------------------- | ------ | ---- | ------------ | +| `thread_author` | string | 是 | 帖子作者 ID | +| `content` | string | 是 | 评论内容 | +| `thread_create_time` | string | 否 | 帖子创建时间 | +| `image` | string | 否 | 图片链接 | + +**返回**: `{ task_id: string, create_time: number }` + +--- + +### POST /channels/{channel_id}/schedules — 创建日程 + +> 需要管理频道权限。单管理员/天限 10 次,单频道/天限 100 次。 + +**请求体**: + +```json +{ + "schedule": { + "name": "日程名称", + "start_timestamp": "毫秒时间戳", + "end_timestamp": "毫秒时间戳", + "jump_channel_id": "0", + "remind_type": "0" + } +} +``` + +| 参数 | 类型 | 必填 | 说明 | +| -------------------------- | ------ | ---- | ------------------------- | +| `schedule.name` | string | 是 | 日程名称 | +| `schedule.start_timestamp` | string | 是 | 开始时间(毫秒) | +| `schedule.end_timestamp` | string | 是 | 结束时间(毫秒) | +| `schedule.jump_channel_id` | string | 否 | 跳转子频道 ID,默认 `"0"` | +| `schedule.remind_type` | string | 否 | 提醒类型,默认 `"0"` | + +**返回**: `Schedule` + +--- + +### PATCH /channels/{channel_id}/schedules/{schedule_id} — 修改日程 + +> 需要管理频道权限 + +**请求体**:同创建日程 + +**返回**: `Schedule` + +--- + +### DELETE /channels/{channel_id}/schedules/{schedule_id} — 删除日程 + +> ⚠️ 不可逆!需要管理频道权限 diff --git a/extensions/qqbot/skills/qqbot-media/SKILL.md b/extensions/qqbot/skills/qqbot-media/SKILL.md new file mode 100644 index 00000000000..b8c4479126a --- /dev/null +++ b/extensions/qqbot/skills/qqbot-media/SKILL.md @@ -0,0 +1,36 @@ +--- +name: qqbot-media +description: QQBot 富媒体收发能力。使用 标签,系统根据文件扩展名自动识别类型(图片/语音/视频/文件)。 +metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qqbot"] } } } +--- + +# QQBot 富媒体收发 + +## 用法 + +``` +路径或URL +``` + +系统根据文件扩展名自动识别类型并路由: + +- `.jpg/.png/.gif/.webp/.bmp` → 图片 +- `.silk/.wav/.mp3/.ogg/.aac/.flac` 等 → 语音 +- `.mp4/.mov/.avi/.mkv/.webm` 等 → 视频 +- 其他扩展名 → 文件 +- 无扩展名的 URL → 默认按图片处理 + +## 接收媒体 + +- 用户发来的**图片**自动下载到本地,路径在上下文【附件】中,可直接用 `路径` 回发 +- 用户发来的**语音**路径在上下文中;若有 STT 能力则优先转写 + +## 规则 + +1. **路径必须是绝对路径**(以 `/` 或 `http` 开头) +2. **标签必须用开闭标签包裹路径**:`路径` +3. **文件大小上限 10MB** +4. **你有能力发送本地图片/文件**,直接用标签包裹路径即可,**不要说"无法发送"** +5. 发送语音时不要重复语音中已朗读的文字 +6. 多个媒体用多个标签 +7. 以会话上下文中的能力说明为准(如未启用语音则不要发语音) diff --git a/extensions/qqbot/skills/qqbot-remind/SKILL.md b/extensions/qqbot/skills/qqbot-remind/SKILL.md new file mode 100644 index 00000000000..f5a38b1edcf --- /dev/null +++ b/extensions/qqbot/skills/qqbot-remind/SKILL.md @@ -0,0 +1,149 @@ +--- +name: qqbot-remind +description: QQBot 定时提醒。支持一次性和周期性提醒的创建、查询、取消。当通过 QQ 通道通信且涉及提醒/定时任务时使用。 +metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqbot"] } } } +--- + +# QQ Bot 定时提醒 + +## ⚠️ 强制规则 + +**当用户提到「提醒」「闹钟」「定时」「X分钟/小时后」「每天X点」「叫我」等任何涉及延时或定时的请求时,你必须调用工具,绝对不能只用自然语言回复说"好的,我会提醒你"!** + +你没有内存或后台线程,口头承诺"到时候提醒"是无效的——只有调用工具才能真正注册定时任务。 + +--- + +## 推荐流程(优先使用 `qqbot_remind` 工具) + +**第一步**:调用 `qqbot_remind` 工具,传入简单参数: + +| 参数 | 说明 | 示例 | +| --------- | -------------------------------------------- | ---------------------------------- | +| `action` | 操作类型 | `"add"` / `"list"` / `"remove"` | +| `content` | 提醒内容 | `"喝水"` | +| `to` | 目标地址(可选,系统自动获取,通常无需填写) | — | +| `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` | +| `jobId` | 任务 ID(仅 remove) | `"xxx"` | + +**第二步**:`qqbot_remind` 会返回 `cronParams`,你必须**立即**将 `cronParams` 的值作为参数调用 `cron` 工具。 + +**第三步**:根据 `cron` 工具的返回结果,回复用户。 + +### 示例 + +用户说:"5分钟后提醒我喝水" + +1. 调用 `qqbot_remind`:`{ "action": "add", "content": "喝水", "time": "5m" }` +2. 收到返回的 `cronParams` → 立即调用 `cron` 工具,参数为该 `cronParams` +3. 回复用户:`⏰ 好的,5分钟后提醒你喝水~` + +--- + +## 备用方案(直接使用 `cron` 工具) + +> 仅当 `qqbot_remind` 工具不可用时使用以下方式。 + +### 核心规则 + +> **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"` | 隔离会话避免污染 | + +> `schedule.atMs` 必须是**绝对毫秒时间戳**(如 `1770733800000`),不支持 `"5m"` 等相对字符串。 +> 计算方式:`当前时间戳ms + 延迟毫秒`。 + +### 一次性提醒(schedule.kind = "at") + +```json +{ + "action": "add", + "job": { + "name": "{任务名}", + "schedule": { "kind": "at", "atMs": "{当前时间戳ms + N*60000}" }, + "sessionTarget": "isolated", + "wakeMode": "now", + "deleteAfterRun": true, + "payload": { + "kind": "agentTurn", + "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀", + "deliver": true, + "channel": "qqbot", + "to": "{openid}" + } + } +} +``` + +### 周期提醒(schedule.kind = "cron") + +```json +{ + "action": "add", + "job": { + "name": "{任务名}", + "schedule": { "kind": "cron", "expr": "0 8 * * *", "tz": "Asia/Shanghai" }, + "sessionTarget": "isolated", + "wakeMode": "now", + "payload": { + "kind": "agentTurn", + "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀", + "deliver": true, + "channel": "qqbot", + "to": "{openid}" + } + } +} +``` + +> 周期任务**不加** `deleteAfterRun`。群聊 `to` 格式为 `"group:{group_openid}"`。 + +--- + +## cron 表达式速查 + +| 场景 | expr | +| -------------- | ---------------- | +| 每天早上8点 | `"0 8 * * *"` | +| 每天晚上10点 | `"0 22 * * *"` | +| 工作日早上9点 | `"0 9 * * 1-5"` | +| 每周一早上9点 | `"0 9 * * 1"` | +| 每周末上午10点 | `"0 10 * * 0,6"` | +| 每小时整点 | `"0 * * * *"` | + +> 周期提醒必须加 `"tz": "Asia/Shanghai"`。 + +--- + +## AI 决策指南 + +| 用户说法 | action | time 格式 | +| ------------------- | ---------------- | --------------- | +| "5分钟后提醒我喝水" | `add` | `"5m"` | +| "1小时后提醒开会" | `add` | `"1h"` | +| "每天8点提醒我打卡" | `add` | `"0 8 * * *"` | +| "工作日早上9点提醒" | `add` | `"0 9 * * 1-5"` | +| "我有哪些提醒" | `list` | — | +| "取消喝水提醒" | `remove` | — | +| "修改提醒时间" | `remove` → `add` | — | +| "提醒我"(无时间) | **需追问** | — | + +纯相对时间("5分钟后"、"1小时后")可直接计算,无需确认。时间模糊或缺失时需追问。 + +--- + +## 回复模板 + +- 一次性:`⏰ 好的,{时间}后提醒你{内容}~` +- 周期:`⏰ 收到,{周期}提醒你{内容}~` +- 查询无结果:`📋 目前没有提醒哦~ 说"5分钟后提醒我xxx"试试?` +- 删除成功:`✅ 已取消"{名称}"` diff --git a/extensions/qqbot/src/api.ts b/extensions/qqbot/src/api.ts new file mode 100644 index 00000000000..026ffd18280 --- /dev/null +++ b/extensions/qqbot/src/api.ts @@ -0,0 +1,991 @@ +import { createRequire } from "node:module"; +import os from "node:os"; +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); +let _pluginVersion = "unknown"; +try { + _pluginVersion = _require("../package.json").version ?? "unknown"; +} catch { + /* fallback */ +} +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(String(appId).trim(), callback); +} + +/** Initialize per-app API behavior such as markdown support. */ +export function initApiConfig(appId: string, options: { markdownSupport?: boolean }): void { + markdownSupportMap.set(String(appId).trim(), options.markdownSupport === true); +} + +/** Return whether markdown is enabled for the given appId. */ +export function isMarkdownSupport(appId: string): boolean { + return markdownSupportMap.get(String(appId).trim()) ?? 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 = String(appId).trim(); + 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; + try { + response = await fetch(TOKEN_URL, { + method: "POST", + headers: requestHeaders, + body: JSON.stringify(requestBody), + }); + } catch (err) { + debugError(`[qqbot-api:${appId}] <<< Network error:`, err); + throw new Error( + `Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + 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: ${err instanceof Error ? err.message : String(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; +} + +/** Clear one token cache or all token caches. */ +export function clearTokenCache(appId?: string): void { + if (appId) { + const normalizedAppId = String(appId).trim(); + 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; + try { + res = await fetch(url, options); + } 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`); + } + debugError(`[qqbot-api] <<< Network error:`, err); + throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(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}` : ""}`, + ); + + let data: T; + let rawBody: string; + try { + rawBody = await res.text(); + debugLog(`[qqbot-api] <<< Body:`, rawBody); + data = JSON.parse(rawBody) as T; + } catch (err) { + throw new Error( + `Failed to parse response[${path}]: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (!res.ok) { + const error = data as { message?: string; code?: number }; + throw new Error(`API Error [${path}]: ${error.message ?? JSON.stringify(data)}`); + } + + return data; +} + +// 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(String(appId).trim()); + if (result.ext_info?.ref_idx && hook) { + try { + hook(result.ext_info.ref_idx, meta); + } catch (err) { + debugError(`[qqbot-api:${appId}] onMessageSent hook error: ${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<{ id: string; timestamp: string }> { + 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<{ id: string; timestamp: string }> { + const body = buildProactiveMessageBody(appId, content); + return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body); +} + +// 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; +} + +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 = 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 = 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: ${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/channel.setup.ts b/extensions/qqbot/src/channel.setup.ts new file mode 100644 index 00000000000..f399c5f1530 --- /dev/null +++ b/extensions/qqbot/src/channel.setup.ts @@ -0,0 +1,130 @@ +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +import { + applyAccountNameToChannelSection, + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "openclaw/plugin-sdk/core"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; +import { qqbotChannelConfigSchema } from "./config-schema.js"; +import { + DEFAULT_ACCOUNT_ID, + listQQBotAccountIds, + resolveQQBotAccount, + applyQQBotAccountConfig, + resolveDefaultQQBotAccountId, +} from "./config.js"; +import { qqbotSetupWizard } from "./setup-surface.js"; +import type { ResolvedQQBotAccount } from "./types.js"; + +/** + * Setup-only QQBot plugin — lightweight subset used during `openclaw onboard` + * and `openclaw configure` without pulling the full runtime dependencies. + */ +export const qqbotSetupPlugin: ChannelPlugin = { + id: "qqbot", + setupWizard: qqbotSetupWizard, + meta: { + id: "qqbot", + label: "QQ Bot", + selectionLabel: "QQ Bot", + docsPath: "/channels/qqbot", + blurb: "Connect to QQ via official QQ Bot API", + order: 50, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: false, + threads: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.qqbot"] }, + configSchema: qqbotChannelConfigSchema, + config: { + listAccountIds: (cfg) => listQQBotAccountIds(cfg), + resolveAccount: (cfg, accountId) => + resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }), + defaultAccountId: (cfg) => resolveDefaultQQBotAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "qqbot", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "qqbot", + accountId, + clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"], + }), + isConfigured: (account) => + Boolean( + account?.appId && + (Boolean(account?.clientSecret) || + hasConfiguredSecretInput(account?.config?.clientSecret) || + Boolean(account?.config?.clientSecretFile?.trim())), + ), + describeAccount: (account) => ({ + accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID, + name: account?.name, + enabled: account?.enabled ?? false, + configured: Boolean( + account?.appId && + (Boolean(account?.clientSecret) || + hasConfiguredSecretInput(account?.config?.clientSecret) || + Boolean(account?.config?.clientSecretFile?.trim())), + ), + tokenSource: account?.secretSource, + }), + }, + setup: { + resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID, + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: "qqbot", + accountId, + name, + }), + validateInput: ({ input }) => { + if (!input.token && !input.tokenFile && !input.useEnv) { + return "QQBot requires --token (format: appId:clientSecret) or --use-env"; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + let appId = ""; + let clientSecret = ""; + + if (input.token) { + const colonIdx = input.token.indexOf(":"); + if (colonIdx > 0) { + appId = input.token.slice(0, colonIdx); + clientSecret = input.token.slice(colonIdx + 1); + } else { + // Token must be in appId:clientSecret format; skip config write if malformed. + return cfg; + } + } + + if (!appId && !input.tokenFile) { + // No valid credentials provided; skip config write. + return cfg; + } + + // When only --token-file is provided, appId will be empty here. + // This is by design: --token-file supplies the clientSecret only, + // not the appId. The appId is expected to come from the env var + // QQBOT_APP_ID or be set separately in the config file. + return applyQQBotAccountConfig(cfg, accountId, { + appId, + clientSecret, + clientSecretFile: input.tokenFile, + name: input.name, + }); + }, + }, +}; diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts new file mode 100644 index 00000000000..11e1647e758 --- /dev/null +++ b/extensions/qqbot/src/channel.ts @@ -0,0 +1,343 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +import { + applyAccountNameToChannelSection, + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "openclaw/plugin-sdk/core"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; +import { initApiConfig } from "./api.js"; +import { qqbotChannelConfigSchema } from "./config-schema.js"; +import { + DEFAULT_ACCOUNT_ID, + applyQQBotAccountConfig, + listQQBotAccountIds, + resolveQQBotAccount, + resolveDefaultQQBotAccountId, +} from "./config.js"; +import { getQQBotRuntime } from "./runtime.js"; +import { qqbotSetupWizard } from "./setup-surface.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"; +import type { ResolvedQQBotAccount } from "./types.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; +function loadGatewayModule(): Promise { + _gatewayModulePromise ??= import("./gateway.js"); + return _gatewayModulePromise; +} + +export const qqbotPlugin: ChannelPlugin = { + id: "qqbot", + setupWizard: qqbotSetupWizard, + meta: { + id: "qqbot", + label: "QQ Bot", + selectionLabel: "QQ Bot", + docsPath: "/channels/qqbot", + blurb: "Connect to QQ via official QQ Bot API", + order: 50, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: false, + threads: false, + /** + * blockStreaming=true means the channel supports block streaming. + * The framework collects streamed blocks and sends them through deliver(). + */ + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.qqbot"] }, + configSchema: qqbotChannelConfigSchema, + + config: { + listAccountIds: (cfg) => listQQBotAccountIds(cfg), + resolveAccount: (cfg, accountId) => + resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }), + defaultAccountId: (cfg) => resolveDefaultQQBotAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "qqbot", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "qqbot", + accountId, + clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"], + }), + isConfigured: (account) => + Boolean( + account?.appId && + (Boolean(account?.clientSecret) || + hasConfiguredSecretInput(account?.config?.clientSecret) || + Boolean(account?.config?.clientSecretFile?.trim())), + ), + describeAccount: (account) => ({ + accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID, + name: account?.name, + enabled: account?.enabled ?? false, + configured: Boolean( + account?.appId && + (Boolean(account?.clientSecret) || + hasConfiguredSecretInput(account?.config?.clientSecret) || + Boolean(account?.config?.clientSecretFile?.trim())), + ), + tokenSource: account?.secretSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => { + const account = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }); + const allowFrom = account.config?.allowFrom; + return allowFrom; + }, + // Normalize allowFrom entries by removing the qqbot: prefix and uppercasing IDs. + formatAllowFrom: ({ allowFrom }) => + (allowFrom ?? []) + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^qqbot:/i, "")) + .map((entry) => entry.toUpperCase()), + }, + setup: { + resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID, + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: "qqbot", + accountId, + name, + }), + validateInput: ({ input }) => { + if (!input.token && !input.tokenFile && !input.useEnv) { + return "QQBot requires --token (format: appId:clientSecret) or --use-env"; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + let appId = ""; + let clientSecret = ""; + + if (input.token) { + const colonIdx = input.token.indexOf(":"); + if (colonIdx > 0) { + appId = input.token.slice(0, colonIdx); + clientSecret = input.token.slice(colonIdx + 1); + } + } + + return applyQQBotAccountConfig(cfg, accountId, { + appId, + clientSecret, + clientSecretFile: input.tokenFile, + name: input.name, + }); + }, + }, + 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; + }, + 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); + }, + hint: "QQ Bot target format: qqbot:c2c:openid (direct) or qqbot:group:groupid (group)", + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 5000, + sendText: async ({ to, text, accountId, replyToId, cfg }) => { + const account = resolveQQBotAccount(cfg, accountId); + const { sendText } = await import("./outbound.js"); + initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); + const result = await sendText({ to, text, accountId, replyToId, account }); + return { + channel: "qqbot" as const, + messageId: result.messageId ?? "", + meta: result.error ? { error: result.error } : undefined, + }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => { + const account = resolveQQBotAccount(cfg, accountId); + const { sendMedia } = await import("./outbound.js"); + initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); + const result = await sendMedia({ + to, + text: text ?? "", + mediaUrl: mediaUrl ?? "", + accountId, + replyToId, + account, + }); + return { + channel: "qqbot" as const, + messageId: result.messageId ?? "", + meta: result.error ? { error: result.error } : undefined, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const { account } = ctx; + const { abortSignal, log, cfg } = ctx; + // 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. + const { startGateway } = await loadGatewayModule(); + + log?.info( + `[qqbot:${account.accountId}] Starting gateway — appId=${account.appId}, enabled=${account.enabled}, name=${account.name ?? "unnamed"}`, + ); + + await startGateway({ + account, + abortSignal, + cfg, + log, + onReady: () => { + log?.info(`[qqbot:${account.accountId}] Gateway ready`); + ctx.setStatus({ + ...ctx.getStatus(), + running: true, + connected: true, + lastConnectedAt: Date.now(), + }); + }, + onError: (error) => { + log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`); + ctx.setStatus({ + ...ctx.getStatus(), + lastError: error.message, + }); + }, + }); + }, + logoutAccount: async ({ accountId, cfg }) => { + const nextCfg = { ...cfg } as OpenClawConfig; + const nextQQBot = cfg.channels?.qqbot ? { ...cfg.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 = { ...nextCfg.channels, qqbot: nextQQBot }; + const runtime = getQQBotRuntime(); + const configApi = runtime.config as { + writeConfigFile: (cfg: OpenClawConfig) => Promise; + }; + await configApi.writeConfigFile(nextCfg); + } + + const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId); + const loggedOut = resolved.secretSource === "none"; + const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET); + + return { ok: true, cleared, envToken, loggedOut }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + connected: false, + lastConnectedAt: null, + lastError: null, + lastInboundAt: null, + lastOutboundAt: null, + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + tokenSource: snapshot.tokenSource ?? "none", + running: snapshot.running ?? false, + connected: snapshot.connected ?? false, + lastConnectedAt: snapshot.lastConnectedAt ?? null, + lastError: snapshot.lastError ?? null, + }), + buildAccountSnapshot: ({ account, runtime }) => ({ + accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID, + name: account?.name, + enabled: account?.enabled ?? false, + configured: Boolean(account?.appId && account?.clientSecret), + tokenSource: account?.secretSource, + running: runtime?.running ?? false, + connected: runtime?.connected ?? false, + lastConnectedAt: runtime?.lastConnectedAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + }, +}; diff --git a/extensions/qqbot/src/command-auth.test.ts b/extensions/qqbot/src/command-auth.test.ts new file mode 100644 index 00000000000..752e3d7e85e --- /dev/null +++ b/extensions/qqbot/src/command-auth.test.ts @@ -0,0 +1,62 @@ +/** + * Regression tests for QQBot command authorization alignment with the shared + * command-auth model. + * + * Covers the regression identified in the code review: + * + * allowFrom entries with the qqbot: prefix must normalize correctly so that + * "qqbot:" in channel.allowFrom matches the inbound event.senderId "". + * Verified against the normalization logic in the gateway.ts inbound path. + * + * Note: commands.allowFrom.qqbot precedence over channel allowFrom is enforced + * by the framework's resolveCommandAuthorization(). QQBot routes requireAuth:true + * commands through the framework (api.registerCommand), so that behavior is + * covered by the framework's own tests rather than duplicated here. + */ + +import { describe, expect, it } from "vitest"; +import { qqbotPlugin } from "./channel.js"; + +// --------------------------------------------------------------------------- +// qqbot: prefix normalization for inbound commandAuthorized +// +// Uses qqbotPlugin.config.formatAllowFrom directly — the same function the +// fixed gateway.ts inbound path calls — so the test stays in sync with the +// actual implementation without duplicating the logic. +// --------------------------------------------------------------------------- + +describe("qqbot: prefix normalization for inbound commandAuthorized", () => { + const formatAllowFrom = qqbotPlugin.config!.formatAllowFrom!; + + /** Mirrors the fixed gateway.ts inbound commandAuthorized computation. */ + function resolveInboundCommandAuthorized(rawAllowFrom: string[], senderId: string): boolean { + const normalizedAllowFrom = formatAllowFrom({ + cfg: {} as never, + accountId: null, + allowFrom: rawAllowFrom, + }); + const normalizedSenderId = senderId.replace(/^qqbot:/i, "").toUpperCase(); + const allowAll = normalizedAllowFrom.length === 0 || normalizedAllowFrom.some((e) => e === "*"); + return allowAll || normalizedAllowFrom.includes(normalizedSenderId); + } + + it("authorizes when allowFrom uses qqbot: prefix and senderId is the bare id", () => { + expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "USER123")).toBe(true); + }); + + it("authorizes when qqbot: prefix is mixed case", () => { + expect(resolveInboundCommandAuthorized(["QQBot:user123"], "USER123")).toBe(true); + }); + + it("denies a sender not in the qqbot:-prefixed allowFrom list", () => { + expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "OTHER")).toBe(false); + }); + + it("authorizes any sender when allowFrom is empty (open)", () => { + expect(resolveInboundCommandAuthorized([], "ANYONE")).toBe(true); + }); + + it("authorizes any sender when allowFrom contains wildcard *", () => { + expect(resolveInboundCommandAuthorized(["*"], "ANYONE")).toBe(true); + }); +}); diff --git a/extensions/qqbot/src/config-schema.ts b/extensions/qqbot/src/config-schema.ts new file mode 100644 index 00000000000..a0978d1c449 --- /dev/null +++ b/extensions/qqbot/src/config-schema.ts @@ -0,0 +1,34 @@ +import { + AllowFromListSchema, + buildCatchallMultiAccountChannelSchema, + buildChannelConfigSchema, +} from "openclaw/plugin-sdk/channel-config-schema"; +import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input"; +import { z } from "zod"; + +const AudioFormatPolicySchema = z + .object({ + sttDirectFormats: z.array(z.string()).optional(), + uploadDirectFormats: z.array(z.string()).optional(), + transcodeEnabled: z.boolean().optional(), + }) + .optional(); + +const QQBotAccountSchema = z.object({ + enabled: z.boolean().optional(), + name: z.string().optional(), + appId: z.string().optional(), + clientSecret: buildSecretInputSchema().optional(), + clientSecretFile: z.string().optional(), + allowFrom: AllowFromListSchema, + systemPrompt: z.string().optional(), + markdownSupport: z.boolean().optional(), + voiceDirectUploadFormats: z.array(z.string()).optional(), + audioFormatPolicy: AudioFormatPolicySchema, + urlDirectUpload: z.boolean().optional(), + upgradeUrl: z.string().optional(), + upgradeMode: z.enum(["doc", "hot-reload"]).optional(), +}); + +export const QQBotConfigSchema = buildCatchallMultiAccountChannelSchema(QQBotAccountSchema); +export const qqbotChannelConfigSchema = buildChannelConfigSchema(QQBotConfigSchema); diff --git a/extensions/qqbot/src/config.test.ts b/extensions/qqbot/src/config.test.ts new file mode 100644 index 00000000000..d987821c056 --- /dev/null +++ b/extensions/qqbot/src/config.test.ts @@ -0,0 +1,151 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { describe, expect, it } from "vitest"; +import { qqbotSetupPlugin } from "./channel.setup.js"; +import { QQBotConfigSchema } from "./config-schema.js"; +import { DEFAULT_ACCOUNT_ID, resolveQQBotAccount } from "./config.js"; + +describe("qqbot config", () => { + it("accepts SecretRef-backed credentials in the runtime schema", () => { + const parsed = QQBotConfigSchema.safeParse({ + appId: "123456", + clientSecret: { + source: "env", + provider: "default", + id: "QQBOT_CLIENT_SECRET", + }, + allowFrom: ["*"], + audioFormatPolicy: { + sttDirectFormats: [".wav"], + uploadDirectFormats: [".mp3"], + transcodeEnabled: false, + }, + urlDirectUpload: false, + upgradeUrl: "https://docs.openclaw.ai/channels/qqbot", + upgradeMode: "doc", + accounts: { + bot2: { + appId: "654321", + clientSecret: { + source: "env", + provider: "default", + id: "QQBOT_CLIENT_SECRET_BOT2", + }, + allowFrom: ["user-1"], + }, + }, + }); + + expect(parsed.success).toBe(true); + }); + + it("preserves top-level media and upgrade config on the default account", () => { + const cfg = { + channels: { + qqbot: { + appId: "123456", + clientSecret: "secret-value", + audioFormatPolicy: { + sttDirectFormats: [".wav"], + uploadDirectFormats: [".mp3"], + transcodeEnabled: false, + }, + urlDirectUpload: false, + upgradeUrl: "https://docs.openclaw.ai/channels/qqbot", + upgradeMode: "hot-reload", + }, + }, + } as OpenClawConfig; + + const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID); + + expect(resolved.clientSecret).toBe("secret-value"); + expect(resolved.config.audioFormatPolicy).toEqual({ + sttDirectFormats: [".wav"], + uploadDirectFormats: [".mp3"], + transcodeEnabled: false, + }); + expect(resolved.config.urlDirectUpload).toBe(false); + expect(resolved.config.upgradeUrl).toBe("https://docs.openclaw.ai/channels/qqbot"); + expect(resolved.config.upgradeMode).toBe("hot-reload"); + }); + + it("rejects unresolved SecretRefs on runtime resolution", () => { + const cfg = { + channels: { + qqbot: { + appId: "123456", + clientSecret: { + source: "env", + provider: "default", + id: "QQBOT_CLIENT_SECRET", + }, + }, + }, + } as OpenClawConfig; + + expect(() => resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID)).toThrow( + 'channels.qqbot.clientSecret: unresolved SecretRef "env:default:QQBOT_CLIENT_SECRET"', + ); + }); + + it("allows unresolved SecretRefs for setup/status flows", () => { + const cfg = { + channels: { + qqbot: { + appId: "123456", + clientSecret: { + source: "env", + provider: "default", + id: "QQBOT_CLIENT_SECRET", + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID, { + allowUnresolvedSecretRef: true, + }); + + expect(resolved.clientSecret).toBe(""); + expect(resolved.secretSource).toBe("config"); + expect(qqbotSetupPlugin.config.isConfigured?.(resolved, cfg)).toBe(true); + expect(qqbotSetupPlugin.config.describeAccount?.(resolved, cfg)?.configured).toBe(true); + }); + + it.each([ + { + accountId: DEFAULT_ACCOUNT_ID, + inputAccountId: DEFAULT_ACCOUNT_ID, + expectedPath: ["channels", "qqbot"], + }, + { + accountId: "bot2", + inputAccountId: "bot2", + expectedPath: ["channels", "qqbot", "accounts", "bot2"], + }, + ])("splits --token on the first colon for $accountId", ({ inputAccountId, expectedPath }) => { + const setup = qqbotSetupPlugin.setup; + expect(setup).toBeDefined(); + + const next = setup!.applyAccountConfig?.({ + cfg: {} as OpenClawConfig, + accountId: inputAccountId, + input: { + token: "102905186:Oi2Mg1Mh2Ni3:Pl7TpBXuHe1OmAYwKi7W", + }, + }) as Record; + + const accountConfig = expectedPath.reduce((value, key) => { + if (!value || typeof value !== "object") { + return undefined; + } + return (value as Record)[key]; + }, next) as Record | undefined; + + expect(accountConfig).toMatchObject({ + enabled: true, + appId: "102905186", + clientSecret: "Oi2Mg1Mh2Ni3:Pl7TpBXuHe1OmAYwKi7W", + }); + }); +}); diff --git a/extensions/qqbot/src/config.ts b/extensions/qqbot/src/config.ts new file mode 100644 index 00000000000..5ec3f5d7b1a --- /dev/null +++ b/extensions/qqbot/src/config.ts @@ -0,0 +1,199 @@ +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; +} + +function normalizeQQBotAccountConfig(account: QQBotAccountConfig | undefined): QQBotAccountConfig { + if (!account) { + return {}; + } + return { + ...account, + ...(account.audioFormatPolicy ? { audioFormatPolicy: { ...account.audioFormatPolicy } } : {}), + }; +} + +function normalizeAppId(raw: unknown): string { + if (raw === null || raw === undefined) return ""; + return String(raw).trim(); +} + +/** 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; + 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 ?? DEFAULT_ACCOUNT_ID; + 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 }; + + 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) || {}), + 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 { + // 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) || {}), + enabled: true, + accounts: { + ...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}), + [accountId]: { + ...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}), + 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; +} diff --git a/extensions/qqbot/src/gateway.ts b/extensions/qqbot/src/gateway.ts new file mode 100644 index 00000000000..e0b33210826 --- /dev/null +++ b/extensions/qqbot/src/gateway.ts @@ -0,0 +1,1476 @@ +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import WebSocket from "ws"; +import { + getAccessToken, + getGatewayUrl, + sendC2CMessage, + sendChannelMessage, + sendDmMessage, + sendGroupMessage, + clearTokenCache, + initApiConfig, + startBackgroundTokenRefresh, + stopBackgroundTokenRefresh, + sendC2CInputNotify, + onMessageSent, + PLUGIN_USER_AGENT, +} from "./api.js"; +import { qqbotPlugin } from "./channel.js"; +import { processAttachments, formatVoiceText } from "./inbound-attachments.js"; +import { recordKnownUser, flushKnownUsers } from "./known-users.js"; +import { createMessageQueue, type QueuedMessage } from "./message-queue.js"; +import { + parseAndSendMediaTags, + sendPlainReply, + type DeliverEventContext, + type DeliverAccountContext, +} from "./outbound-deliver.js"; +import { sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js"; +import { + setRefIndex, + getRefIndex, + formatRefEntryForAgent, + flushRefIndex, + type RefAttachmentSummary, +} from "./ref-index-store.js"; +import { + sendWithTokenRetry, + sendErrorToTarget, + handleStructuredPayload, + type ReplyContext, + type MessageTarget, +} from "./reply-dispatcher.js"; +import { getQQBotRuntime } from "./runtime.js"; +import { loadSession, saveSession, clearSession } from "./session-store.js"; +import { + matchSlashCommand, + type SlashCommandContext, + type SlashCommandFileResult, +} from "./slash-commands.js"; +import type { + ResolvedQQBotAccount, + WSPayload, + C2CMessageEvent, + GuildMessageEvent, + GroupMessageEvent, +} from "./types.js"; +import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js"; +import { isGlobalTTSAvailable, resolveTTSConfig } from "./utils/audio-convert.js"; +import { runDiagnostics } from "./utils/platform.js"; +import { parseFaceTags, parseRefIndices, buildAttachmentSummaries } 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; + +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 === true}`, + ); + + // 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 as OpenClawConfig)) { + const globalProvider = (cfg as OpenClawConfig).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 = content.toLowerCase(); + const isUrgentCommand = URGENT_COMMANDS.some( + (cmd) => + contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "), + ); + 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 as SlashCommandFileResult).text : (reply as string); + const replyFile = isFileResult ? (reply as SlashCommandFileResult).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: ${fileErr}`); + } + } + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Slash command error: ${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) { + 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}`); + 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 as OpenClawConfig); + + 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 = qqbotPlugin.config?.formatAllowFrom + ? qqbotPlugin.config.formatAllowFrom({ + cfg: cfg as OpenClawConfig, + accountId: account.accountId, + allowFrom: rawAllowFrom, + }) + : rawAllowFrom.map((e: string) => e.replace(/^qqbot:/i, "").toUpperCase()); + 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}`); + } + } + 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}`, + ); + } + } + 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}`, + ); + } + } + }, 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) => { + log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`); + hasResponse = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + + const errMsg = String(err); + 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: true, + }, + }); + + try { + await Promise.race([dispatchPromise, timeoutPromise]); + } catch (err) { + 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) { + log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`); + } 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 = data.toString(); + 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); + 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((event as any).message_scene?.ext); + 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((event as any).message_scene?.ext); + 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); + 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}`); + } + }); + + 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 = String(err); + log?.error(`[qqbot:${account.accountId}] Connection failed: ${err}`); + + // 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/inbound-attachments.ts b/extensions/qqbot/src/inbound-attachments.ts new file mode 100644 index 00000000000..aa9c9db75ef --- /dev/null +++ b/extensions/qqbot/src/inbound-attachments.ts @@ -0,0 +1,357 @@ +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"; + +export interface RawAttachment { + content_type: string; + url: string; + filename?: string; + voice_wav_url?: string; + asr_refer_text?: string; +} + +export type TranscriptSource = "stt" | "asr" | "fallback"; + +/** Normalized attachment output consumed by the gateway. */ +export interface ProcessedAttachments { + attachmentInfo: string; + imageUrls: string[]; + imageMediaTypes: string[]; + voiceAttachmentPaths: string[]; + voiceAttachmentUrls: string[]; + voiceAsrReferTexts: string[]; + voiceTranscripts: string[]; + voiceTranscriptSources: TranscriptSource[]; + attachmentLocalPaths: Array; +} + +interface ProcessContext { + accountId: string; + cfg: unknown; + log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; + }; +} + +const EMPTY_RESULT: ProcessedAttachments = { + attachmentInfo: "", + imageUrls: [], + imageMediaTypes: [], + voiceAttachmentPaths: [], + voiceAttachmentUrls: [], + voiceAsrReferTexts: [], + voiceTranscripts: [], + voiceTranscriptSources: [], + attachmentLocalPaths: [], +}; + +/** Download, convert, transcribe, and classify inbound attachments. */ +export async function processAttachments( + attachments: RawAttachment[] | undefined, + ctx: ProcessContext, +): Promise { + if (!attachments?.length) return EMPTY_RESULT; + + const { accountId, cfg, log } = ctx; + const downloadDir = getQQBotMediaDir("downloads"); + const prefix = `[qqbot:${accountId}]`; + + const imageUrls: string[] = []; + const imageMediaTypes: string[] = []; + const voiceAttachmentPaths: string[] = []; + const voiceAttachmentUrls: string[] = []; + const voiceAsrReferTexts: string[] = []; + const voiceTranscripts: string[] = []; + const voiceTranscriptSources: TranscriptSource[] = []; + const attachmentLocalPaths: Array = []; + const otherAttachments: string[] = []; + + // 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 wavUrl = + isVoice && att.voice_wav_url + ? att.voice_wav_url.startsWith("//") + ? `https:${att.voice_wav_url}` + : att.voice_wav_url + : ""; + + let localPath: string | null = null; + let audioPath: string | null = null; + + if (isVoice && wavUrl) { + const wavLocalPath = await downloadFile(wavUrl, downloadDir); + if (wavLocalPath) { + localPath = wavLocalPath; + audioPath = wavLocalPath; + log?.info( + `${prefix} 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`); + } + } + + if (!localPath) { + localPath = await downloadFile(attUrl, downloadDir, att.filename); + } + + return { att, attUrl, isVoice, localPath, audioPath }; + }); + + const downloadResults = await Promise.all(downloadTasks); + + // Phase 2: convert/transcribe voice attachments and classify everything else. + const processTasks = downloadResults.map( + async ({ att, attUrl, isVoice, localPath, audioPath }) => { + const asrReferText = typeof att.asr_refer_text === "string" ? att.asr_refer_text.trim() : ""; + const wavUrl = + isVoice && att.voice_wav_url + ? att.voice_wav_url.startsWith("//") + ? `https:${att.voice_wav_url}` + : att.voice_wav_url + : ""; + const voiceSourceUrl = wavUrl || attUrl; + + const meta = { + voiceUrl: isVoice && voiceSourceUrl ? voiceSourceUrl : undefined, + asrReferText: isVoice && asrReferText ? asrReferText : undefined, + }; + + if (localPath) { + if (att.content_type?.startsWith("image/")) { + log?.info(`${prefix} 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}`); + return processVoiceAttachment( + localPath, + audioPath, + att, + asrReferText, + cfg, + downloadDir, + log, + prefix, + ); + } else { + log?.info(`${prefix} Downloaded attachment to: ${localPath}`); + return { localPath, type: "other" as const, filename: att.filename, meta }; + } + } else { + log?.error(`${prefix} Failed to download: ${attUrl}`); + if (att.content_type?.startsWith("image/")) { + return { + localPath: null, + type: "image-fallback" as const, + attUrl, + contentType: att.content_type, + meta, + }; + } else if (isVoice && asrReferText) { + log?.info(`${prefix} Voice attachment download failed, using asr_refer_text fallback`); + return { + localPath: null, + type: "voice-fallback" as const, + transcript: asrReferText, + meta, + }; + } else { + return { + localPath: null, + type: "other-fallback" as const, + filename: att.filename ?? att.content_type, + meta, + }; + } + } + }, + ); + + const processResults = await Promise.all(processTasks); + + // Phase 3: collect results in the original attachment order. + for (const result of processResults) { + if (result.meta.voiceUrl) voiceAttachmentUrls.push(result.meta.voiceUrl); + if (result.meta.asrReferText) voiceAsrReferTexts.push(result.meta.asrReferText); + + if (result.type === "image" && result.localPath) { + imageUrls.push(result.localPath); + imageMediaTypes.push(result.contentType); + attachmentLocalPaths.push(result.localPath); + } else if (result.type === "voice" && result.localPath) { + voiceAttachmentPaths.push(result.localPath); + voiceTranscripts.push(result.transcript); + voiceTranscriptSources.push(result.transcriptSource); + attachmentLocalPaths.push(result.localPath); + } else if (result.type === "other" && result.localPath) { + otherAttachments.push(`[Attachment: ${result.localPath}]`); + attachmentLocalPaths.push(result.localPath); + } else if (result.type === "image-fallback") { + imageUrls.push(result.attUrl); + imageMediaTypes.push(result.contentType); + attachmentLocalPaths.push(null); + } else if (result.type === "voice-fallback") { + voiceTranscripts.push(result.transcript); + voiceTranscriptSources.push("asr"); + attachmentLocalPaths.push(null); + } else if (result.type === "other-fallback") { + otherAttachments.push(`[Attachment: ${result.filename}] (download failed)`); + attachmentLocalPaths.push(null); + } + } + + const attachmentInfo = otherAttachments.length > 0 ? "\n" + otherAttachments.join("\n") : ""; + + return { + attachmentInfo, + imageUrls, + imageMediaTypes, + voiceAttachmentPaths, + voiceAttachmentUrls, + voiceAsrReferTexts, + voiceTranscripts, + voiceTranscriptSources, + attachmentLocalPaths, + }; +} + +/** 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"); +} + +// Internal helpers. + +type VoiceResult = + | { + localPath: string; + type: "voice"; + transcript: string; + transcriptSource: TranscriptSource; + meta: { voiceUrl?: string; asrReferText?: string }; + } + | { + localPath: string; + type: "voice"; + transcript: string; + transcriptSource: TranscriptSource; + meta: { voiceUrl?: string; asrReferText?: string }; + }; + +async function processVoiceAttachment( + localPath: string, + audioPath: string | null, + att: RawAttachment, + asrReferText: string, + cfg: unknown, + downloadDir: string, + log: ProcessContext["log"], + prefix: string, +): Promise { + const wavUrl = att.voice_wav_url + ? att.voice_wav_url.startsWith("//") + ? `https:${att.voice_wav_url}` + : att.voice_wav_url + : ""; + const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url; + const voiceSourceUrl = wavUrl || attUrl; + const meta = { + voiceUrl: voiceSourceUrl || undefined, + asrReferText: asrReferText || undefined, + }; + + 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)`, + ); + return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta }; + } + log?.info( + `${prefix} Voice attachment: ${att.filename} (STT not configured, skipping transcription)`, + ); + return { + localPath, + type: "voice", + transcript: "[Voice message - transcription unavailable because STT is not configured]", + transcriptSource: "fallback", + meta, + }; + } + + // Convert SILK input to WAV before STT when necessary. + if (!audioPath) { + log?.info(`${prefix} Voice attachment: ${att.filename}, converting SILK→WAV...`); + try { + const wavResult = await convertSilkToWav(localPath, downloadDir); + if (wavResult) { + audioPath = wavResult.wavPath; + log?.info( + `${prefix} Voice converted: ${wavResult.wavPath} (${formatDuration(wavResult.duration)})`, + ); + } else { + audioPath = localPath; + } + } catch (convertErr) { + log?.error(`${prefix} Voice conversion failed: ${convertErr}`); + if (asrReferText) { + return { + localPath, + type: "voice", + transcript: asrReferText, + transcriptSource: "asr", + meta, + }; + } + return { + localPath, + type: "voice", + transcript: "[Voice message - format conversion failed]", + transcriptSource: "fallback", + meta, + }; + } + } + + // Run speech-to-text on the prepared audio file. + try { + const transcript = await transcribeAudio(audioPath!, cfg as Record); + if (transcript) { + log?.info(`${prefix} 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`); + return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta }; + } + log?.info(`${prefix} STT returned empty result`); + return { + localPath, + type: "voice", + transcript: "[Voice message - transcription returned an empty result]", + transcriptSource: "fallback", + meta, + }; + } catch (sttErr) { + log?.error(`${prefix} STT failed: ${sttErr}`); + if (asrReferText) { + return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta }; + } + return { + localPath, + type: "voice", + transcript: "[Voice message - transcription failed]", + transcriptSource: "fallback", + meta, + }; + } +} diff --git a/extensions/qqbot/src/known-users.ts b/extensions/qqbot/src/known-users.ts new file mode 100644 index 00000000000..d475d25f605 --- /dev/null +++ b/extensions/qqbot/src/known-users.ts @@ -0,0 +1,276 @@ +import fs from "node:fs"; +import path from "node:path"; +import { debugLog, debugError } from "./utils/debug-log.js"; + +/** Persisted record for a user who has interacted with the bot. */ +export interface KnownUser { + openid: string; + type: "c2c" | "group"; + nickname?: string; + groupOpenid?: string; + accountId: string; + firstSeenAt: number; + lastSeenAt: number; + 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 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); + } + + debugLog(`[known-users] Loaded ${usersCache.size} users`); + } + } catch (err) { + debugError(`[known-users] Failed to load users: ${err}`); + usersCache = new Map(); + } + + return usersCache; +} + +/** Schedule a throttled write to disk. */ +function saveUsersToFile(): void { + if (!isDirty) 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"); + isDirty = false; + } catch (err) { + debugError(`[known-users] Failed to save users: ${err}`); + } +} + +/** Flush pending writes immediately, typically during shutdown. */ +export function flushKnownUsers(): void { + if (saveTimer) { + clearTimeout(saveTimer); + saveTimer = null; + } + 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"; + nickname?: string; + groupOpenid?: string; + accountId: string; +}): void { + const cache = loadUsersFromFile(); + const key = makeUserKey(user); + const now = Date.now(); + + const existing = cache.get(key); + + if (existing) { + existing.lastSeenAt = now; + existing.interactionCount++; + if (user.nickname && user.nickname !== existing.nickname) { + existing.nickname = user.nickname; + } + } else { + const newUser: KnownUser = { + openid: user.openid, + type: user.type, + nickname: user.nickname, + groupOpenid: user.groupOpenid, + accountId: user.accountId, + firstSeenAt: now, + lastSeenAt: now, + interactionCount: 1, + }; + cache.set(key, newUser); + debugLog(`[known-users] New user: ${user.openid} (${user.type})`); + } + + isDirty = true; + saveUsersToFile(); +} + +/** Look up one known user. */ +export function getKnownUser( + accountId: string, + openid: string, + type: "c2c" | "group" = "c2c", + groupOpenid?: string, +): KnownUser | undefined { + const cache = loadUsersFromFile(); + const key = makeUserKey({ accountId, openid, type, groupOpenid }); + return cache.get(key); +} + +/** List known users with optional filtering and sorting. */ +export function listKnownUsers(options?: { + accountId?: string; + type?: "c2c" | "group"; + activeWithin?: number; + limit?: number; + sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount"; + sortOrder?: "asc" | "desc"; +}): KnownUser[] { + const cache = loadUsersFromFile(); + let users = Array.from(cache.values()); + + if (options?.accountId) { + users = users.filter((u) => u.accountId === options.accountId); + } + if (options?.type) { + users = users.filter((u) => u.type === options.type); + } + if (options?.activeWithin) { + 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; + }); + + if (options?.limit && options.limit > 0) { + users = users.slice(0, options.limit); + } + + return users; +} + +/** Return summary stats for known users. */ +export function getKnownUsersStats(accountId?: string): { + totalUsers: number; + c2cUsers: number; + groupUsers: number; + activeIn24h: number; + activeIn7d: number; +} { + let users = listKnownUsers({ accountId }); + + const now = Date.now(); + const day = 24 * 60 * 60 * 1000; + + return { + totalUsers: users.length, + c2cUsers: users.filter((u) => u.type === "c2c").length, + groupUsers: users.filter((u) => u.type === "group").length, + activeIn24h: users.filter((u) => now - u.lastSeenAt < day).length, + activeIn7d: users.filter((u) => now - u.lastSeenAt < 7 * day).length, + }; +} + +/** Remove one user record. */ +export function removeKnownUser( + accountId: string, + openid: string, + type: "c2c" | "group" = "c2c", + groupOpenid?: string, +): boolean { + const cache = loadUsersFromFile(); + const key = makeUserKey({ accountId, openid, type, groupOpenid }); + + if (cache.has(key)) { + cache.delete(key); + isDirty = true; + saveUsersToFile(); + debugLog(`[known-users] Removed user ${openid}`); + return true; + } + + return false; +} + +/** Clear all user records, optionally scoped to one account. */ +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) { + cache.delete(key); + count++; + } + } + } else { + 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 all recorded members for one group. */ +export function getGroupMembers(accountId: string, groupOpenid: string): KnownUser[] { + return listKnownUsers({ accountId, type: "group" }).filter((u) => u.groupOpenid === groupOpenid); +} diff --git a/extensions/qqbot/src/message-queue.ts b/extensions/qqbot/src/message-queue.ts new file mode 100644 index 00000000000..f3613134cf9 --- /dev/null +++ b/extensions/qqbot/src/message-queue.ts @@ -0,0 +1,193 @@ +import type { QueueSnapshot } from "./slash-commands.js"; + +// Message queue limits. +const MESSAGE_QUEUE_SIZE = 1000; +const PER_USER_QUEUE_SIZE = 20; +const MAX_CONCURRENT_USERS = 10; + +/** + * Queue item used for asynchronous message handling without blocking heartbeats. + */ +export interface QueuedMessage { + 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; + }>; + /** refIdx of the quoted message. */ + refMsgIdx?: string; + /** refIdx assigned to this message for future quoting. */ + msgIdx?: string; +} + +export interface MessageQueueContext { + accountId: string; + log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; + }; + /** Abort-state probe supplied by the caller. */ + isAborted: () => boolean; +} + +export interface MessageQueue { + enqueue: (msg: QueuedMessage) => void; + startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise) => void; + getSnapshot: (senderPeerId: string) => QueueSnapshot; + getMessagePeerId: (msg: QueuedMessage) => string; + /** Clear a user's queued messages and return how many were dropped. */ + clearUserQueue: (peerId: string) => number; + /** Execute one message immediately, bypassing the queue for urgent commands. */ + executeImmediate: (msg: QueuedMessage) => void; +} + +/** + * Create a per-user concurrent queue. + * Messages are serialized per user and processed in parallel across users. + */ +export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { + const { accountId, log } = ctx; + + const userQueues = new Map(); + const activeUsers = new Set(); + let messagesProcessed = 0; + let handleMessageFnRef: ((msg: QueuedMessage) => Promise) | null = null; + let totalEnqueued = 0; + + const getMessagePeerId = (msg: QueuedMessage): string => { + if (msg.type === "guild") return `guild:${msg.channelId ?? "unknown"}`; + if (msg.type === "group") return `group:${msg.groupOpenid ?? "unknown"}`; + return `dm:${msg.senderId}`; + }; + + const drainUserQueue = async (peerId: string): Promise => { + if (activeUsers.has(peerId)) return; + if (activeUsers.size >= MAX_CONCURRENT_USERS) { + log?.info( + `[qqbot:${accountId}] Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`, + ); + return; + } + + const queue = userQueues.get(peerId); + if (!queue || queue.length === 0) { + userQueues.delete(peerId); + return; + } + + activeUsers.add(peerId); + + try { + while (queue.length > 0 && !ctx.isAborted()) { + const msg = queue.shift()!; + totalEnqueued = Math.max(0, totalEnqueued - 1); + try { + if (handleMessageFnRef) { + await handleMessageFnRef(msg); + messagesProcessed++; + } + } catch (err) { + log?.error(`[qqbot:${accountId}] Message processor error for ${peerId}: ${err}`); + } + } + } finally { + activeUsers.delete(peerId); + userQueues.delete(peerId); + for (const [waitingPeerId, waitingQueue] of userQueues) { + if (activeUsers.size >= MAX_CONCURRENT_USERS) break; + if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) { + drainUserQueue(waitingPeerId); + } + } + } + }; + + const enqueue = (msg: QueuedMessage): void => { + const peerId = getMessagePeerId(msg); + let queue = userQueues.get(peerId); + if (!queue) { + queue = []; + userQueues.set(peerId, queue); + } + + 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}`, + ); + } + + totalEnqueued++; + if (totalEnqueued > MESSAGE_QUEUE_SIZE) { + log?.error( + `[qqbot:${accountId}] 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}`, + ); + + drainUserQueue(peerId); + }; + + const startProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise): void => { + handleMessageFnRef = handleMessageFn; + log?.info( + `[qqbot:${accountId}] Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`, + ); + }; + + const getSnapshot = (senderPeerId: string): QueueSnapshot => { + let totalPending = 0; + for (const [, q] of userQueues) { + totalPending += q.length; + } + const senderQueue = userQueues.get(senderPeerId); + return { + totalPending, + activeUsers: activeUsers.size, + maxConcurrentUsers: MAX_CONCURRENT_USERS, + senderPending: senderQueue ? senderQueue.length : 0, + }; + }; + + const clearUserQueue = (peerId: string): number => { + const queue = userQueues.get(peerId); + if (!queue || queue.length === 0) return 0; + const droppedCount = queue.length; + queue.length = 0; + totalEnqueued = Math.max(0, totalEnqueued - droppedCount); + return droppedCount; + }; + + const executeImmediate = (msg: QueuedMessage): void => { + if (handleMessageFnRef) { + handleMessageFnRef(msg).catch((err) => { + log?.error(`[qqbot:${accountId}] Immediate execution error: ${err}`); + }); + } + }; + + return { + enqueue, + startProcessor, + getSnapshot, + getMessagePeerId, + clearUserQueue, + executeImmediate, + }; +} diff --git a/extensions/qqbot/src/outbound-deliver.ts b/extensions/qqbot/src/outbound-deliver.ts new file mode 100644 index 00000000000..2c4c043765a --- /dev/null +++ b/extensions/qqbot/src/outbound-deliver.ts @@ -0,0 +1,754 @@ +/** + * Outbound delivery helpers. + * + * 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. + */ + +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"; + +// Type definitions. + +export interface DeliverEventContext { + type: "c2c" | "guild" | "dm" | "group"; + senderId: string; + messageId: string; + channelId?: string; + guildId?: string; + groupOpenid?: string; + msgIdx?: string; +} + +export interface DeliverAccountContext { + account: ResolvedQQBotAccount; + qualifiedTarget: string; + log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; + }; +} + +/** Wrapper that retries when the access token expires. */ +export type SendWithRetryFn = (sendFn: (token: string) => Promise) => Promise; + +/** Consume a quote ref exactly once. */ +export type ConsumeQuoteRefFn = () => string | undefined; + +// Media-tag parsing and delivery. + +/** + * Parse media tags from the reply text and send them in order. + * + * @returns `true` when media tags were found and handled; `false` when the caller + * should continue through the plain-text pipeline. + */ +export async function parseAndSendMediaTags( + replyText: string, + event: DeliverEventContext, + actx: DeliverAccountContext, + sendWithRetry: SendWithRetryFn, + consumeQuoteRef: ConsumeQuoteRefFn, +): 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 = + /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi; + const mediaTagMatches = [...text.matchAll(mediaTagRegex)]; + + if (mediaTagMatches.length === 0) { + return { handled: false, normalizedText: text }; + } + + const tagCounts = mediaTagMatches.reduce( + (acc, m) => { + const t = m[1]!.toLowerCase(); + acc[t] = (acc[t] ?? 0) + 1; + return acc; + }, + {} as Record, + ); + log?.info( + `${prefix} 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; + }; + const sendQueue: QueueItem[] = []; + + let lastIndex = 0; + const regex2 = + /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi; + let match; + + while ((match = regex2.exec(text)) !== null) { + const textBefore = text + .slice(lastIndex, match.index) + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (textBefore) { + sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) }); + } + + const tagName = match[1]!.toLowerCase(); + let mediaPath = decodeMediaPath(match[2]?.trim() ?? "", log, prefix); + + if (mediaPath) { + const typeMap: Record = { + qqmedia: "media", + qqvoice: "voice", + qqvideo: "video", + qqfile: "file", + }; + const itemType = typeMap[tagName] ?? "image"; + sendQueue.push({ type: itemType, content: mediaPath }); + log?.info(`${prefix} Found ${itemType} in <${tagName}>: ${mediaPath}`); + } + + lastIndex = match.index + match[0].length; + } + + const textAfter = text + .slice(lastIndex) + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (textAfter) { + sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) }); + } + + log?.info(`${prefix} Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`); + + // Send queue items in order. + const mediaTarget: MediaTargetContext = { + targetType: + event.type === "c2c" + ? "c2c" + : event.type === "group" + ? "group" + : event.type === "dm" + ? "dm" + : "channel", + targetId: + event.type === "c2c" + ? event.senderId + : event.type === "group" + ? event.groupOpenid! + : event.type === "dm" + ? event.guildId! + : event.channelId!, + account, + replyToId: event.messageId, + logPrefix: prefix, + }; + + for (const item of sendQueue) { + if (item.type === "text") { + await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef); + } else if (item.type === "image") { + const result = await sendPhoto(mediaTarget, item.content); + if (result.error) log?.error(`${prefix} sendPhoto error: ${result.error}`); + } else if (item.type === "voice") { + await sendVoiceWithTimeout(mediaTarget, item.content, account, log, prefix); + } else if (item.type === "video") { + const result = await sendVideoMsg(mediaTarget, item.content); + if (result.error) log?.error(`${prefix} sendVideoMsg error: ${result.error}`); + } else if (item.type === "file") { + const result = await sendDocument(mediaTarget, item.content); + if (result.error) log?.error(`${prefix} sendDocument error: ${result.error}`); + } else if (item.type === "media") { + const result = await sendMediaAuto({ + to: actx.qualifiedTarget, + text: "", + mediaUrl: item.content, + accountId: account.accountId, + replyToId: event.messageId, + account, + }); + if (result.error) log?.error(`${prefix} sendMedia(auto) error: ${result.error}`); + } + } + + return { handled: true, normalizedText: text }; +} + +// Unstructured reply delivery for plain text and images. + +export interface PlainReplyPayload { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string; +} + +/** + * Send a reply that does not contain structured media tags. + * Handles markdown image embeds, Base64 media, plain-text chunking, and local media routing. + */ +export async function sendPlainReply( + payload: PlainReplyPayload, + replyText: string, + event: DeliverEventContext, + actx: DeliverAccountContext, + sendWithRetry: SendWithRetryFn, + consumeQuoteRef: ConsumeQuoteRefFn, + toolMediaUrls: string[], +): Promise { + const { account, qualifiedTarget, log } = actx; + const prefix = `[qqbot:${account.accountId}]`; + + const collectedImageUrls: string[] = []; + const localMediaToSend: string[] = []; + + const collectImageUrl = (url: string | undefined | null): boolean => { + if (!url) return false; + const isHttpUrl = url.startsWith("http://") || url.startsWith("https://"); + const isDataUrl = url.startsWith("data:image/"); + 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) + "..."}`, + ); + } + return true; + } + if (isLocalFilePath(url)) { + if (!localMediaToSend.includes(url)) { + localMediaToSend.push(url); + log?.info(`${prefix} Collected local media for auto-routing: ${url}`); + } + return true; + } + return false; + }; + + if (payload.mediaUrls?.length) { + for (const url of payload.mediaUrls) collectImageUrl(url); + } + if (payload.mediaUrl) collectImageUrl(payload.mediaUrl); + + // Extract markdown images. + const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi; + const mdMatches = [...replyText.matchAll(mdImageRegex)]; + for (const m of mdMatches) { + const url = m[2]?.trim(); + 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)}...`); + } else if (isLocalFilePath(url)) { + if (!localMediaToSend.includes(url)) { + localMediaToSend.push(url); + log?.info(`${prefix} Collected local media from markdown for auto-routing: ${url}`); + } + } + } + } + + // Extract bare image URLs. + const bareUrlRegex = + /(?]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi; + const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)]; + for (const m of bareUrlMatches) { + const url = m[1]; + if (url && !collectedImageUrls.includes(url)) { + collectedImageUrls.push(url); + log?.info(`${prefix} Extracted bare image URL: ${url.slice(0, 80)}...`); + } + } + + const useMarkdown = account.markdownSupport === true; + log?.info(`${prefix} 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)) { + textWithoutImages = textWithoutImages.replace(m[0], "").trim(); + } + } + + if (useMarkdown) { + await sendMarkdownReply( + textWithoutImages, + collectedImageUrls, + mdMatches, + bareUrlMatches, + event, + actx, + sendWithRetry, + consumeQuoteRef, + ); + } else { + await sendPlainTextReply( + textWithoutImages, + collectedImageUrls, + mdMatches, + bareUrlMatches, + event, + actx, + sendWithRetry, + consumeQuoteRef, + ); + } + + // 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`, + ); + for (const mediaPath of localMediaToSend) { + try { + const result = await sendMediaAuto({ + to: qualifiedTarget, + text: "", + mediaUrl: mediaPath, + accountId: account.accountId, + replyToId: event.messageId, + account, + }); + if (result.error) + log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`); + else log?.info(`${prefix} Sent local media: ${mediaPath}`); + } catch (err) { + log?.error(`${prefix} sendMedia(auto) failed for ${mediaPath}: ${err}`); + } + } + } + + // 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`, + ); + for (const mediaUrl of toolMediaUrls) { + try { + const result = await sendMediaAuto({ + to: qualifiedTarget, + text: "", + mediaUrl, + accountId: account.accountId, + replyToId: event.messageId, + account, + }); + if (result.error) log?.error(`${prefix} Tool media forward error: ${result.error}`); + else log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`); + } catch (err) { + log?.error(`${prefix} Tool media forward failed: ${err}`); + } + } + toolMediaUrls.length = 0; + } +} + +// Internal helpers. + +/** 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: ${decodeErr}`); + } + + return mediaPath; +} + +/** Shared helper for sending chunked text replies. */ +async function sendTextChunks( + text: string, + event: DeliverEventContext, + actx: DeliverAccountContext, + sendWithRetry: SendWithRetryFn, + consumeQuoteRef: ConsumeQuoteRefFn, +): Promise { + const { account, log } = actx; + const prefix = `[qqbot:${account.accountId}]`; + const chunks = getQQBotRuntime().channel.text.chunkMarkdownText(text, TEXT_CHUNK_LIMIT); + for (const chunk of chunks) { + try { + await sendWithRetry(async (token) => { + const ref = consumeQuoteRef(); + if (event.type === "c2c") { + return await sendC2CMessage( + account.appId, + token, + event.senderId, + chunk, + event.messageId, + ref, + ); + } else if (event.type === "group" && event.groupOpenid) { + return await sendGroupMessage( + account.appId, + token, + event.groupOpenid, + chunk, + event.messageId, + ); + } else if (event.type === "dm" && event.guildId) { + return await sendDmMessage(token, event.guildId, chunk, event.messageId); + } else if (event.channelId) { + return await sendChannelMessage(token, event.channelId, chunk, event.messageId); + } + }); + log?.info( + `${prefix} Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`, + ); + } catch (err) { + log?.error(`${prefix} Failed to send text chunk: ${err}`); + } + } +} + +/** 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: ${err}`); + } +} + +/** Send in markdown mode. */ +async function sendMarkdownReply( + textWithoutImages: string, + imageUrls: string[], + mdMatches: RegExpMatchArray[], + bareUrlMatches: RegExpMatchArray[], + event: DeliverEventContext, + actx: DeliverAccountContext, + sendWithRetry: SendWithRetryFn, + consumeQuoteRef: ConsumeQuoteRefFn, +): Promise { + const { account, log } = actx; + const prefix = `[qqbot:${account.accountId}]`; + + // Split images into public URLs vs. Base64 payloads. + const httpImageUrls: string[] = []; + const base64ImageUrls: string[] = []; + for (const url of imageUrls) { + if (url.startsWith("data:image/")) base64ImageUrls.push(url); + else if (url.startsWith("http://") || url.startsWith("https://")) httpImageUrls.push(url); + } + log?.info( + `${prefix} Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`, + ); + + // Send Base64 images. + if (base64ImageUrls.length > 0) { + log?.info(`${prefix} 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)`, + ); + } catch (imgErr) { + log?.error(`${prefix} Failed to send Base64 image via Rich Media API: ${imgErr}`); + } + } + } + + // Handle public image URLs. + const existingMdUrls = new Set(mdMatches.map((m) => m[2])); + const imagesToAppend: string[] = []; + + for (const url of httpImageUrls) { + if (!existingMdUrls.has(url)) { + 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)}...`, + ); + } catch (err) { + log?.info(`${prefix} Failed to get image size, using default: ${err}`); + imagesToAppend.push(formatQQBotMarkdownImage(url, null)); + } + } + } + + // Backfill dimensions for existing markdown images. + let result = textWithoutImages; + for (const m of mdMatches) { + const fullMatch = m[0]; + const imgUrl = m[2]; + const isHttpUrl = imgUrl.startsWith("http://") || imgUrl.startsWith("https://"); + if (isHttpUrl && !hasQQBotImageSize(fullMatch)) { + 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)}...`, + ); + } catch (err) { + log?.info(`${prefix} Failed to get image size for existing md, using default: ${err}`); + result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, null)); + } + } + } + + // Remove bare image URLs from the text body. + for (const m of bareUrlMatches) { + result = result.replace(m[0], "").trim(); + } + + // Append markdown images. + if (imagesToAppend.length > 0) { + result = result.trim(); + result = result ? result + "\n\n" + imagesToAppend.join("\n") : imagesToAppend.join("\n"); + } + + // Send markdown text. + if (result.trim()) { + const mdChunks = chunkText(result, TEXT_CHUNK_LIMIT); + for (const chunk of mdChunks) { + try { + await sendWithRetry(async (token) => { + const ref = consumeQuoteRef(); + if (event.type === "c2c") { + return await sendC2CMessage( + account.appId, + token, + event.senderId, + chunk, + event.messageId, + ref, + ); + } else if (event.type === "group" && event.groupOpenid) { + return await sendGroupMessage( + account.appId, + token, + event.groupOpenid, + chunk, + event.messageId, + ); + } else if (event.type === "dm" && event.guildId) { + return await sendDmMessage(token, event.guildId, chunk, event.messageId); + } else if (event.channelId) { + return await sendChannelMessage(token, event.channelId, chunk, event.messageId); + } + }); + log?.info( + `${prefix} Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`, + ); + } catch (err) { + log?.error(`${prefix} Failed to send markdown message chunk: ${err}`); + } + } + } +} + +/** Send in plain-text mode. */ +async function sendPlainTextReply( + textWithoutImages: string, + imageUrls: string[], + mdMatches: RegExpMatchArray[], + bareUrlMatches: RegExpMatchArray[], + event: DeliverEventContext, + actx: DeliverAccountContext, + sendWithRetry: SendWithRetryFn, + consumeQuoteRef: ConsumeQuoteRefFn, +): Promise { + const { account, log } = actx; + const prefix = `[qqbot:${account.accountId}]`; + + const imgMediaTarget: MediaTargetContext = { + targetType: + event.type === "c2c" + ? "c2c" + : event.type === "group" + ? "group" + : event.type === "dm" + ? "dm" + : "channel", + targetId: + event.type === "c2c" + ? event.senderId + : event.type === "group" + ? event.groupOpenid! + : event.type === "dm" + ? event.guildId! + : event.channelId!, + account, + replyToId: event.messageId, + logPrefix: prefix, + }; + + let result = textWithoutImages; + for (const m of mdMatches) result = result.replace(m[0], "").trim(); + for (const m of bareUrlMatches) result = result.replace(m[0], "").trim(); + + // QQ group messages reject some dotted bare URLs, so filter them first. + if (result && event.type !== "c2c") { + result = result.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2"); + } + + try { + for (const imageUrl of imageUrls) { + try { + const imgResult = await sendPhoto(imgMediaTarget, imageUrl); + if (imgResult.error) log?.error(`${prefix} Failed to send image: ${imgResult.error}`); + else log?.info(`${prefix} Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`); + } catch (imgErr) { + log?.error(`${prefix} Failed to send image: ${imgErr}`); + } + } + + if (result.trim()) { + const plainChunks = chunkText(result, TEXT_CHUNK_LIMIT); + for (const chunk of plainChunks) { + await sendWithRetry(async (token) => { + const ref = consumeQuoteRef(); + if (event.type === "c2c") { + return await sendC2CMessage( + account.appId, + token, + event.senderId, + chunk, + event.messageId, + ref, + ); + } else if (event.type === "group" && event.groupOpenid) { + return await sendGroupMessage( + account.appId, + token, + event.groupOpenid, + chunk, + event.messageId, + ); + } else if (event.channelId) { + return await sendChannelMessage(token, event.channelId, chunk, event.messageId); + } + }); + log?.info( + `${prefix} Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`, + ); + } + } + } catch (err) { + log?.error(`${prefix} Send failed: ${err}`); + } +} diff --git a/extensions/qqbot/src/outbound.ts b/extensions/qqbot/src/outbound.ts new file mode 100644 index 00000000000..8998b28db51 --- /dev/null +++ b/extensions/qqbot/src/outbound.ts @@ -0,0 +1,1444 @@ +import * as path from "path"; +import { + getAccessToken, + sendC2CMessage, + sendChannelMessage, + sendDmMessage, + sendGroupMessage, + sendProactiveC2CMessage, + sendProactiveGroupMessage, + sendC2CImageMessage, + sendGroupImageMessage, + sendC2CVoiceMessage, + sendGroupVoiceMessage, + sendC2CVideoMessage, + sendGroupVideoMessage, + sendC2CFileMessage, + sendGroupFileMessage, +} from "./api.js"; +import type { ResolvedQQBotAccount } from "./types.js"; +import { + isAudioFile, + audioFileToSilkBase64, + waitForFile, + shouldTranscodeVoice, +} from "./utils/audio-convert.js"; +import { debugLog, debugError, debugWarn } from "./utils/debug-log.js"; +import { downloadFile } from "./utils/file-utils.js"; +import { + checkFileSize, + readFileAsync, + fileExistsAsync, + isLargeFile, + formatFileSize, +} from "./utils/file-utils.js"; +import { normalizeMediaTags } from "./utils/media-tags.js"; +import { decodeCronPayload } from "./utils/payload.js"; +import { + isLocalPath as isLocalFilePath, + normalizePath, + resolveQQBotLocalMediaPath, + sanitizeFileName, + getQQBotDataDir, + getQQBotMediaDir, +} from "./utils/platform.js"; + +// Limit passive replies per message_id within the QQ Bot reply window. +const MESSAGE_REPLY_LIMIT = 4; +const MESSAGE_REPLY_TTL = 60 * 60 * 1000; + +interface MessageReplyRecord { + count: number; + firstReplyAt: number; +} + +const messageReplyTracker = new Map(); + +/** Result of the passive-reply limit check. */ +export interface ReplyLimitResult { + allowed: boolean; + remaining: number; + shouldFallbackToProactive: boolean; + fallbackReason?: "expired" | "limit_exceeded"; + message?: string; +} + +/** 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, + }; +} + +/** 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++; + } + } + debugLog( + `[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`, + ); +} + +/** 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 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), + }; +} + +export interface OutboundContext { + to: string; + text: string; + accountId?: string | null; + replyToId?: string | null; + account: ResolvedQQBotAccount; +} + +export interface MediaOutboundContext extends OutboundContext { + mediaUrl: string; + mimeType?: string; +} + +export interface OutboundResult { + channel: string; + messageId?: string; + timestamp?: string | number; + error?: string; + refIdx?: string; +} + +/** Parse a qqbot target into a structured delivery target. */ +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 }; +} + +// Structured media send helpers shared by gateway delivery and sendText. + +/** Normalized target information for media sends. */ +export interface MediaTargetContext { + targetType: "c2c" | "group" | "channel" | "dm"; + targetId: string; + account: ResolvedQQBotAccount; + 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 { + 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 { + return account.config?.urlDirectUpload !== false; +} + +/** + * Send a photo from a local file, public URL, or Base64 data URL. + */ +export async function sendPhoto( + ctx: MediaTargetContext, + imagePath: string, +): Promise { + const prefix = ctx.logPrefix ?? "[qqbot]"; + const mediaPath = resolveQQBotLocalMediaPath(normalizePath(imagePath)); + const isLocal = isLocalFilePath(mediaPath); + const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://"); + const isData = mediaPath.startsWith("data:"); + + // 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"); + if (localFile) { + return await sendPhoto(ctx, localFile); + } + return { channel: "qqbot", error: `Failed to download image: ${mediaPath.slice(0, 80)}` }; + } + + let imageUrl = mediaPath; + + if (isLocal) { + if (!(await fileExistsAsync(mediaPath))) { + return { channel: "qqbot", error: "Image not found" }; + } + const sizeCheck = checkFileSize(mediaPath); + if (!sizeCheck.ok) { + return { channel: "qqbot", error: sizeCheck.error! }; + } + const fileBuffer = await readFileAsync(mediaPath); + const ext = path.extname(mediaPath).toLowerCase(); + 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) { + return { channel: "qqbot", error: `Unsupported image format: ${ext}` }; + } + imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`; + debugLog(`${prefix} 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; + + if (ctx.targetType === "c2c") { + const r = await sendC2CImageMessage( + ctx.account.appId, + token, + ctx.targetId, + imageUrl, + ctx.replyToId, + 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); + return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; + } + debugLog(`${prefix} sendPhoto: channel does not support local/Base64 images`); + return { channel: "qqbot", error: "Channel does not support local/Base64 images" }; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + + // 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...`, + ); + const retryResult = await downloadAndRetrySendPhoto(ctx, mediaPath, prefix); + if (retryResult) return retryResult; + } + + debugError(`${prefix} sendPhoto failed: ${msg}`); + return { channel: "qqbot", error: msg }; + } +} + +/** Download a remote image locally and retry `sendPhoto` through the local-file path. */ +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)}`); + return null; + } + + debugLog(`${prefix} sendPhoto fallback: downloaded → ${localFile}, retrying as Base64`); + return await sendPhoto(ctx, localFile); + } catch (err) { + debugError(`${prefix} sendPhoto fallback error:`, err); + return null; + } +} + +/** + * Send voice from either a local file or a public URL. + * + * URL handling respects `urlDirectUpload`, and local files are transcoded when needed. + */ +export async function sendVoice( + ctx: MediaTargetContext, + voicePath: string, + directUploadFormats?: string[], + transcodeEnabled: boolean = true, +): Promise { + const prefix = ctx.logPrefix ?? "[qqbot]"; + const mediaPath = resolveQQBotLocalMediaPath(normalizePath(voicePath)); + const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://"); + + 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, + ); + return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; + } else { + debugLog(`${prefix} sendVoice: voice not supported in channel`); + return { channel: "qqbot", error: "Voice not supported in channel" }; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + debugWarn( + `${prefix} sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`, + ); + } + } else { + debugLog(`${prefix} sendVoice: urlDirectUpload=false, downloading URL first...`); + } + + const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVoice"); + if (localFile) { + return await sendVoiceFromLocal( + ctx, + localFile, + directUploadFormats, + transcodeEnabled, + prefix, + ); + } + return { channel: "qqbot", error: `Failed to download audio: ${mediaPath.slice(0, 80)}` }; + } + + return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled, prefix); +} + +/** Send voice from a local file. */ +async function sendVoiceFromLocal( + ctx: MediaTargetContext, + 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); + if (fileSize === 0) { + return { channel: "qqbot", error: "Voice generate failed" }; + } + + const needsTranscode = shouldTranscodeVoice(mediaPath); + + if (needsTranscode && !transcodeEnabled) { + const ext = path.extname(mediaPath).toLowerCase(); + debugLog( + `${prefix} sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`, + ); + return { + channel: "qqbot", + error: `Voice transcoding is disabled and format ${ext} cannot be uploaded directly`, + }; + } + + try { + const silkBase64 = await audioFileToSilkBase64(mediaPath, directUploadFormats); + let uploadBase64 = silkBase64; + + if (!uploadBase64) { + const buf = await readFileAsync(mediaPath); + uploadBase64 = buf.toString("base64"); + debugLog( + `${prefix} sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`, + ); + } else { + debugLog(`${prefix} sendVoice: SILK ready (${fileSize} bytes)`); + } + + const token = await getToken(ctx.account); + + if (ctx.targetType === "c2c") { + const r = await sendC2CVoiceMessage( + ctx.account.appId, + token, + ctx.targetId, + uploadBase64, + undefined, + ctx.replyToId, + undefined, + mediaPath, + ); + 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, + ); + return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; + } else { + debugLog(`${prefix} sendVoice: voice not supported in channel`); + return { channel: "qqbot", error: "Voice not supported in channel" }; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + debugError(`${prefix} sendVoice (local) failed: ${msg}`); + return { channel: "qqbot", error: msg }; + } +} + +/** Send video from either a public URL or a local file. */ +export async function sendVideoMsg( + ctx: MediaTargetContext, + videoPath: string, +): Promise { + const prefix = ctx.logPrefix ?? "[qqbot]"; + const mediaPath = resolveQQBotLocalMediaPath(normalizePath(videoPath)); + 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"); + if (localFile) { + return await sendVideoFromLocal(ctx, localFile, prefix); + } + 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, + ); + return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; + } else { + debugLog(`${prefix} sendVideoMsg: video not supported in channel`); + return { channel: "qqbot", error: "Video not supported in channel" }; + } + } + + return await sendVideoFromLocal(ctx, mediaPath, prefix); + } catch (err) { + const msg = err instanceof Error ? err.message : String(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...`, + ); + const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg"); + if (localFile) { + return await sendVideoFromLocal(ctx, localFile, prefix); + } + } + + debugError(`${prefix} sendVideoMsg failed: ${msg}`); + return { channel: "qqbot", error: msg }; + } +} + +/** Send video from a local file. */ +async function sendVideoFromLocal( + ctx: MediaTargetContext, + mediaPath: string, + prefix: string, +): Promise { + if (!(await fileExistsAsync(mediaPath))) { + return { channel: "qqbot", error: "Video not found" }; + } + const sizeCheck = checkFileSize(mediaPath); + if (!sizeCheck.ok) { + return { channel: "qqbot", error: sizeCheck.error! }; + } + + const fileBuffer = await readFileAsync(mediaPath); + const videoBase64 = fileBuffer.toString("base64"); + debugLog(`${prefix} 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, + 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, + ); + return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; + } else { + debugLog(`${prefix} sendVideoMsg: video not supported in channel`); + return { channel: "qqbot", error: "Video not supported in channel" }; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + debugError(`${prefix} sendVideoMsg (local) failed: ${msg}`); + return { channel: "qqbot", error: msg }; + } +} + +/** Send a file from a local path or public URL. */ +export async function sendDocument( + ctx: MediaTargetContext, + filePath: string, +): Promise { + const prefix = ctx.logPrefix ?? "[qqbot]"; + const mediaPath = resolveQQBotLocalMediaPath(normalizePath(filePath)); + const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://"); + 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"); + if (localFile) { + return await sendDocumentFromLocal(ctx, localFile, prefix); + } + 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, + 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`); + return { channel: "qqbot", error: "File not supported in channel" }; + } + } + + return await sendDocumentFromLocal(ctx, mediaPath, prefix); + } catch (err) { + const msg = err instanceof Error ? err.message : String(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...`, + ); + const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument"); + if (localFile) { + return await sendDocumentFromLocal(ctx, localFile, prefix); + } + } + + debugError(`${prefix} sendDocument failed: ${msg}`); + return { channel: "qqbot", error: msg }; + } +} + +/** Send a file from local storage. */ +async function sendDocumentFromLocal( + ctx: MediaTargetContext, + mediaPath: string, + prefix: string, +): Promise { + const fileName = sanitizeFileName(path.basename(mediaPath)); + + if (!(await fileExistsAsync(mediaPath))) { + return { channel: "qqbot", error: "File not found" }; + } + const sizeCheck = checkFileSize(mediaPath); + if (!sizeCheck.ok) { + return { channel: "qqbot", error: sizeCheck.error! }; + } + const fileBuffer = await readFileAsync(mediaPath); + if (fileBuffer.length === 0) { + return { channel: "qqbot", error: `File is empty: ${mediaPath}` }; + } + const fileBase64 = fileBuffer.toString("base64"); + debugLog(`${prefix} 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, + fileBase64, + undefined, + 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, + ); + return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; + } else { + debugLog(`${prefix} sendDocument: file not supported in channel`); + return { channel: "qqbot", error: "File not supported in channel" }; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + debugError(`${prefix} 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 { + 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)}`); + return null; + } + debugLog(`${prefix} ${caller} fallback: downloaded → ${localFile}`); + return localFile; + } catch (err) { + debugError(`${prefix} ${caller} fallback download error:`, err); + return null; + } +} + +/** + * Send text, optionally falling back from passive reply mode to proactive mode. + * + * Also supports inline media tags such as `...`. + */ +export async function sendText(ctx: OutboundContext): Promise { + const { to, account } = ctx; + let { text, replyToId } = ctx; + let fallbackToProactive = false; + + debugLog( + "[qqbot] sendText ctx:", + JSON.stringify( + { to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, + null, + 2, + ), + ); + + if (replyToId) { + const limitCheck = checkMessageReplyLimit(replyToId); + + if (!limitCheck.allowed) { + if (limitCheck.shouldFallbackToProactive) { + debugWarn( + `[qqbot] sendText: passive reply unavailable, falling back to proactive send - ${limitCheck.message}`, + ); + fallbackToProactive = true; + replyToId = null; + } else { + debugError( + `[qqbot] sendText: passive reply was blocked without a fallback path - ${limitCheck.message}`, + ); + return { + channel: "qqbot", + error: limitCheck.message, + }; + } + } else { + debugLog( + `[qqbot] sendText: remaining passive replies for ${replyToId}: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`, + ); + } + } + + text = normalizeMediaTags(text); + + const mediaTagRegex = + /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi; + const mediaTagMatches = text.match(mediaTagRegex); + + if (mediaTagMatches && mediaTagMatches.length > 0) { + debugLog(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`); + + // Preserve the original text/media ordering when sending mixed content. + const sendQueue: Array<{ + type: "text" | "image" | "voice" | "video" | "file" | "media"; + content: string; + }> = []; + + let lastIndex = 0; + const mediaTagRegexWithIndex = + /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi; + let match; + + while ((match = mediaTagRegexWithIndex.exec(text)) !== null) { + const textBefore = text + .slice(lastIndex, match.index) + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (textBefore) { + sendQueue.push({ type: "text", content: textBefore }); + } + + const tagName = match[1]!.toLowerCase(); + + let mediaPath = match[2]?.trim() ?? ""; + if (mediaPath.startsWith("MEDIA:")) { + mediaPath = mediaPath.slice("MEDIA:".length); + } + mediaPath = normalizePath(mediaPath); + + // Fix paths that the model emitted with markdown-style escaping. + 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)) { + debugLog(`[qqbot] sendText: 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; + debugLog(`[qqbot] sendText: Successfully decoded path: ${mediaPath}`); + } + } + } catch (decodeErr) { + debugError(`[qqbot] sendText: Path decode error: ${decodeErr}`); + } + + if (mediaPath) { + if (tagName === "qqmedia") { + sendQueue.push({ type: "media", content: mediaPath }); + debugLog(`[qqbot] sendText: Found auto-detect media in : ${mediaPath}`); + } else if (tagName === "qqvoice") { + sendQueue.push({ type: "voice", content: mediaPath }); + debugLog(`[qqbot] sendText: Found voice path in : ${mediaPath}`); + } else if (tagName === "qqvideo") { + sendQueue.push({ type: "video", content: mediaPath }); + debugLog(`[qqbot] sendText: Found video URL in : ${mediaPath}`); + } else if (tagName === "qqfile") { + sendQueue.push({ type: "file", content: mediaPath }); + debugLog(`[qqbot] sendText: Found file path in : ${mediaPath}`); + } else { + sendQueue.push({ type: "image", content: mediaPath }); + debugLog(`[qqbot] sendText: Found image path in : ${mediaPath}`); + } + } + + lastIndex = match.index + match[0].length; + } + + const textAfter = text + .slice(lastIndex) + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (textAfter) { + sendQueue.push({ type: "text", content: textAfter }); + } + + debugLog(`[qqbot] sendText: Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`); + + // Send queue items in order. + const mediaTarget = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendText]"); + let lastResult: OutboundResult = { channel: "qqbot" }; + + for (const item of sendQueue) { + try { + if (item.type === "text") { + 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: (result as any).ext_info?.ref_idx, + }; + } + } 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: (result as any).ext_info?.ref_idx, + }; + } 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: (result as any).ext_info?.ref_idx, + }; + } else { + const result = await sendChannelMessage(accessToken, target.id, item.content); + lastResult = { + channel: "qqbot", + messageId: result.id, + timestamp: result.timestamp, + refIdx: (result as any).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); + } else if (item.type === "voice") { + lastResult = await sendVoice( + mediaTarget, + item.content, + undefined, + account.config?.audioFormatPolicy?.transcodeEnabled !== false, + ); + } else if (item.type === "video") { + lastResult = await sendVideoMsg(mediaTarget, item.content); + } else if (item.type === "file") { + lastResult = await sendDocument(mediaTarget, item.content); + } else if (item.type === "media") { + // Auto-route qqmedia based on the file extension. + lastResult = await sendMedia({ + to, + text: "", + mediaUrl: item.content, + accountId: account.accountId, + replyToId, + account, + }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + debugError(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`); + lastResult = { channel: "qqbot", error: errMsg }; + } + } + + return lastResult; + } + + if (!replyToId) { + if (!text || text.trim().length === 0) { + debugError("[qqbot] sendText error: proactive message content cannot be empty"); + return { + channel: "qqbot", + error: "Proactive messages require non-empty content (--message cannot be empty)", + }; + } + if (fallbackToProactive) { + debugLog( + `[qqbot] sendText: [fallback] sending proactive message to ${to}, length=${text.length}`, + ); + } else { + debugLog(`[qqbot] sendText: sending proactive message to ${to}, length=${text.length}`); + } + } + + if (!account.appId || !account.clientSecret) { + return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; + } + + try { + const accessToken = await getAccessToken(account.appId, account.clientSecret); + const target = parseTarget(to); + 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: (result as any).ext_info?.ref_idx, + }; + } 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: (result as any).ext_info?.ref_idx, + }; + } else { + const result = await sendChannelMessage(accessToken, target.id, text); + outResult = { + channel: "qqbot", + messageId: result.id, + timestamp: result.timestamp, + refIdx: (result as any).ext_info?.ref_idx, + }; + } + 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: (result as any).ext_info?.ref_idx, + }; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(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: (result as any).ext_info?.ref_idx, + }; + } 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: (result as any).ext_info?.ref_idx, + }; + } 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: (result as any).ext_info?.ref_idx, + }; + } + return outResult; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(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; + const mediaUrl = resolveQQBotLocalMediaPath(normalizePath(ctx.mediaUrl)); + + if (!account.appId || !account.clientSecret) { + return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; + } + if (!mediaUrl) { + return { channel: "qqbot", error: "mediaUrl is required for sendMedia" }; + } + + const target = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendMedia]"); + + // Dispatch by type, preferring MIME and falling back to the file extension. + // Individual send* helpers already handle direct URL upload vs. download fallback. + if (isAudioFile(mediaUrl, mimeType)) { + const formats = + account.config?.audioFormatPolicy?.uploadDirectFormats ?? + account.config?.voiceDirectUploadFormats; + const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false; + const result = await sendVoice(target, mediaUrl, formats, transcodeEnabled); + if (!result.error) { + if (text?.trim()) await sendTextAfterMedia(target, text); + return result; + } + // Preserve the voice error and fall back to file send. + const voiceError = result.error; + debugWarn(`[qqbot] sendMedia: sendVoice failed (${voiceError}), falling back to sendDocument`); + const fallback = await sendDocument(target, mediaUrl); + if (!fallback.error) { + if (text?.trim()) await sendTextAfterMedia(target, text); + return fallback; + } + return { channel: "qqbot", error: `voice: ${voiceError} | fallback file: ${fallback.error}` }; + } + + if (isVideoFile(mediaUrl, mimeType)) { + const result = await sendVideoMsg(target, mediaUrl); + if (!result.error && text?.trim()) await sendTextAfterMedia(target, text); + return result; + } + + // Non-image, non-audio, and non-video media fall back to file send. + if ( + !isImageFile(mediaUrl, mimeType) && + !isAudioFile(mediaUrl, mimeType) && + !isVideoFile(mediaUrl, mimeType) + ) { + const result = await sendDocument(target, mediaUrl); + if (!result.error && text?.trim()) await sendTextAfterMedia(target, text); + return result; + } + + // Default to image handling. sendPhoto already contains URL fallback logic. + const result = await sendPhoto(target, mediaUrl); + if (!result.error && text?.trim()) await sendTextAfterMedia(target, text); + return result; +} + +/** Send text after media when the transport supports a follow-up text message. */ +async function sendTextAfterMedia(ctx: MediaTargetContext, text: string): 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); + } + } catch (err) { + debugError(`[qqbot] sendTextAfterMedia failed: ${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 path.extname(cleanPath).toLowerCase(); +} + +/** 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 message emitted by an OpenClaw cron task. + * + * Cron output may be either: + * 1. A `QQBOT_CRON:{base64}` structured payload that includes target metadata. + * 2. Plain text that should be sent directly to the provided fallback target. + * + * @param account Resolved account configuration. + * @param to Fallback target address when the payload does not include one. + * @param message Message content, either `QQBOT_CRON:` payload or plain text. + * @returns Send result. + * + * @example + * ```typescript + * // Structured payload + * const result = await sendCronMessage( + * account, + * "user_openid", + * "QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs..." + * ); + * + * // Plain text + * const result = await sendCronMessage(account, "user_openid", "This is a plain reminder message."); + * ``` + */ +export async function sendCronMessage( + account: ResolvedQQBotAccount, + to: string, + message: string, +): Promise { + const timestamp = new Date().toISOString(); + debugLog(`[${timestamp}] [qqbot] sendCronMessage: to=${to}, message length=${message.length}`); + + // Detect `QQBOT_CRON:` structured payloads first. + const cronResult = decodeCronPayload(message); + + if (cronResult.isCronPayload) { + if (cronResult.error) { + debugError( + `[${timestamp}] [qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`, + ); + return { + channel: "qqbot", + error: `Failed to decode cron payload: ${cronResult.error}`, + }; + } + + if (cronResult.payload) { + const payload = cronResult.payload; + debugLog( + `[${timestamp}] [qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}, content length=${payload.content.length}`, + ); + + // Prefer the target encoded in the structured payload. + const targetTo = + payload.targetType === "group" ? `group:${payload.targetAddress}` : payload.targetAddress; + + debugLog( + `[${timestamp}] [qqbot] sendCronMessage: sending proactive message to targetTo=${targetTo}`, + ); + + // Send the reminder content. + const result = await sendProactiveMessage(account, targetTo, payload.content); + + if (result.error) { + debugError( + `[${timestamp}] [qqbot] sendCronMessage: proactive message failed, error=${result.error}`, + ); + } else { + debugLog(`[${timestamp}] [qqbot] sendCronMessage: proactive message sent successfully`); + } + + return result; + } + } + + // 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); +} diff --git a/extensions/qqbot/src/proactive.ts b/extensions/qqbot/src/proactive.ts new file mode 100644 index 00000000000..fda9affeace --- /dev/null +++ b/extensions/qqbot/src/proactive.ts @@ -0,0 +1,324 @@ +/** + * 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 { ResolvedQQBotAccount } from "./types.js"; +import { debugLog, debugError } from "./utils/debug-log.js"; + +// Re-export known-user types and functions from the canonical module. +export type { KnownUser } from "./known-users.js"; +export { + recordKnownUser, + listKnownUsers as listKnownUsersFromStore, + getKnownUser as getKnownUserFromStore, + removeKnownUser as removeKnownUserFromStore, + clearKnownUsers as clearKnownUsersFromStore, + flushKnownUsers, +} from "./known-users.js"; +import { + listKnownUsers as listKnownUsersImpl, + removeKnownUser as removeKnownUserImpl, + clearKnownUsers as clearKnownUsersImpl, + getKnownUser as getKnownUserImpl, +} 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; +} +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + getAccessToken, + sendProactiveC2CMessage, + sendProactiveGroupMessage, + sendChannelMessage, + sendC2CImageMessage, + sendGroupImageMessage, +} from "./api.js"; +import { resolveQQBotAccount } from "./config.js"; + +/** 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 as "c2c" | "group" | undefined), + 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 = "default" } = 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: ${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: ${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 = err instanceof Error ? err.message : String(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 = "default", +): 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 as "c2c" | "group", + 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: err instanceof Error ? err.message : String(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/ref-index-store.ts b/extensions/qqbot/src/ref-index-store.ts new file mode 100644 index 00000000000..a61f9776d8e --- /dev/null +++ b/extensions/qqbot/src/ref-index-store.ts @@ -0,0 +1,293 @@ +import fs from "node:fs"; +import path from "node:path"; +import { debugLog, debugError } from "./utils/debug-log.js"; +import { getQQBotDataDir } from "./utils/platform.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; +} + +const STORAGE_DIR = getQQBotDataDir("data"); +const REF_INDEX_FILE = path.join(STORAGE_DIR, "ref-index.jsonl"); +const MAX_ENTRIES = 50000; +const TTL_MS = 7 * 24 * 60 * 60 * 1000; +const COMPACT_THRESHOLD_RATIO = 2; + +interface RefIndexLine { + k: string; + v: RefIndexEntry; + t: number; +} + +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; + + try { + 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(); + let expired = 0; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) 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, + }); + } 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: ${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: ${err}`); + } +} + +function ensureDir(): void { + if (!fs.existsSync(STORAGE_DIR)) { + fs.mkdirSync(STORAGE_DIR, { recursive: true }); + } +} + +function shouldCompact(): boolean { + if (!cache) return false; + return 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)); + } + + 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}`); + } +} + +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()].sort((a, b) => a[1]._createdAt - b[1]._createdAt); + const toRemove = sorted.slice(0, cache.size - MAX_ENTRIES + 1000); + for (const [key] of toRemove) { + cache.delete(key); + } + debugLog(`[ref-index-store] Evicted ${toRemove.length} oldest entries`); + } +} + +/** Persist a refIdx mapping for one message. */ +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, + }); + + appendLine({ + k: refIdx, + v: { + content: entry.content, + senderId: entry.senderId, + senderName: entry.senderName, + timestamp: entry.timestamp, + isBot: entry.isBot, + attachments: entry.attachments, + }, + t: now, + }); + + if (shouldCompact()) { + compactFile(); + } +} + +/** Look up one quoted message by refIdx. */ +export function getRefIndex(refIdx: string): RefIndexEntry | null { + const store = loadFromFile(); + const entry = store.get(refIdx); + if (!entry) return null; + + if (Date.now() - entry._createdAt > TTL_MS) { + store.delete(refIdx); + return null; + } + + return { + content: entry.content, + senderId: entry.senderId, + senderName: entry.senderName, + timestamp: entry.timestamp, + isBot: entry.isBot, + attachments: entry.attachments, + }; +} + +/** 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()) { + compactFile(); + } +} + +/** Return ref-index stats for diagnostics. */ +export function getRefIndexStats(): { + size: number; + maxEntries: number; + totalLinesOnDisk: number; + filePath: string; +} { + const store = loadFromFile(); + return { + size: store.size, + maxEntries: MAX_ENTRIES, + totalLinesOnDisk, + filePath: REF_INDEX_FILE, + }; +} diff --git a/extensions/qqbot/src/reply-dispatcher.ts b/extensions/qqbot/src/reply-dispatcher.ts new file mode 100644 index 00000000000..cec67a4fcec --- /dev/null +++ b/extensions/qqbot/src/reply-dispatcher.ts @@ -0,0 +1,587 @@ +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { textToSpeech as globalTextToSpeech } from "openclaw/plugin-sdk/speech-runtime"; +import { + getAccessToken, + sendC2CMessage, + sendChannelMessage, + sendDmMessage, + sendGroupMessage, + clearTokenCache, + sendC2CImageMessage, + sendGroupImageMessage, + sendC2CVoiceMessage, + sendGroupVoiceMessage, + sendC2CVideoMessage, + sendGroupVideoMessage, + sendC2CFileMessage, + sendGroupFileMessage, +} from "./api.js"; +import type { ResolvedQQBotAccount } from "./types.js"; +import { + isGlobalTTSAvailable, + resolveTTSConfig, + textToSilk, + audioFileToSilkBase64, + formatDuration, +} from "./utils/audio-convert.js"; +import { + checkFileSize, + readFileAsync, + fileExistsAsync, + formatFileSize, +} from "./utils/file-utils.js"; +import { + parseQQBotPayload, + encodePayloadForCron, + isCronReminderPayload, + isMediaPayload, + type MediaPayload, +} from "./utils/payload.js"; +import { + getQQBotDataDir, + normalizePath, + resolveQQBotLocalMediaPath, + 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: ${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 { target, account, cfg, 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; + 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}`); + } + 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: ${(parsedPayload as MediaPayload).mediaType}`, + ); + } + recordActivity(); + return true; + } + + log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${(parsedPayload as any).type}`); + return true; +} + +// Media payload handlers. + +async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Promise { + const { target, account, log } = ctx; + let imageUrl = resolveQQBotLocalMediaPath(normalizePath(payload.path)); + const originalImagePath = payload.source === "file" ? imageUrl : undefined; + + if (payload.source === "file") { + try { + if (!(await fileExistsAsync(imageUrl))) { + log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`); + return; + } + const imgSzCheck = checkFileSize(imageUrl); + if (!imgSzCheck.ok) { + log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`); + return; + } + const fileBuffer = await readFileAsync(imageUrl); + const base64Data = fileBuffer.toString("base64"); + const ext = path.extname(imageUrl).toLowerCase(); + 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}`); + 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}`); + } +} + +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 globalTextToSpeech({ + 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}`); + } +} + +async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise { + const { target, account, log } = ctx; + try { + const videoPath = resolveQQBotLocalMediaPath(normalizePath(payload.path ?? "")); + if (!videoPath?.trim()) { + log?.error(`[qqbot:${account.accountId}] Video missing path`); + } else { + const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://"); + log?.info(`[qqbot:${account.accountId}] Video send: "${videoPath.slice(0, 60)}..."`); + + 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 if (target.type === "dm") { + log?.error(`[qqbot:${account.accountId}] Video not supported in DM`); + } else if (target.channelId) { + log?.error(`[qqbot:${account.accountId}] Video not supported in channel`); + } + } else { + if (!(await fileExistsAsync(videoPath))) { + throw new Error(`Video file does not exist: ${videoPath}`); + } + const vPaySzCheck = checkFileSize(videoPath); + if (!vPaySzCheck.ok) { + throw new Error(vPaySzCheck.error!); + } + const fileBuffer = await readFileAsync(videoPath); + const videoBase64 = fileBuffer.toString("base64"); + log?.info( + `[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`, + ); + + 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 if (target.type === "dm") { + log?.error(`[qqbot:${account.accountId}] Video not supported in DM`); + } else if (target.channelId) { + log?.error(`[qqbot:${account.accountId}] Video not supported in channel`); + } + } + }, + log, + account.accountId, + ); + log?.info(`[qqbot:${account.accountId}] Video message sent`); + + if (payload.caption) { + await sendTextToTarget(ctx, payload.caption); + } + } + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`); + } +} + +async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise { + const { target, account, log } = ctx; + try { + const filePath = resolveQQBotLocalMediaPath(normalizePath(payload.path ?? "")); + if (!filePath?.trim()) { + log?.error(`[qqbot:${account.accountId}] File missing path`); + } else { + const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://"); + const fileName = sanitizeFileName(path.basename(filePath)); + log?.info( + `[qqbot:${account.accountId}] File send: "${filePath.slice(0, 60)}..." (${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 if (target.type === "dm") { + log?.error(`[qqbot:${account.accountId}] File not supported in DM`); + } else if (target.channelId) { + log?.error(`[qqbot:${account.accountId}] File not supported in channel`); + } + } else { + if (!(await fileExistsAsync(filePath))) { + throw new Error(`File does not exist: ${filePath}`); + } + const fPaySzCheck = checkFileSize(filePath); + if (!fPaySzCheck.ok) { + throw new Error(fPaySzCheck.error!); + } + const fileBuffer = await readFileAsync(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 if (target.type === "dm") { + log?.error(`[qqbot:${account.accountId}] File not supported in DM`); + } else if (target.channelId) { + log?.error(`[qqbot:${account.accountId}] File not supported in channel`); + } + } + }, + log, + account.accountId, + ); + log?.info(`[qqbot:${account.accountId}] File message sent`); + } + } catch (err) { + log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`); + } +} diff --git a/extensions/qqbot/src/runtime.ts b/extensions/qqbot/src/runtime.ts new file mode 100644 index 00000000000..9852a8ffc40 --- /dev/null +++ b/extensions/qqbot/src/runtime.ts @@ -0,0 +1,6 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; + +const { setRuntime: setQQBotRuntime, getRuntime: getQQBotRuntime } = + createPluginRuntimeStore("QQBot runtime not initialized"); +export { getQQBotRuntime, setQQBotRuntime }; diff --git a/extensions/qqbot/src/session-store.ts b/extensions/qqbot/src/session-store.ts new file mode 100644 index 00000000000..a169277ea24 --- /dev/null +++ b/extensions/qqbot/src/session-store.ts @@ -0,0 +1,258 @@ +import fs from "node:fs"; +import path from "node:path"; +import { debugLog, debugError } from "./utils/debug-log.js"; + +/** Persisted gateway session state. */ +export interface SessionState { + sessionId: string | null; + lastSeq: number | null; + lastConnectedAt: number; + intentLevelIndex: number; + accountId: string; + savedAt: number; + 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, + { + pendingState: SessionState | null; + lastSaveTime: number; + throttleTimer: ReturnType | null; + } +>(); + +/** Ensure the session directory exists. */ +function ensureDir(): void { + if (!fs.existsSync(SESSION_DIR)) { + fs.mkdirSync(SESSION_DIR, { recursive: true }); + } +} + +/** Return the session file path for one account. */ +function getSessionPath(accountId: string): string { + const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_"); + return path.join(SESSION_DIR, `session-${safeId}.json`); +} + +/** Load a saved session, rejecting expired or mismatched appId entries. */ +export function loadSession(accountId: string, expectedAppId?: string): SessionState | null { + const filePath = getSessionPath(accountId); + + try { + if (!fs.existsSync(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`, + ); + try { + fs.unlinkSync(filePath); + } catch {} + return null; + } + + if (expectedAppId && state.appId && state.appId !== expectedAppId) { + debugLog( + `[session-store] appId mismatch for ${accountId}: saved=${state.appId}, current=${expectedAppId}. Discarding stale session.`, + ); + try { + fs.unlinkSync(filePath); + } catch {} + return null; + } + + if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) { + debugLog(`[session-store] Invalid session data for ${accountId}`); + return null; + } + + debugLog( + `[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, appId=${state.appId ?? "unknown"}, age=${Math.round((now - state.savedAt) / 1000)}s`, + ); + return state; + } catch (err) { + debugError(`[session-store] Failed to load session for ${accountId}: ${err}`); + return null; + } +} + +/** 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, + }; + throttleState.set(accountId, throttle); + } + + const now = Date.now(); + const timeSinceLastSave = now - throttle.lastSaveTime; + + if (timeSinceLastSave >= SAVE_THROTTLE_MS) { + 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) { + doSaveSession(t.pendingState); + t.lastSaveTime = Date.now(); + t.pendingState = null; + } + if (t) { + t.throttleTimer = null; + } + }, delay); + } + } +} + +/** Write one session file to disk immediately. */ +function doSaveSession(state: SessionState): void { + const filePath = getSessionPath(state.accountId); + + try { + ensureDir(); + + const stateToSave: SessionState = { + ...state, + savedAt: Date.now(), + }; + + fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8"); + debugLog( + `[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}: ${err}`); + } +} + +/** Clear a saved session and any pending throttle state. */ +export function clearSession(accountId: string): void { + const filePath = getSessionPath(accountId); + + const throttle = throttleState.get(accountId); + if (throttle) { + if (throttle.throttleTimer) { + clearTimeout(throttle.throttleTimer); + } + throttleState.delete(accountId); + } + + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + debugLog(`[session-store] Cleared session for ${accountId}`); + } + } catch (err) { + debugError(`[session-store] Failed to clear session for ${accountId}: ${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, + }); + } +} + +/** Load all saved sessions from disk. */ +export function getAllSessions(): SessionState[] { + const sessions: SessionState[] = []; + + try { + ensureDir(); + const files = fs.readdirSync(SESSION_DIR); + + for (const file of files) { + if (file.startsWith("session-") && file.endsWith(".json")) { + const filePath = path.join(SESSION_DIR, file); + try { + const data = fs.readFileSync(filePath, "utf-8"); + const state = JSON.parse(data) as SessionState; + sessions.push(state); + } catch { + // Ignore malformed session files here. + } + } + } + } catch { + // Ignore missing directories and similar filesystem errors. + } + + return sessions; +} + +/** + * 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(); + + for (const file of files) { + if (file.startsWith("session-") && file.endsWith(".json")) { + const filePath = path.join(SESSION_DIR, file); + try { + const data = fs.readFileSync(filePath, "utf-8"); + const state = JSON.parse(data) as SessionState; + + if (now - state.savedAt > SESSION_EXPIRE_TIME) { + fs.unlinkSync(filePath); + cleaned++; + 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 { + // Ignore missing directories and similar filesystem errors. + } + + return cleaned; +} diff --git a/extensions/qqbot/src/setup-surface.ts b/extensions/qqbot/src/setup-surface.ts new file mode 100644 index 00000000000..51496896c7e --- /dev/null +++ b/extensions/qqbot/src/setup-surface.ts @@ -0,0 +1,160 @@ +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 { + DEFAULT_ACCOUNT_ID, + listQQBotAccountIds, + resolveQQBotAccount, + applyQQBotAccountConfig, +} from "./config.js"; + +const channel = "qqbot" as const; + +type QQBotEnvCredentialField = "appId" | "clientSecret"; + +/** + * 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) || {}) }; + + 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>) || {}) }; + 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 }) => + listQQBotAccountIds(cfg).some((accountId) => { + const account = resolveQQBotAccount(cfg, accountId, { 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 resolved = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }); + const hasConfiguredValue = Boolean( + hasConfiguredSecretInput(resolved.config.clientSecret) || + resolved.config.clientSecretFile?.trim() || + resolved.clientSecret, + ); + return { + accountConfigured: Boolean(resolved.appId && hasConfiguredValue), + hasConfiguredValue: Boolean(resolved.appId), + resolvedValue: resolved.appId || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.QQBOT_APP_ID?.trim() || undefined + : 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 resolved = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }); + const hasConfiguredValue = Boolean( + hasConfiguredSecretInput(resolved.config.clientSecret) || + resolved.config.clientSecretFile?.trim() || + resolved.clientSecret, + ); + return { + accountConfigured: Boolean(resolved.appId && hasConfiguredValue), + hasConfiguredValue, + resolvedValue: resolved.clientSecret || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.QQBOT_CLIENT_SECRET?.trim() || undefined + : 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 new file mode 100644 index 00000000000..45949f7f0e2 --- /dev/null +++ b/extensions/qqbot/src/setup.test.ts @@ -0,0 +1,99 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { describe, expect, it } from "vitest"; +import { qqbotSetupPlugin } from "./channel.setup.js"; +import { DEFAULT_ACCOUNT_ID } from "./config.js"; +import { qqbotSetupWizard } from "./setup-surface.js"; + +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("marks unresolved SecretRef accounts as configured in setup-only plugin status", () => { + const cfg = { + channels: { + qqbot: { + appId: "123456", + clientSecret: { + source: "env", + provider: "default", + id: "QQBOT_CLIENT_SECRET", + }, + }, + }, + } as OpenClawConfig; + + const account = qqbotSetupPlugin.config.resolveAccount?.(cfg, DEFAULT_ACCOUNT_ID); + + expect(account?.clientSecret).toBe(""); + expect(qqbotSetupPlugin.config.isConfigured?.(account!, cfg)).toBe(true); + expect(qqbotSetupPlugin.config.describeAccount?.(account!, cfg)?.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"); + }); +}); diff --git a/extensions/qqbot/src/slash-commands.test.ts b/extensions/qqbot/src/slash-commands.test.ts new file mode 100644 index 00000000000..1fbac9cf61a --- /dev/null +++ b/extensions/qqbot/src/slash-commands.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { 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, + }; +} + +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 ---- +}); diff --git a/extensions/qqbot/src/slash-commands.ts b/extensions/qqbot/src/slash-commands.ts new file mode 100644 index 00000000000..8b4ffaf2642 --- /dev/null +++ b/extensions/qqbot/src/slash-commands.ts @@ -0,0 +1,578 @@ +/** + * 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 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. +let PLUGIN_VERSION = "unknown"; +try { + const pkg = require("../package.json"); + PLUGIN_VERSION = pkg.version ?? "unknown"; +} catch { + // fallback +} + +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; +} + +// ============ 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(cmd.name.toLowerCase(), cmd); + } else { + commands.set(cmd.name.toLowerCase(), 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: () => 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 = (spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx)).toLowerCase(); + 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/stt.ts b/extensions/qqbot/src/stt.ts new file mode 100644 index 00000000000..e3502a51fc9 --- /dev/null +++ b/extensions/qqbot/src/stt.ts @@ -0,0 +1,83 @@ +/** + * OpenAI-compatible STT used at the plugin layer. + * + * This avoids pushing raw WAV PCM into the framework media-understanding pipeline. + */ + +import * as fs from "node:fs"; +import path from "node:path"; +import { sanitizeFileName } from "./utils/platform.js"; + +export interface STTConfig { + baseUrl: string; + apiKey: string; + model: string; +} + +export function resolveSTTConfig(cfg: Record): STTConfig | null { + const c = cfg as any; + + // Prefer plugin-specific STT config. + const channelStt = c?.channels?.qqbot?.stt; + if (channelStt && channelStt.enabled !== false) { + const providerId: string = channelStt?.provider || "openai"; + const providerCfg = c?.models?.providers?.[providerId]; + const baseUrl: string | undefined = channelStt?.baseUrl || providerCfg?.baseUrl; + const apiKey: string | undefined = channelStt?.apiKey || providerCfg?.apiKey; + const model: string = channelStt?.model || "whisper-1"; + if (baseUrl && apiKey) { + return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model }; + } + } + + // Fall back to framework-level audio model config. + const audioModelEntry = c?.tools?.media?.audio?.models?.[0]; + if (audioModelEntry) { + const providerId: string = audioModelEntry?.provider || "openai"; + const providerCfg = c?.models?.providers?.[providerId]; + const baseUrl: string | undefined = audioModelEntry?.baseUrl || providerCfg?.baseUrl; + const apiKey: string | undefined = audioModelEntry?.apiKey || providerCfg?.apiKey; + const model: string = audioModelEntry?.model || "whisper-1"; + if (baseUrl && apiKey) { + return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model }; + } + } + + return null; +} + +export async function transcribeAudio( + audioPath: string, + cfg: Record, +): Promise { + const sttCfg = resolveSTTConfig(cfg); + if (!sttCfg) return null; + + const fileBuffer = fs.readFileSync(audioPath); + const fileName = sanitizeFileName(path.basename(audioPath)); + const mime = fileName.endsWith(".wav") + ? "audio/wav" + : fileName.endsWith(".mp3") + ? "audio/mpeg" + : fileName.endsWith(".ogg") + ? "audio/ogg" + : "application/octet-stream"; + + const form = new FormData(); + form.append("file", new Blob([fileBuffer], { type: mime }), fileName); + form.append("model", sttCfg.model); + + const resp = await fetch(`${sttCfg.baseUrl}/audio/transcriptions`, { + method: "POST", + headers: { Authorization: `Bearer ${sttCfg.apiKey}` }, + body: form, + }); + + if (!resp.ok) { + const detail = await resp.text().catch(() => ""); + throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`); + } + + const result = (await resp.json()) as { text?: string }; + return result.text?.trim() || null; +} diff --git a/extensions/qqbot/src/text-utils.ts b/extensions/qqbot/src/text-utils.ts new file mode 100644 index 00000000000..e05e110eb15 --- /dev/null +++ b/extensions/qqbot/src/text-utils.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000000..7aebf0c611f --- /dev/null +++ b/extensions/qqbot/src/tools/channel.ts @@ -0,0 +1,249 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { getAccessToken } from "../api.js"; +import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js"; +import { debugError, debugLog } from "../utils/debug-log.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 json(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +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; + 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: p.path, + }); + } + debugError("[qqbot-channel-api] <<< Network error:", err); + return json({ + error: `Network error: ${err instanceof Error ? err.message : String(err)}`, + path: p.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: 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, + }); + } catch (err) { + return json({ + error: err instanceof Error ? err.message : String(err), + path: p.path, + }); + } + }, + }, + { name: "qqbot_channel_api" }, + ); +} diff --git a/extensions/qqbot/src/tools/remind.ts b/extensions/qqbot/src/tools/remind.ts new file mode 100644 index 00000000000..b393b8d944e --- /dev/null +++ b/extensions/qqbot/src/tools/remind.ts @@ -0,0 +1,251 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; + +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 json(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +function parseRelativeTime(timeStr: string): number | null { + const s = timeStr.trim().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; +} + +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 new file mode 100644 index 00000000000..232456f11d9 --- /dev/null +++ b/extensions/qqbot/src/types.ts @@ -0,0 +1,151 @@ +import type { SecretInput } from "openclaw/plugin-sdk/secret-input"; + +/** QQ Bot base config. */ +export interface QQBotConfig { + appId: string; + clientSecret?: SecretInput; + clientSecretFile?: string; +} + +/** Resolved QQ Bot account config used at runtime. */ +export interface ResolvedQQBotAccount { + accountId: string; + name?: string; + enabled: boolean; + appId: string; + clientSecret: string; + secretSource: "config" | "file" | "env" | "none"; + /** Additional system prompt text. */ + systemPrompt?: string; + /** Whether markdown output is enabled. Defaults to true. */ + markdownSupport: boolean; + config: QQBotAccountConfig; +} + +/** QQ Bot account config from user settings. */ +export interface QQBotAccountConfig { + enabled?: boolean; + name?: string; + appId?: string; + clientSecret?: SecretInput; + clientSecretFile?: string; + allowFrom?: string[]; + /** Optional system prompt prepended to user messages. */ + systemPrompt?: string; + /** Whether markdown output is enabled. Defaults to true. */ + markdownSupport?: boolean; + /** + * @deprecated Use audioFormatPolicy.uploadDirectFormats instead. + * Legacy list of formats that can upload directly without SILK conversion. + */ + voiceDirectUploadFormats?: string[]; + /** + * Audio format policy covering inbound STT and outbound upload behavior. + */ + audioFormatPolicy?: AudioFormatPolicy; + /** + * Whether public URLs should be uploaded to QQ directly. Defaults to true. + */ + urlDirectUpload?: boolean; + /** + * Upgrade guide URL returned by `/bot-upgrade`. + */ + upgradeUrl?: string; + /** + * Upgrade command mode. + * - "doc": show an upgrade guide link + * - "hot-reload": run an in-place npm update flow + */ + upgradeMode?: "doc" | "hot-reload"; +} + +/** Audio format policy controlling which formats can skip transcoding. */ +export interface AudioFormatPolicy { + /** + * Formats supported directly by the STT provider. + */ + sttDirectFormats?: string[]; + /** + * Formats QQ accepts directly for outbound uploads. + */ + uploadDirectFormats?: string[]; + /** + * Whether outbound audio transcoding is enabled. Defaults to true. + */ + transcodeEnabled?: boolean; +} + +/** Rich-media attachment metadata. */ +export interface MessageAttachment { + content_type: string; + filename?: string; + height?: number; + width?: number; + size?: number; + url: string; + voice_wav_url?: string; + asr_refer_text?: string; +} + +/** C2C message event payload. */ +export interface C2CMessageEvent { + author: { + id: string; + union_openid: string; + user_openid: string; + }; + content: string; + id: string; + timestamp: string; + message_scene?: { + source: string; + /** ext can contain ref_msg_idx and msg_idx values. */ + ext?: string[]; + }; + attachments?: MessageAttachment[]; +} + +/** Guild @-message event payload. */ +export interface GuildMessageEvent { + id: string; + channel_id: string; + guild_id: string; + content: string; + timestamp: string; + author: { + id: string; + username?: string; + bot?: boolean; + }; + member?: { + nick?: string; + joined_at?: string; + }; + attachments?: MessageAttachment[]; +} + +/** Group @-message event payload. */ +export interface GroupMessageEvent { + author: { + id: string; + member_openid: string; + }; + content: string; + id: string; + timestamp: string; + group_id: string; + group_openid: string; + message_scene?: { + source: string; + ext?: string[]; + }; + attachments?: MessageAttachment[]; +} + +/** WebSocket event payload. */ +export interface WSPayload { + op: number; + d?: unknown; + s?: number; + t?: string; +} diff --git a/extensions/qqbot/src/typing-keepalive.ts b/extensions/qqbot/src/typing-keepalive.ts new file mode 100644 index 00000000000..704b64de68f --- /dev/null +++ b/extensions/qqbot/src/typing-keepalive.ts @@ -0,0 +1,62 @@ +/** Periodically refresh C2C typing state while a response is still in progress. */ + +import { sendC2CInputNotify } from "./api.js"; + +// 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; + +export class TypingKeepAlive { + private timer: ReturnType | null = null; + private stopped = false; + + constructor( + private readonly getToken: () => Promise, + private readonly clearCache: () => void, + private readonly openid: string, + private readonly msgId: string | undefined, + private readonly log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; + }, + private readonly logPrefix = "[qqbot]", + ) {} + + /** Start periodic keep-alive sends. */ + start(): void { + if (this.stopped) return; + this.timer = setInterval(() => { + if (this.stopped) { + this.stop(); + return; + } + this.send().catch(() => {}); + }, TYPING_INTERVAL_MS); + } + + /** Stop periodic keep-alive sends. */ + stop(): void { + this.stopped = true; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + 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}`); + } catch (err) { + try { + this.clearCache(); + const token = await this.getToken(); + await sendC2CInputNotify(token, this.openid, this.msgId, TYPING_INPUT_SECOND); + } catch { + this.log?.debug?.(`${this.logPrefix} Typing keep-alive failed for ${this.openid}: ${err}`); + } + } + } +} diff --git a/extensions/qqbot/src/update-checker.ts b/extensions/qqbot/src/update-checker.ts new file mode 100644 index 00000000000..55cc0c5ea99 --- /dev/null +++ b/extensions/qqbot/src/update-checker.ts @@ -0,0 +1,204 @@ +/** + * Update-check helpers for the standalone npm package. + * + * `triggerUpdateCheck()` warms the cache in the background and `getUpdateInfo()` + * queries the registry on demand. The lookup talks directly to the npm registry + * API and falls back from npmjs.org to npmmirror.com. + */ + +import https from "node:https"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const PKG_NAME = "@openclaw/qqbot"; +const ENCODED_PKG = encodeURIComponent(PKG_NAME); + +const REGISTRIES = [ + `https://registry.npmjs.org/${ENCODED_PKG}`, + `https://registry.npmmirror.com/${ENCODED_PKG}`, +]; + +let CURRENT_VERSION = "unknown"; +try { + const pkg = require("../package.json"); + CURRENT_VERSION = pkg.version ?? "unknown"; +} catch { + // fallback +} + +export interface UpdateInfo { + current: string; + /** Preferred upgrade target: alpha for prerelease users, latest for stable users. */ + latest: string | null; + /** Stable dist-tag. */ + stable: string | null; + /** Alpha dist-tag. */ + alpha: string | null; + hasUpdate: boolean; + checkedAt: number; + error?: string; +} + +let _log: + | { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void } + | undefined; + +function fetchJson(url: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const req = https.get( + url, + { timeout: timeoutMs, headers: { Accept: "application/json" } }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`HTTP ${res.statusCode} from ${url}`)); + return; + } + let data = ""; + res.on("data", (chunk: string) => { + data += chunk; + }); + res.on("end", () => { + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(e); + } + }); + }, + ); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error(`timeout fetching ${url}`)); + }); + }); +} + +async function fetchDistTags(): Promise> { + for (const url of REGISTRIES) { + try { + const json = await fetchJson(url, 10_000); + const tags = json["dist-tags"]; + if (tags && typeof tags === "object") return tags; + } catch (e: any) { + _log?.debug?.(`[qqbot:update-checker] ${url} failed: ${e.message}`); + } + } + throw new Error("all registries failed"); +} + +function buildUpdateInfo(tags: Record): UpdateInfo { + const currentIsPrerelease = CURRENT_VERSION.includes("-"); + const stableTag = tags.latest || null; + const alphaTag = tags.alpha || null; + + // Keep prerelease and stable tracks isolated from each other. + const compareTarget = currentIsPrerelease ? alphaTag : stableTag; + + const hasUpdate = + typeof compareTarget === "string" && + compareTarget !== CURRENT_VERSION && + compareVersions(compareTarget, CURRENT_VERSION) > 0; + + return { + current: CURRENT_VERSION, + latest: compareTarget, + stable: stableTag, + alpha: alphaTag, + hasUpdate, + checkedAt: Date.now(), + }; +} + +/** Capture a logger and warm the update check in the background. */ +export function triggerUpdateCheck(log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; +}): void { + if (log) _log = log; + // Warm the cache without blocking startup. + getUpdateInfo() + .then((info) => { + if (info.hasUpdate) { + _log?.info?.( + `[qqbot:update-checker] new version available: ${info.latest} (current: ${CURRENT_VERSION})`, + ); + } + }) + .catch(() => {}); +} + +/** Query the npm registry on demand. */ +export async function getUpdateInfo(): Promise { + try { + const tags = await fetchDistTags(); + return buildUpdateInfo(tags); + } catch (err: any) { + _log?.debug?.(`[qqbot:update-checker] check failed: ${err.message}`); + return { + current: CURRENT_VERSION, + latest: null, + stable: null, + alpha: null, + hasUpdate: false, + checkedAt: Date.now(), + error: err.message, + }; + } +} + +/** + * Check whether a specific version exists in the npm registry. + */ +export async function checkVersionExists(version: string): Promise { + for (const baseUrl of REGISTRIES) { + try { + const url = `${baseUrl}/${version}`; + const json = await fetchJson(url, 10_000); + if (json && json.version === version) return true; + } catch { + // try next registry + } + } + return false; +} + +function compareVersions(a: string, b: string): number { + const parse = (v: string) => { + const clean = v.replace(/^v/, ""); + const [main, pre] = clean.split("-", 2); + return { parts: main.split(".").map(Number), pre: pre || null }; + }; + const pa = parse(a); + const pb = parse(b); + // Compare the numeric core version first. + for (let i = 0; i < 3; i++) { + const diff = (pa.parts[i] || 0) - (pb.parts[i] || 0); + if (diff !== 0) return diff; + } + // For equal core versions, stable beats prerelease. + if (!pa.pre && pb.pre) return 1; + if (pa.pre && !pb.pre) return -1; + if (!pa.pre && !pb.pre) return 0; + // When both are prereleases, compare each prerelease segment in order. + const aParts = pa.pre!.split("."); + const bParts = pb.pre!.split("."); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const aP = aParts[i] ?? ""; + const bP = bParts[i] ?? ""; + const aNum = Number(aP); + const bNum = Number(bP); + // Compare numerically when both segments are numbers. + if (!isNaN(aNum) && !isNaN(bNum)) { + if (aNum !== bNum) return aNum - bNum; + } else { + // Fall back to lexical comparison for string segments. + if (aP < bP) return -1; + if (aP > bP) return 1; + } + } + return 0; +} diff --git a/extensions/qqbot/src/user-messages.ts b/extensions/qqbot/src/user-messages.ts new file mode 100644 index 00000000000..65b4778dc19 --- /dev/null +++ b/extensions/qqbot/src/user-messages.ts @@ -0,0 +1,6 @@ +/** + * User-facing prompt strings are intentionally empty. + * + * The QQ Bot plugin follows the same rule as Feishu: runtime errors are logged + * but no extra plugin-layer user messages are injected. + */ diff --git a/extensions/qqbot/src/utils/audio-convert.ts b/extensions/qqbot/src/utils/audio-convert.ts new file mode 100644 index 00000000000..9225bf28eaa --- /dev/null +++ b/extensions/qqbot/src/utils/audio-convert.ts @@ -0,0 +1,824 @@ +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 { decode, encode, isSilk } from "silk-wasm"; +import { debugLog, debugError, debugWarn } from "./debug-log.js"; +import { detectFfmpeg, isWindows } from "./platform.js"; + +/** Detect whether a file contains SILK audio payloads. */ +function isSilkFile(filePath: string): boolean { + try { + const buf = fs.readFileSync(filePath); + return isSilk(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)); + } catch { + return false; + } +} + +/** Wrap PCM s16le bytes in a WAV container. */ +function pcmToWav( + pcmData: Uint8Array, + sampleRate: number, + channels: number = 1, + bitsPerSample: number = 16, +): Buffer { + const byteRate = sampleRate * channels * (bitsPerSample / 8); + const blockAlign = channels * (bitsPerSample / 8); + const dataSize = pcmData.length; + const headerSize = 44; + const fileSize = headerSize + dataSize; + + 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.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); + + return buffer; +} + +/** Strip a leading AMR header from QQ voice payloads when present. */ +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); + } + return buf; +} + +/** Convert SILK or AMR voice files into WAV. */ +export async function convertSilkToWav( + inputPath: string, + outputDir?: string, +): Promise<{ wavPath: string; duration: number } | null> { + if (!fs.existsSync(inputPath)) { + return null; + } + + const fileBuf = fs.readFileSync(inputPath); + + const strippedBuf = stripAmrHeader(fileBuf); + + const rawData = new Uint8Array( + strippedBuf.buffer, + strippedBuf.byteOffset, + strippedBuf.byteLength, + ); + + if (!isSilk(rawData)) { + return null; + } + + // QQ voice commonly uses 24 kHz. + const sampleRate = 24000; + const result = await decode(rawData, sampleRate); + + const wavBuffer = pcmToWav(result.data, sampleRate); + + const dir = outputDir || path.dirname(inputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const baseName = path.basename(inputPath, path.extname(inputPath)); + const wavPath = path.join(dir, `${baseName}.wav`); + fs.writeFileSync(wavPath, wavBuffer); + + return { wavPath, duration: result.duration }; +} + +/** Return true when an attachment looks like a voice file. */ +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 ? path.extname(att.filename).toLowerCase() : ""; + 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`; +} + +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 = path.extname(filePath).toLowerCase(); + return [ + ".silk", + ".slk", + ".amr", + ".wav", + ".mp3", + ".ogg", + ".opus", + ".aac", + ".flac", + ".m4a", + ".wma", + ".pcm", + ].includes(ext); +} + +/** Voice MIME types the QQ platform accepts without transcoding. */ +const QQ_NATIVE_VOICE_MIMES = new Set([ + "audio/silk", + "audio/amr", + "audio/wav", + "audio/wave", + "audio/x-wav", + "audio/mpeg", + "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. + */ +export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean { + // Prefer MIME when it is available. + if (mimeType && QQ_NATIVE_VOICE_MIMES.has(mimeType.toLowerCase())) { + return false; + } + const ext = path.extname(filePath).toLowerCase(); + 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; +} + +function resolveTTSFromBlock( + block: Record, + providerCfg: Record | undefined, +): TTSConfig | null { + const baseUrl: string | undefined = block?.baseUrl || providerCfg?.baseUrl; + const apiKey: string | undefined = block?.apiKey || providerCfg?.apiKey; + const model: string = block?.model || "tts-1"; + const voice: string = block?.voice || "alloy"; + if (!baseUrl || !apiKey) return null; + + const authStyle = + (block?.authStyle || providerCfg?.authStyle) === "api-key" + ? ("api-key" as const) + : ("bearer" as const); + const queryParams: Record = { + ...(providerCfg?.queryParams ?? {}), + ...(block?.queryParams ?? {}), + }; + const speed: number | undefined = 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 c = cfg as any; + + // Prefer plugin-specific TTS config first. + const channelTts = c?.channels?.qqbot?.tts; + if (channelTts && channelTts.enabled !== false) { + const providerId: string = channelTts?.provider || "openai"; + const providerCfg = c?.models?.providers?.[providerId]; + const result = resolveTTSFromBlock(channelTts, providerCfg); + if (result) return result; + } + + // Fall back to framework-level TTS config. + const msgTts = c?.messages?.tts; + if (msgTts && msgTts.auto !== "off" && msgTts.auto !== "disabled") { + const providerId: string = msgTts?.provider || "openai"; + const providerBlock = msgTts?.[providerId]; + const providerCfg = c?.models?.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 pcmData = new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength); + const result = await 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"]; + +/** + * Convert a local audio file into an uploadable Base64 payload. + */ +export async function audioFileToSilkBase64( + filePath: string, + directUploadFormats?: string[], +): Promise { + if (!fs.existsSync(filePath)) return null; + + const buf = fs.readFileSync(filePath); + if (buf.length === 0) { + debugError(`[audio-convert] file is empty: ${filePath}`); + return null; + } + + const ext = path.extname(filePath).toLowerCase(); + + const uploadFormats = directUploadFormats + ? normalizeFormats(directUploadFormats) + : QQ_NATIVE_UPLOAD_FORMATS; + if (uploadFormats.includes(ext)) { + debugLog(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`); + 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); + if (isSilk(raw)) { + debugLog(`[audio-convert] SILK file, direct use: ${filePath} (${buf.length} bytes)`); + return buf.toString("base64"); + } + } + + // 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( + strippedCheck.buffer, + strippedCheck.byteOffset, + strippedCheck.byteLength, + ); + if (isSilk(rawCheck) || isSilk(strippedRaw)) { + debugLog(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`); + return buf.toString("base64"); + } + + const targetRate = 24000; + + // Prefer ffmpeg for broad codec coverage. + const ffmpegCmd = await checkFfmpeg(); + if (ffmpegCmd) { + try { + debugLog( + `[audio-convert] ffmpeg (${ffmpegCmd}): converting ${ext} (${buf.length} bytes) → PCM s16le ${targetRate}Hz`, + ); + const pcmBuf = await ffmpegToPCM(ffmpegCmd, filePath, targetRate); + if (pcmBuf.length === 0) { + debugError(`[audio-convert] ffmpeg produced empty PCM output`); + return null; + } + const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate); + debugLog(`[audio-convert] ffmpeg: ${ext} → SILK done (${silkBuffer.length} bytes)`); + return silkBuffer.toString("base64"); + } catch (err) { + debugError( + `[audio-convert] ffmpeg conversion failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + // Fall back to WASM decoders when ffmpeg is unavailable. + debugLog(`[audio-convert] fallback: trying WASM decoders for ${ext}`); + + if (ext === ".pcm") { + const pcmBuf = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength); + const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate); + return silkBuffer.toString("base64"); + } + + if (ext === ".wav" || (buf.length >= 4 && buf.toString("ascii", 0, 4) === "RIFF")) { + const wavInfo = parseWavFallback(buf); + if (wavInfo) { + const { silkBuffer } = await pcmToSilk(wavInfo, targetRate); + return silkBuffer.toString("base64"); + } + } + + if (ext === ".mp3" || ext === ".mpeg") { + const pcmBuf = await wasmDecodeMp3ToPCM(buf, targetRate); + if (pcmBuf) { + const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate); + debugLog(`[audio-convert] WASM: MP3 → SILK done (${silkBuffer.length} bytes)`); + return silkBuffer.toString("base64"); + } + } + + const installHint = isWindows() + ? "Install ffmpeg with choco install ffmpeg, scoop install ffmpeg, or from https://ffmpeg.org" + : process.platform === "darwin" + ? "Install ffmpeg with brew install ffmpeg" + : "Install ffmpeg with sudo apt install ffmpeg or sudo yum install ffmpeg"; + debugError(`[audio-convert] unsupported format: ${ext} (no ffmpeg available). ${installHint}`); + return null; +} + +/** + * Wait until a file exists and its size has stabilized. + */ +export async function waitForFile( + filePath: string, + timeoutMs: number = 30000, + pollMs: number = 500, +): Promise { + const start = Date.now(); + let lastSize = -1; + let stableCount = 0; + let fileExists = false; + let fileAppearedAt = 0; + let pollCount = 0; + + const emptyGiveUpMs = 10000; + const noFileGiveUpMs = 15000; + + while (Date.now() - start < timeoutMs) { + pollCount++; + try { + const stat = fs.statSync(filePath); + if (!fileExists) { + fileExists = true; + fileAppearedAt = Date.now(); + debugLog( + `[audio-convert] waitForFile: file appeared (${stat.size} bytes, after ${Date.now() - start}ms): ${path.basename(filePath)}`, + ); + } + if (stat.size > 0) { + if (stat.size === lastSize) { + stableCount++; + if (stableCount >= 2) { + debugLog( + `[audio-convert] waitForFile: ready (${stat.size} bytes, waited ${Date.now() - start}ms, polls=${pollCount})`, + ); + return stat.size; + } + } else { + stableCount = 0; + } + lastSize = stat.size; + } else { + if (Date.now() - fileAppearedAt > emptyGiveUpMs) { + debugError( + `[audio-convert] waitForFile: file still empty after ${emptyGiveUpMs}ms, giving up: ${path.basename(filePath)}`, + ); + return 0; + } + } + } catch { + if (!fileExists && Date.now() - start > noFileGiveUpMs) { + debugError( + `[audio-convert] waitForFile: file never appeared after ${noFileGiveUpMs}ms, giving up: ${path.basename(filePath)}`, + ); + return 0; + } + } + await new Promise((r) => setTimeout(r, pollMs)); + } + + try { + const finalStat = fs.statSync(filePath); + if (finalStat.size > 0) { + debugWarn( + `[audio-convert] waitForFile: timeout but file has data (${finalStat.size} bytes), using it`, + ); + return finalStat.size; + } + debugError( + `[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file exists but empty (0 bytes): ${path.basename(filePath)}`, + ); + } catch { + debugError( + `[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file never appeared: ${path.basename(filePath)}`, + ); + } + return 0; +} + +/** Delegate ffmpeg detection to the platform helper. */ +async function checkFfmpeg(): Promise { + return detectFfmpeg(); +} + +/** Convert arbitrary audio into mono 24 kHz PCM s16le with ffmpeg. */ +function ffmpegToPCM( + ffmpegCmd: string, + inputPath: string, + sampleRate: number = 24000, +): Promise { + return new Promise((resolve, reject) => { + const args = [ + "-i", + inputPath, + "-f", + "s16le", + "-ar", + String(sampleRate), + "-ac", + "1", + "-acodec", + "pcm_s16le", + "-v", + "error", + "pipe:1", + ]; + execFile( + ffmpegCmd, + args, + { + maxBuffer: 50 * 1024 * 1024, + encoding: "buffer", + ...(isWindows() ? { windowsHide: true } : {}), + }, + (err, stdout) => { + if (err) { + reject(new Error(`ffmpeg failed: ${err.message}`)); + return; + } + resolve(stdout as unknown as Buffer); + }, + ); + }); +} + +/** Decode MP3 into PCM through mpg123-decoder when ffmpeg is unavailable. */ +async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise { + try { + const { MPEGDecoder } = await import("mpg123-decoder"); + debugLog(`[audio-convert] WASM MP3 decode: size=${buf.length} bytes`); + const decoder = new MPEGDecoder(); + await decoder.ready; + + const decoded = decoder.decode(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)); + decoder.free(); + + if (decoded.samplesDecoded === 0 || decoded.channelData.length === 0) { + debugError( + `[audio-convert] WASM MP3 decode: no samples (samplesDecoded=${decoded.samplesDecoded})`, + ); + return null; + } + + debugLog( + `[audio-convert] WASM MP3 decode: samples=${decoded.samplesDecoded}, sampleRate=${decoded.sampleRate}, channels=${decoded.channelData.length}`, + ); + + // Down-mix multi-channel float PCM into mono. + let floatMono: Float32Array; + if (decoded.channelData.length === 1) { + floatMono = decoded.channelData[0]; + } else { + floatMono = new Float32Array(decoded.samplesDecoded); + const channels = decoded.channelData.length; + for (let i = 0; i < decoded.samplesDecoded; i++) { + let sum = 0; + for (let ch = 0; ch < channels; ch++) { + sum += decoded.channelData[ch][i]; + } + floatMono[i] = sum / channels; + } + } + + // Convert Float32 PCM into s16le. + const s16 = new Uint8Array(floatMono.length * 2); + const view = new DataView(s16.buffer); + for (let i = 0; i < floatMono.length; i++) { + const clamped = Math.max(-1, Math.min(1, floatMono[i])); + const val = clamped < 0 ? clamped * 32768 : clamped * 32767; + view.setInt16(i * 2, Math.round(val), true); + } + + // Resample with simple linear interpolation. + let pcm: Uint8Array = s16; + if (decoded.sampleRate !== targetRate) { + const inputSamples = s16.length / 2; + const outputSamples = Math.round((inputSamples * targetRate) / decoded.sampleRate); + const output = new Uint8Array(outputSamples * 2); + const inView = new DataView(s16.buffer, s16.byteOffset, s16.byteLength); + const outView = new DataView(output.buffer, output.byteOffset, output.byteLength); + for (let i = 0; i < outputSamples; i++) { + const srcIdx = (i * decoded.sampleRate) / targetRate; + const idx0 = Math.floor(srcIdx); + const idx1 = Math.min(idx0 + 1, inputSamples - 1); + const frac = srcIdx - idx0; + const s0 = inView.getInt16(idx0 * 2, true); + const s1 = inView.getInt16(idx1 * 2, true); + const sample = Math.round(s0 + (s1 - s0) * frac); + outView.setInt16(i * 2, Math.max(-32768, Math.min(32767, sample)), true); + } + pcm = output; + } + + return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength); + } catch (err) { + debugError( + `[audio-convert] WASM MP3 decode failed: ${err instanceof Error ? err.message : String(err)}`, + ); + if (err instanceof Error && err.stack) { + debugError(`[audio-convert] stack: ${err.stack}`); + } + return null; + } +} + +/** Normalize file extensions to lowercased dotted form. */ +function normalizeFormats(formats: string[]): string[] { + return formats.map((f) => { + const lower = f.toLowerCase().trim(); + return lower.startsWith(".") ? lower : `.${lower}`; + }); +} + +/** Parse standard PCM WAV as a no-ffmpeg fallback. */ +function parseWavFallback(buf: Buffer): Buffer | null { + if (buf.length < 44) return null; + if (buf.toString("ascii", 0, 4) !== "RIFF") return null; + if (buf.toString("ascii", 8, 12) !== "WAVE") return null; + if (buf.toString("ascii", 12, 16) !== "fmt ") return null; + + const audioFormat = buf.readUInt16LE(20); + if (audioFormat !== 1) return null; + + const channels = buf.readUInt16LE(22); + const sampleRate = buf.readUInt32LE(24); + const bitsPerSample = buf.readUInt16LE(34); + if (bitsPerSample !== 16) return null; + + // Find the PCM data chunk. + let offset = 36; + while (offset < buf.length - 8) { + const chunkId = buf.toString("ascii", offset, offset + 4); + const chunkSize = buf.readUInt32LE(offset + 4); + if (chunkId === "data") { + const dataStart = offset + 8; + 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); + const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength); + const outV = new DataView(mono.buffer, mono.byteOffset, mono.byteLength); + for (let i = 0; i < samplesPerCh; i++) { + let sum = 0; + for (let ch = 0; ch < channels; ch++) sum += inV.getInt16((i * channels + ch) * 2, true); + outV.setInt16(i * 2, Math.max(-32768, Math.min(32767, Math.round(sum / channels))), true); + } + pcm = mono; + } + + // Resample with simple linear interpolation. + const targetRate = 24000; + if (sampleRate !== targetRate) { + const inSamples = pcm.length / 2; + const outSamples = Math.round((inSamples * targetRate) / sampleRate); + const out = new Uint8Array(outSamples * 2); + const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength); + const outV = new DataView(out.buffer, out.byteOffset, out.byteLength); + for (let i = 0; i < outSamples; i++) { + const src = (i * sampleRate) / targetRate; + const i0 = Math.floor(src); + const i1 = Math.min(i0 + 1, inSamples - 1); + const f = src - i0; + const s0 = inV.getInt16(i0 * 2, true); + const s1 = inV.getInt16(i1 * 2, true); + outV.setInt16( + i * 2, + Math.max(-32768, Math.min(32767, Math.round(s0 + (s1 - s0) * f))), + true, + ); + } + pcm = out; + } + + return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength); + } + offset += 8 + chunkSize; + } + + return null; +} diff --git a/extensions/qqbot/src/utils/debug-log.ts b/extensions/qqbot/src/utils/debug-log.ts new file mode 100644 index 00000000000..a7276389682 --- /dev/null +++ b/extensions/qqbot/src/utils/debug-log.ts @@ -0,0 +1,26 @@ +/** + * 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/file-utils.ts b/extensions/qqbot/src/utils/file-utils.ts new file mode 100644 index 00000000000..32caf4f9877 --- /dev/null +++ b/extensions/qqbot/src/utils/file-utils.ts @@ -0,0 +1,139 @@ +import crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** Maximum file size accepted by the QQ Bot API. */ +export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; + +/** Threshold used to treat an upload as a large file. */ +export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; + +/** Result of local file-size validation. */ +export interface FileSizeCheckResult { + ok: boolean; + size: number; + error?: string; +} + +/** Validate that a file is within the allowed upload size. */ +export function checkFileSize(filePath: string, maxSize = MAX_UPLOAD_SIZE): FileSizeCheckResult { + try { + const stat = fs.statSync(filePath); + if (stat.size > maxSize) { + const sizeMB = (stat.size / (1024 * 1024)).toFixed(1); + const limitMB = (maxSize / (1024 * 1024)).toFixed(0); + return { + ok: false, + size: stat.size, + error: `File is too large (${sizeMB}MB); QQ Bot API limit is ${limitMB}MB`, + }; + } + return { ok: true, size: stat.size }; + } catch (err) { + return { + ok: false, + size: 0, + error: `Failed to read file metadata: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +/** Read file contents asynchronously. */ +export async function readFileAsync(filePath: string): Promise { + return fs.promises.readFile(filePath); +} + +/** Check file readability asynchronously. */ +export async function fileExistsAsync(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +/** Get file size asynchronously. */ +export async function getFileSizeAsync(filePath: string): Promise { + const stat = await fs.promises.stat(filePath); + return stat.size; +} + +/** Return true when a file should be treated as large. */ +export function isLargeFile(sizeBytes: number): boolean { + return sizeBytes >= LARGE_FILE_THRESHOLD; +} + +/** Format a byte count into a human-readable size string. */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +/** Infer a MIME type from the file extension. */ +export function getMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".avi": "video/x-msvideo", + ".mkv": "video/x-matroska", + ".webm": "video/webm", + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".txt": "text/plain", + }; + return mimeTypes[ext] ?? "application/octet-stream"; +} + +/** Download a remote file into a local directory. */ +export async function downloadFile( + url: string, + destDir: string, + originalFilename?: string, +): Promise { + try { + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + const resp = await fetch(url, { redirect: "follow" }); + if (!resp.ok || !resp.body) return null; + + let filename = originalFilename?.trim() || ""; + if (!filename) { + try { + const urlPath = new URL(url).pathname; + filename = path.basename(urlPath) || "download"; + } catch { + filename = "download"; + } + } + + const ts = Date.now(); + const ext = path.extname(filename); + const base = path.basename(filename, ext) || "file"; + const rand = crypto.randomBytes(3).toString("hex"); + const safeFilename = `${base}_${ts}_${rand}${ext}`; + + const destPath = path.join(destDir, safeFilename); + const buffer = Buffer.from(await resp.arrayBuffer()); + await fs.promises.writeFile(destPath, buffer); + return destPath; + } catch { + return null; + } +} diff --git a/extensions/qqbot/src/utils/image-size.ts b/extensions/qqbot/src/utils/image-size.ts new file mode 100644 index 00000000000..ca297ef9395 --- /dev/null +++ b/extensions/qqbot/src/utils/image-size.ts @@ -0,0 +1,239 @@ +/** + * Image dimension helpers for QQ Bot markdown image syntax. + * + * QQ Bot markdown images use `![#widthpx #heightpx](url)`. + */ + +import { Buffer } from "buffer"; +import { debugLog } from "./debug-log.js"; + +export interface ImageSize { + width: number; + height: number; +} + +/** Default dimensions used when probing fails. */ +export const DEFAULT_IMAGE_SIZE: ImageSize = { width: 512, height: 512 }; + +/** + * Parse image dimensions from the PNG header. + */ +function parsePngSize(buffer: Buffer): ImageSize | null { + // PNG signature: 89 50 4E 47 0D 0A 1A 0A + if (buffer.length < 24) return null; + if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) { + return null; + } + // The IHDR chunk begins at byte 8, with width/height at 16..23. + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + return { width, height }; +} + +/** Parse image dimensions from JPEG SOF0/SOF2 markers. */ +function parseJpegSize(buffer: Buffer): ImageSize | null { + // JPEG signature: FF D8 FF + if (buffer.length < 4) return null; + if (buffer[0] !== 0xff || buffer[1] !== 0xd8) { + return null; + } + + let offset = 2; + while (offset < buffer.length - 9) { + if (buffer[offset] !== 0xff) { + offset++; + continue; + } + + const marker = buffer[offset + 1]; + // SOF0 (0xC0) and SOF2 (0xC2) contain dimensions. + if (marker === 0xc0 || marker === 0xc2) { + // Layout: FF C0 length(2) precision(1) height(2) width(2) + if (offset + 9 <= buffer.length) { + const height = buffer.readUInt16BE(offset + 5); + const width = buffer.readUInt16BE(offset + 7); + return { width, height }; + } + } + + // Skip the current block. + if (offset + 3 < buffer.length) { + const blockLength = buffer.readUInt16BE(offset + 2); + offset += 2 + blockLength; + } else { + break; + } + } + + return null; +} + +/** Parse image dimensions from the GIF header. */ +function parseGifSize(buffer: Buffer): ImageSize | null { + if (buffer.length < 10) return null; + const signature = buffer.toString("ascii", 0, 6); + if (signature !== "GIF87a" && signature !== "GIF89a") { + return null; + } + const width = buffer.readUInt16LE(6); + const height = buffer.readUInt16LE(8); + return { width, height }; +} + +/** Parse image dimensions from WebP headers. */ +function parseWebpSize(buffer: Buffer): ImageSize | null { + if (buffer.length < 30) return null; + + // Check the RIFF and WEBP signatures. + const riff = buffer.toString("ascii", 0, 4); + const webp = buffer.toString("ascii", 8, 12); + if (riff !== "RIFF" || webp !== "WEBP") { + return null; + } + + const chunkType = buffer.toString("ascii", 12, 16); + + // VP8 (lossy) + if (chunkType === "VP8 ") { + // The VP8 frame header starts at byte 23 and uses the 9D 01 2A signature. + if (buffer.length >= 30 && buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) { + const width = buffer.readUInt16LE(26) & 0x3fff; + const height = buffer.readUInt16LE(28) & 0x3fff; + return { width, height }; + } + } + + // VP8L (lossless) + if (chunkType === "VP8L") { + // VP8L signature: 0x2F + if (buffer.length >= 25 && buffer[20] === 0x2f) { + const bits = buffer.readUInt32LE(21); + const width = (bits & 0x3fff) + 1; + const height = ((bits >> 14) & 0x3fff) + 1; + return { width, height }; + } + } + + // VP8X (extended format) + if (chunkType === "VP8X") { + if (buffer.length >= 30) { + // Width and height live at 24..26 and 27..29 as 24-bit little-endian values. + const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1; + const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1; + return { width, height }; + } + } + + return null; +} + +/** Parse image dimensions from raw image bytes. */ +export function parseImageSize(buffer: Buffer): ImageSize | null { + // Try each supported image format in sequence. + return ( + parsePngSize(buffer) ?? parseJpegSize(buffer) ?? parseGifSize(buffer) ?? parseWebpSize(buffer) + ); +} + +/** + * Fetch image dimensions from a public URL using only the first 64 KB. + */ +export async function getImageSizeFromUrl( + url: string, + timeoutMs = 5000, +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + // Request only the first 64 KB, which is enough for common headers. + const response = await fetch(url, { + signal: controller.signal, + headers: { + Range: "bytes=0-65535", + "User-Agent": "QQBot-Image-Size-Detector/1.0", + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok && response.status !== 206) { + debugLog(`[image-size] Failed to fetch ${url}: ${response.status}`); + return null; + } + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const size = parseImageSize(buffer); + if (size) { + debugLog( + `[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`, + ); + } + + return size; + } catch (err) { + debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${err}`); + return null; + } +} + +/** Parse image dimensions from a Base64 data URL. */ +export function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null { + try { + // Format: data:image/png;base64,xxxxx + const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/); + if (!matches) { + return null; + } + + const base64Data = matches[1]; + const buffer = Buffer.from(base64Data, "base64"); + + const size = parseImageSize(buffer); + if (size) { + debugLog(`[image-size] Got size from Base64: ${size.width}x${size.height}`); + } + + return size; + } catch (err) { + debugLog(`[image-size] Error parsing Base64: ${err}`); + return null; + } +} + +/** + * Resolve image dimensions from either an HTTP URL or a Base64 data URL. + */ +export async function getImageSize(source: string): Promise { + if (source.startsWith("data:")) { + return getImageSizeFromDataUrl(source); + } + + if (source.startsWith("http://") || source.startsWith("https://")) { + return getImageSizeFromUrl(source); + } + + return null; +} + +/** Format a markdown image with QQ Bot width/height annotations. */ +export function formatQQBotMarkdownImage(url: string, size: ImageSize | null): string { + const { width, height } = size ?? DEFAULT_IMAGE_SIZE; + return `![#${width}px #${height}px](${url})`; +} + +/** Return true when markdown already contains QQ Bot size annotations. */ +export function hasQQBotImageSize(markdownImage: string): boolean { + return /!\[#\d+px\s+#\d+px\]/.test(markdownImage); +} + +/** Extract width and height from QQBot markdown image syntax: `![#Wpx #Hpx](url)`. */ +export function extractQQBotImageSize(markdownImage: string): ImageSize | null { + const match = markdownImage.match(/!\[#(\d+)px\s+#(\d+)px\]/); + if (match) { + return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) }; + } + return null; +} diff --git a/extensions/qqbot/src/utils/media-tags.ts b/extensions/qqbot/src/utils/media-tags.ts new file mode 100644 index 00000000000..2199744d5a9 --- /dev/null +++ b/extensions/qqbot/src/utils/media-tags.ts @@ -0,0 +1,131 @@ +import { expandTilde } from "./platform.js"; + +// Canonical media tags. `qqmedia` is the generic auto-routing tag. +const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile", "qqmedia"] as const; + +// Lowercased aliases that should normalize to the canonical tag set. +const TAG_ALIASES: Record = { + qq_img: "qqimg", + qqimage: "qqimg", + qq_image: "qqimg", + qqpic: "qqimg", + qq_pic: "qqimg", + qqpicture: "qqimg", + qq_picture: "qqimg", + qqphoto: "qqimg", + qq_photo: "qqimg", + img: "qqimg", + image: "qqimg", + pic: "qqimg", + picture: "qqimg", + photo: "qqimg", + qq_voice: "qqvoice", + qqaudio: "qqvoice", + qq_audio: "qqvoice", + voice: "qqvoice", + audio: "qqvoice", + qq_video: "qqvideo", + video: "qqvideo", + qq_file: "qqfile", + qqdoc: "qqfile", + qq_doc: "qqfile", + file: "qqfile", + doc: "qqfile", + document: "qqfile", + qq_media: "qqmedia", + media: "qqmedia", + attachment: "qqmedia", + attach: "qqmedia", + qqattachment: "qqmedia", + qq_attachment: "qqmedia", + qqsend: "qqmedia", + qq_send: "qqmedia", + send: "qqmedia", +}; + +const ALL_TAG_NAMES = [...VALID_TAGS, ...Object.keys(TAG_ALIASES)]; +ALL_TAG_NAMES.sort((a, b) => b.length - a.length); + +const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|"); + +/** Match self-closing media-tag syntax with file/src/path/url attributes. */ +const SELF_CLOSING_TAG_REGEX = new RegExp( + "`?" + + "[<<<]\\s*(" + + TAG_NAME_PATTERN + + ")" + + "(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'/>>>]*?[\"']?)*" + + "\\s+(?:file|src|path|url)\\s*=\\s*" + + "[\"']?" + + "([^\"'/>>>]+?)" + + "[\"']?" + + "(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'/>>>]*?[\"']?)*" + + "\\s*/?" + + "\\s*[>>>]" + + "`?", + "gi", +); + +/** Match malformed wrapped media tags that should be normalized. */ +const FUZZY_MEDIA_TAG_REGEX = new RegExp( + "`?" + + "[<<<]\\s*(" + + TAG_NAME_PATTERN + + ")\\s*[>>>]" + + "[\"']?\\s*" + + "([^<<<>>\"'`]+?)" + + "\\s*[\"']?" + + "[<<<]\\s*/?\\s*(?:" + + TAG_NAME_PATTERN + + ")\\s*[>>>]" + + "`?", + "gi", +); + +/** Normalize a raw tag name into the canonical tag set. */ +function resolveTagName(raw: string): (typeof VALID_TAGS)[number] { + const lower = raw.toLowerCase(); + if ((VALID_TAGS as readonly string[]).includes(lower)) { + return lower as (typeof VALID_TAGS)[number]; + } + return TAG_ALIASES[lower] ?? "qqimg"; +} + +/** Match wrapped tags whose bodies need newline and tab cleanup. */ +const MULTILINE_TAG_CLEANUP = new RegExp( + "([<<<]\\s*(?:" + + TAG_NAME_PATTERN + + ")\\s*[>>>])" + + "([\\s\\S]*?)" + + "([<<<]\\s*/?\\s*(?:" + + TAG_NAME_PATTERN + + ")\\s*[>>>])", + "gi", +); + +/** Normalize malformed media-tag output into canonical wrapped tags. */ +export function normalizeMediaTags(text: string): string { + let cleaned = text.replace(SELF_CLOSING_TAG_REGEX, (_match, rawTag: string, content: string) => { + const tag = resolveTagName(rawTag); + const trimmed = content.trim(); + if (!trimmed) return _match; + const expanded = expandTilde(trimmed); + return `<${tag}>${expanded}`; + }); + + cleaned = cleaned.replace( + MULTILINE_TAG_CLEANUP, + (_m, open: string, body: string, close: string) => { + const flat = body.replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " "); + return open + flat + close; + }, + ); + + return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, (_match, rawTag: string, content: string) => { + const tag = resolveTagName(rawTag); + const trimmed = content.trim(); + if (!trimmed) return _match; + const expanded = expandTilde(trimmed); + return `<${tag}>${expanded}`; + }); +} diff --git a/extensions/qqbot/src/utils/payload.ts b/extensions/qqbot/src/utils/payload.ts new file mode 100644 index 00000000000..d31d90d4201 --- /dev/null +++ b/extensions/qqbot/src/utils/payload.ts @@ -0,0 +1,159 @@ +/** Structured reminder payload emitted by the model. */ +export interface CronReminderPayload { + type: "cron_reminder"; + content: string; + targetType: "c2c" | "group"; + targetAddress: string; + originalMessageId?: string; +} + +/** Structured media payload emitted by the model. */ +export interface MediaPayload { + type: "media"; + mediaType: "image" | "audio" | "video" | "file"; + source: "url" | "file"; + path: string; + caption?: string; +} + +export type QQBotPayload = CronReminderPayload | MediaPayload; + +/** Result of parsing model output into a structured payload. */ +export interface ParseResult { + isPayload: boolean; + payload?: QQBotPayload; + text?: string; + error?: string; +} + +const PAYLOAD_PREFIX = "QQBOT_PAYLOAD:"; +const CRON_PREFIX = "QQBOT_CRON:"; + +/** 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, + }; + } + + const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim(); + + if (!jsonContent) { + 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", + }; + } + + if (payload.type === "cron_reminder") { + if (!payload.content || !payload.targetType || !payload.targetAddress) { + return { + isPayload: true, + error: + "cron_reminder payload is missing required fields (content, targetType, targetAddress)", + }; + } + } else if (payload.type === "media") { + if (!payload.mediaType || !payload.source || !payload.path) { + return { + isPayload: true, + error: "media payload is missing required fields (mediaType, source, path)", + }; + } + } + + return { + isPayload: true, + payload, + }; + } catch (e) { + return { + isPayload: true, + error: `Failed to parse JSON: ${e instanceof Error ? e.message : String(e)}`, + }; + } +} + +/** Encode a cron reminder payload into the stored cron-message format. */ +export function encodePayloadForCron(payload: CronReminderPayload): string { + const jsonString = JSON.stringify(payload); + const base64 = Buffer.from(jsonString, "utf-8").toString("base64"); + return `${CRON_PREFIX}${base64}`; +} + +/** Decode a stored cron payload. */ +export function decodeCronPayload(message: string): { + isCronPayload: boolean; + payload?: CronReminderPayload; + error?: string; +} { + const trimmedMessage = message.trim(); + + if (!trimmedMessage.startsWith(CRON_PREFIX)) { + return { + isCronPayload: false, + }; + } + + const base64Content = trimmedMessage.slice(CRON_PREFIX.length); + + if (!base64Content) { + return { + isCronPayload: true, + error: "Cron payload body is empty", + }; + } + + try { + const jsonString = Buffer.from(base64Content, "base64").toString("utf-8"); + const payload = JSON.parse(jsonString) as CronReminderPayload; + + if (payload.type !== "cron_reminder") { + return { + isCronPayload: true, + error: `Expected type cron_reminder but got ${payload.type}`, + }; + } + + if (!payload.content || !payload.targetType || !payload.targetAddress) { + return { + isCronPayload: true, + error: "Cron payload is missing required fields", + }; + } + + return { + isCronPayload: true, + payload, + }; + } catch (e) { + return { + isCronPayload: true, + error: `Failed to decode cron payload: ${e instanceof Error ? e.message : String(e)}`, + }; + } +} + +/** Type guard for cron reminder payloads. */ +export function isCronReminderPayload(payload: QQBotPayload): payload is CronReminderPayload { + return payload.type === "cron_reminder"; +} + +/** Type guard for media payloads. */ +export function isMediaPayload(payload: QQBotPayload): payload is MediaPayload { + return payload.type === "media"; +} diff --git a/extensions/qqbot/src/utils/platform.test.ts b/extensions/qqbot/src/utils/platform.test.ts new file mode 100644 index 00000000000..4cee20b7149 --- /dev/null +++ b/extensions/qqbot/src/utils/platform.test.ts @@ -0,0 +1,69 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getHomeDir, resolveQQBotLocalMediaPath } from "./platform.js"; + +describe("qqbot local media path remapping", () => { + const createdPaths: string[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + for (const target of createdPaths.splice(0)) { + fs.rmSync(target, { recursive: true, force: true }); + } + }); + + it("remaps missing workspace media paths to the real media directory", () => { + const actualHome = getHomeDir(); + const openclawDir = path.join(actualHome, ".openclaw"); + fs.mkdirSync(openclawDir, { recursive: true }); + const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-")); + createdPaths.push(testRoot); + + const mediaFile = path.join( + actualHome, + ".openclaw", + "media", + "qqbot", + "downloads", + path.basename(testRoot), + "example.png", + ); + fs.mkdirSync(path.dirname(mediaFile), { recursive: true }); + fs.writeFileSync(mediaFile, "image", "utf8"); + + const missingWorkspacePath = path.join( + actualHome, + ".openclaw", + "workspace", + "qqbot", + "downloads", + path.basename(testRoot), + "example.png", + ); + + expect(resolveQQBotLocalMediaPath(missingWorkspacePath)).toBe(mediaFile); + }); + + it("leaves existing media paths unchanged", () => { + const actualHome = getHomeDir(); + const openclawDir = path.join(actualHome, ".openclaw"); + fs.mkdirSync(openclawDir, { recursive: true }); + const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-")); + createdPaths.push(testRoot); + + const mediaFile = path.join( + actualHome, + ".openclaw", + "media", + "qqbot", + "downloads", + path.basename(testRoot), + "existing.png", + ); + fs.mkdirSync(path.dirname(mediaFile), { recursive: true }); + fs.writeFileSync(mediaFile, "image", "utf8"); + + expect(resolveQQBotLocalMediaPath(mediaFile)).toBe(mediaFile); + }); +}); diff --git a/extensions/qqbot/src/utils/platform.ts b/extensions/qqbot/src/utils/platform.ts new file mode 100644 index 00000000000..9169b639bde --- /dev/null +++ b/extensions/qqbot/src/utils/platform.ts @@ -0,0 +1,411 @@ +/** + * Cross-platform compatibility helpers. + * + * This module centralizes home/temp directory discovery, local-path checks, + * ffmpeg/ffprobe lookup, native-module compatibility checks, and startup diagnostics. + */ + +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 { 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. + +/** + * Resolve the current user's home directory safely across platforms. + * + * Priority: + * 1. `os.homedir()` + * 2. `$HOME` or `%USERPROFILE%` + * 3. `os.tmpdir()` as a last resort + */ +export function getHomeDir(): string { + try { + const home = os.homedir(); + if (home && fs.existsSync(home)) return home; + } catch {} + + // Fall back to environment variables. + const envHome = process.env.HOME || process.env.USERPROFILE; + if (envHome && fs.existsSync(envHome)) return envHome; + + // Final fallback. + return os.tmpdir(); +} + +/** + * 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)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +/** + * Return a path under `~/.openclaw/media/qqbot`, creating it on demand. + * + * Unlike `getQQBotDataDir`, this lives under OpenClaw's core media allowlist so + * downloaded images and audio can be accessed by framework media tooling. + */ +export function getQQBotMediaDir(...subPaths: string[]): string { + const dir = path.join(getHomeDir(), ".openclaw", "media", "qqbot", ...subPaths); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +// Temporary directory helpers. + +/** Return the OS temp directory. */ +export function getTempDir(): string { + return os.tmpdir(); +} + +// Tilde expansion. + +/** + * Expand `~` to the current user's home directory. + * + * Supports `~` and `~/...`. Other forms are returned unchanged. + */ +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(); + // 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); +} + +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; +} + +// 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(/[\x00-\x1F\x7F]/g, ""); + + 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); +} + +let _ffmpegPath: string | null | undefined; +let _ffmpegCheckPromise: Promise | null = null; + +/** Detect ffmpeg and return an executable path when available. */ +export function detectFfmpeg(): Promise { + if (_ffmpegPath !== undefined) return Promise.resolve(_ffmpegPath); + if (_ffmpegCheckPromise) return _ffmpegCheckPromise; + + _ffmpegCheckPromise = (async () => { + const envPath = process.env.FFMPEG_PATH; + if (envPath) { + const ok = await testExecutable(envPath, ["-version"]); + if (ok) { + _ffmpegPath = envPath; + debugLog(`[platform] ffmpeg found via FFMPEG_PATH: ${envPath}`); + return _ffmpegPath; + } + debugWarn(`[platform] FFMPEG_PATH set but not working: ${envPath}`); + } + + const cmd = isWindows() ? "ffmpeg.exe" : "ffmpeg"; + const ok = await testExecutable(cmd, ["-version"]); + if (ok) { + _ffmpegPath = cmd; + debugLog(`[platform] ffmpeg detected in PATH`); + return _ffmpegPath; + } + + const commonPaths = isWindows() + ? [ + "C:\\ffmpeg\\bin\\ffmpeg.exe", + path.join(process.env.LOCALAPPDATA || "", "Programs", "ffmpeg", "bin", "ffmpeg.exe"), + path.join(process.env.ProgramFiles || "", "ffmpeg", "bin", "ffmpeg.exe"), + ] + : [ + "/usr/local/bin/ffmpeg", + "/opt/homebrew/bin/ffmpeg", + "/usr/bin/ffmpeg", + "/snap/bin/ffmpeg", + ]; + + for (const p of commonPaths) { + if (p && fs.existsSync(p)) { + const works = await testExecutable(p, ["-version"]); + if (works) { + _ffmpegPath = p; + debugLog(`[platform] ffmpeg found at: ${p}`); + return _ffmpegPath; + } + } + } + + _ffmpegPath = null; + return null; + })().finally(() => { + _ffmpegCheckPromise = null; + }); + + return _ffmpegCheckPromise; +} + +/** Return true when an executable responds successfully to the given args. */ +function testExecutable(cmd: string, args: string[]): Promise { + return new Promise((resolve) => { + execFile(cmd, args, { timeout: 5000 }, (err) => { + resolve(!err); + }); + }); +} + +/** Reset ffmpeg detection state, mainly for tests. */ +export function resetFfmpegCache(): void { + _ffmpegPath = undefined; + _ffmpegCheckPromise = null; +} + +let _silkWasmAvailable: boolean | null = null; + +/** Check whether silk-wasm can run in the current environment. */ +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"); + } catch (err) { + _silkWasmAvailable = false; + debugWarn( + `[platform] silk-wasm: NOT available (${err instanceof Error ? err.message : String(err)})`, + ); + } + return _silkWasmAvailable; +} + +// Startup environment diagnostics. + +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(); + + // 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.", + ); + } + + // 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.", + ); + } + + // 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.`); + } + + // 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.`, + ); + } + } + + 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; +} diff --git a/extensions/qqbot/src/utils/text-parsing.ts b/extensions/qqbot/src/utils/text-parsing.ts new file mode 100644 index 00000000000..0331825b48d --- /dev/null +++ b/extensions/qqbot/src/utils/text-parsing.ts @@ -0,0 +1,71 @@ +import type { RefAttachmentSummary } from "../ref-index-store.js"; + +/** Replace QQ face tags with readable text labels. */ +export function parseFaceTags(text: string): string { + if (!text) return text; + + return text.replace(//g, (_match, ext: string) => { + try { + 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): string { + if (!text) return text; + + 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 = att.content_type?.toLowerCase() ?? ""; + 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/utils/upload-cache.ts new file mode 100644 index 00000000000..008c28b58f8 --- /dev/null +++ b/extensions/qqbot/src/utils/upload-cache.ts @@ -0,0 +1,105 @@ +/** + * Cache `file_info` values returned by the QQ Bot API so identical uploads can be reused + * before the server-side TTL expires. + */ + +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import { debugLog } from "./debug-log.js"; + +interface CacheEntry { + fileInfo: string; + fileUuid: string; + expiresAt: number; +} + +const cache = new Map(); +const MAX_CACHE_SIZE = 500; + +/** Compute an MD5 hash used as part of the cache key. */ +export function computeFileHash(data: string | Buffer): string { + const content = typeof data === "string" ? data : data; + return crypto.createHash("md5").update(content).digest("hex"); +} + +/** Build the in-memory cache key. */ +function buildCacheKey( + contentHash: string, + scope: string, + targetId: string, + fileType: number, +): string { + return `${contentHash}:${scope}:${targetId}:${fileType}`; +} + +/** Look up a cached `file_info` value. */ +export function getCachedFileInfo( + contentHash: string, + scope: "c2c" | "group", + targetId: string, + fileType: number, +): string | null { + const key = buildCacheKey(contentHash, scope, targetId, fileType); + const entry = cache.get(key); + + if (!entry) return null; + + if (Date.now() >= entry.expiresAt) { + cache.delete(key); + return null; + } + + debugLog(`[upload-cache] Cache HIT: key=${key.slice(0, 40)}..., fileUuid=${entry.fileUuid}`); + return entry.fileInfo; +} + +/** Store an upload result in the cache. */ +export function setCachedFileInfo( + contentHash: string, + scope: "c2c" | "group", + targetId: string, + fileType: number, + fileInfo: string, + fileUuid: string, + ttl: number, +): void { + if (cache.size >= MAX_CACHE_SIZE) { + const now = Date.now(); + for (const [k, v] of cache) { + if (now >= v.expiresAt) { + cache.delete(k); + } + } + if (cache.size >= MAX_CACHE_SIZE) { + const keys = Array.from(cache.keys()); + for (let i = 0; i < keys.length / 2; i++) { + cache.delete(keys[i]!); + } + } + } + + const key = buildCacheKey(contentHash, scope, targetId, fileType); + const safetyMargin = 60; + const effectiveTtl = Math.max(ttl - safetyMargin, 10); + + cache.set(key, { + fileInfo, + fileUuid, + expiresAt: Date.now() + effectiveTtl * 1000, + }); + + debugLog( + `[upload-cache] Cache SET: key=${key.slice(0, 40)}..., ttl=${effectiveTtl}s, uuid=${fileUuid}`, + ); +} + +/** Return cache stats for diagnostics. */ +export function getUploadCacheStats(): { size: number; maxSize: number } { + return { size: cache.size, maxSize: MAX_CACHE_SIZE }; +} + +/** Clear the upload cache. */ +export function clearUploadCache(): void { + cache.clear(); + debugLog(`[upload-cache] Cache cleared`); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f45d7e4a408..c7633cf96f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -560,6 +560,25 @@ importers: extensions/qianfan: {} + extensions/qqbot: + dependencies: + mpg123-decoder: + specifier: ^1.0.3 + version: 1.0.3 + silk-wasm: + specifier: ^3.7.1 + version: 3.7.1 + ws: + specifier: ^8.18.0 + version: 8.20.0 + devDependencies: + '@types/ws': + specifier: ^8.5.0 + version: 8.18.1 + openclaw: + specifier: workspace:* + version: link:../.. + extensions/sglang: {} extensions/signal: {} @@ -2029,14 +2048,12 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-gnu@0.1.97': resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.92': resolution: {integrity: sha512-xV0GQnukYq5qY+ebkAwHjnP2OrSGBxS3vSi1zQNQj0bkXU6Ou+Tw7JjCM7pZcQ28MUyEBS1yKfo7rc7ip2IPFQ==} @@ -4538,7 +4555,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported google-auth-library@10.6.2: resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} @@ -4965,7 +4982,6 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5994,6 +6010,10 @@ packages: peerDependencies: signal-polyfill: ^0.2.0 + silk-wasm@3.7.1: + resolution: {integrity: sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==} + engines: {node: '>=16.11.0'} + simple-git@3.33.0: resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==} @@ -7756,8 +7776,7 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@eshaz/web-worker@1.2.2': - optional: true + '@eshaz/web-worker@1.2.2': {} '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': optionalDependencies: @@ -10066,7 +10085,6 @@ snapshots: dependencies: '@eshaz/web-worker': 1.2.2 simple-yenc: 1.0.4 - optional: true '@wasm-audio-decoders/flac@0.2.10': dependencies: @@ -11862,7 +11880,6 @@ snapshots: mpg123-decoder@1.0.3: dependencies: '@wasm-audio-decoders/common': 9.0.7 - optional: true mri@1.2.0: optional: true @@ -12807,6 +12824,8 @@ snapshots: dependencies: signal-polyfill: 0.2.2 + silk-wasm@3.7.1: {} + simple-git@3.33.0: dependencies: '@kwsites/file-exists': 1.1.1 @@ -12817,8 +12836,7 @@ snapshots: simple-xml-to-json@1.2.4: {} - simple-yenc@1.0.4: - optional: true + simple-yenc@1.0.4: {} sirv@3.0.2: dependencies: diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 53e45bbd6fa..4a46adc488d 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -8827,6 +8827,301 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ additionalProperties: false, }, }, + { + pluginId: "qqbot", + channelId: "qqbot", + label: "QQ Bot", + description: + "connect to QQ via official QQ Bot API with group chat and direct message support.", + schema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + enabled: { + type: "boolean", + }, + name: { + type: "string", + }, + appId: { + type: "string", + }, + clientSecret: { + anyOf: [ + { + type: "string", + }, + { + oneOf: [ + { + type: "object", + properties: { + source: { + type: "string", + const: "env", + }, + provider: { + type: "string", + pattern: "^[a-z][a-z0-9_-]{0,63}$", + }, + id: { + type: "string", + pattern: "^[A-Z][A-Z0-9_]{0,127}$", + }, + }, + required: ["source", "provider", "id"], + additionalProperties: false, + }, + { + type: "object", + properties: { + source: { + type: "string", + const: "file", + }, + provider: { + type: "string", + pattern: "^[a-z][a-z0-9_-]{0,63}$", + }, + id: { + type: "string", + }, + }, + required: ["source", "provider", "id"], + additionalProperties: false, + }, + { + type: "object", + properties: { + source: { + type: "string", + const: "exec", + }, + provider: { + type: "string", + pattern: "^[a-z][a-z0-9_-]{0,63}$", + }, + id: { + type: "string", + }, + }, + required: ["source", "provider", "id"], + additionalProperties: false, + }, + ], + }, + ], + }, + clientSecretFile: { + type: "string", + }, + allowFrom: { + type: "array", + items: { + anyOf: [ + { + type: "string", + }, + { + type: "number", + }, + ], + }, + }, + systemPrompt: { + type: "string", + }, + markdownSupport: { + type: "boolean", + }, + voiceDirectUploadFormats: { + type: "array", + items: { + type: "string", + }, + }, + audioFormatPolicy: { + type: "object", + properties: { + sttDirectFormats: { + type: "array", + items: { + type: "string", + }, + }, + uploadDirectFormats: { + type: "array", + items: { + type: "string", + }, + }, + transcodeEnabled: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + urlDirectUpload: { + type: "boolean", + }, + upgradeUrl: { + type: "string", + }, + upgradeMode: { + type: "string", + enum: ["doc", "hot-reload"], + }, + accounts: { + type: "object", + properties: {}, + additionalProperties: { + type: "object", + properties: { + enabled: { + type: "boolean", + }, + name: { + type: "string", + }, + appId: { + type: "string", + }, + clientSecret: { + anyOf: [ + { + type: "string", + }, + { + oneOf: [ + { + type: "object", + properties: { + source: { + type: "string", + const: "env", + }, + provider: { + type: "string", + pattern: "^[a-z][a-z0-9_-]{0,63}$", + }, + id: { + type: "string", + pattern: "^[A-Z][A-Z0-9_]{0,127}$", + }, + }, + required: ["source", "provider", "id"], + additionalProperties: false, + }, + { + type: "object", + properties: { + source: { + type: "string", + const: "file", + }, + provider: { + type: "string", + pattern: "^[a-z][a-z0-9_-]{0,63}$", + }, + id: { + type: "string", + }, + }, + required: ["source", "provider", "id"], + additionalProperties: false, + }, + { + type: "object", + properties: { + source: { + type: "string", + const: "exec", + }, + provider: { + type: "string", + pattern: "^[a-z][a-z0-9_-]{0,63}$", + }, + id: { + type: "string", + }, + }, + required: ["source", "provider", "id"], + additionalProperties: false, + }, + ], + }, + ], + }, + clientSecretFile: { + type: "string", + }, + allowFrom: { + type: "array", + items: { + anyOf: [ + { + type: "string", + }, + { + type: "number", + }, + ], + }, + }, + systemPrompt: { + type: "string", + }, + markdownSupport: { + type: "boolean", + }, + voiceDirectUploadFormats: { + type: "array", + items: { + type: "string", + }, + }, + audioFormatPolicy: { + type: "object", + properties: { + sttDirectFormats: { + type: "array", + items: { + type: "string", + }, + }, + uploadDirectFormats: { + type: "array", + items: { + type: "string", + }, + }, + transcodeEnabled: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + urlDirectUpload: { + type: "boolean", + }, + upgradeUrl: { + type: "string", + }, + upgradeMode: { + type: "string", + enum: ["doc", "hot-reload"], + }, + }, + additionalProperties: false, + }, + }, + defaultAccount: { + type: "string", + }, + }, + additionalProperties: false, + }, + }, { pluginId: "signal", channelId: "signal", @@ -13215,7 +13510,7 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, "execApprovals.approvers": { label: "Telegram Exec Approval Approvers", - help: "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.", + help: "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from channels.telegram.allowFrom and direct-message defaultTo when possible.", }, "execApprovals.agentFilter": { label: "Telegram Exec Approval Agent Filter",