feat(qqbot): group chat support, C2C streaming, chunked media upload, and architecture refactor (#70624)

* feat(qqbot): implement unified media upload handling and introduce chunked upload support

This commit enhances the media upload functionality by introducing a unified `sendMedia` method that consolidates the previous separate methods for sending images, voice messages, videos, and files. Key changes include:

- Added `uploadChunked` function for future chunked media uploads, currently marked as not implemented.
- Introduced `MediaSource` abstraction to handle various media types (URLs, base64, local files, buffers) uniformly.
- Updated existing media handling logic to utilize the new `sendMedia` method, ensuring consistent media processing across different types.
- Enhanced error handling and validation for media uploads, including MIME type checks and file size limits.

These changes aim to streamline the media upload process and prepare for future enhancements in handling larger files through chunked uploads.

* feat(qqbot): enhance media upload capabilities with chunked upload support

This commit updates the media upload functionality by implementing chunked upload support for larger files. Key changes include:

- Revised the `SKILL.md` documentation to clarify media file size limits and local file path requirements.
- Introduced a new test suite for the chunked media upload functionality, ensuring robust error handling and upload processes.
- Updated the media handling logic to enforce per-file-type upload ceilings, allowing for seamless integration of chunked uploads.
- Enhanced error handling for daily upload limits, providing user-friendly messages when limits are exceeded.

These improvements aim to streamline the media upload process and accommodate larger files effectively.

* feat(qqbot): add C2C streaming API support for message delivery

This commit introduces support for the QQ C2C official `stream_messages` API, enabling single-message typing-style updates. Key changes include:

- Updated the configuration schema to include a new `c2cStreamApi` boolean option for enabling the C2C streaming API.
- Enhanced the `QQBotAccountConfig` interface to accommodate the new streaming option.
- Implemented a `StreamingController` to manage the lifecycle of C2C stream messages, ensuring proper handling of media tags and message boundaries.
- Updated the outbound dispatch logic to utilize the new streaming capabilities, allowing for more dynamic message delivery in one-to-one chats.

These enhancements aim to improve the responsiveness and interactivity of message delivery within the QQBot framework.

* feat(qqbot): implement group chat support and unify adapter/DI architecture

- Implement group message history tracking with pending history buffer
  (record on skip, render on @-mention reply)
- Add mention detection and gating: explicit @bot, implicit quote-reply,
  ignoreOtherMentions, configurable activation mode (mention/always)
- Add group activation resolution with session store persistence
- Add message queue with per-peer FIFO and group message merging
  (batch multiple rapid messages into one merged payload)
- Add deliver debounce to merge rapid outbound text bursts into
  single messages, with media flush and maxWait cap
- Add group config resolution: per-group prompt, history limit,
  wildcard and specific group overrides
- Enrich history attachments with local paths from processAttachments
  so that history context renders downloaded paths instead of ephemeral
  QQ CDN URLs

- Merge ports/ directory into adapter/ as single entry point
- Expand EngineAdapters to 5 required ports: history, mentionGate,
  audioConvert, outboundAudio, commands
- Remove global register/get singletons in favor of constructor
  injection and one-time init
- Add createEngineAdapters() in bridge/gateway.ts as single assembly point

- Extract monolithic buildInboundContext into 11 discrete stages:
  access, content, quote, refidx, group-gate, envelope, assembly
- Extract group chat modules: history, mention, activation,
  message-gating, deliver-debounce
- Extract config/group.ts, utils/attachment-tags.ts

* feat(qqbot): add /bot-streaming command for C2C message streaming control

This commit introduces the `/bot-streaming` command, allowing users to enable or disable streaming for message delivery in C2C chats. Key changes include:

- Implementation of the `isStreamingConfigEnabled` function to check the current streaming configuration.
- Command handler for `/bot-streaming` that provides usage instructions and manages the streaming state.
- Updates to the command's response messages to inform users of the current streaming status and how to toggle it.

These enhancements aim to improve user experience by providing a straightforward way to manage streaming message delivery in private chats.

* feat(qqbot): extract interaction handler and add remote config query/update support

- Extract INTERACTION_CREATE handler from gateway.ts into a dedicated
  interaction-handler.ts module for better separation of concerns
- Add config query (type=2001) and config update (type=2002) interaction
  branches that read/write claw_cfg via runtime.config API
- Register INTERACTION intent (1<<26) in FULL_INTENTS to receive
  INTERACTION_CREATE events from the gateway
- Add InteractionType constants (CONFIG_QUERY, CONFIG_UPDATE)
- Extend GatewayPluginRuntime with optional config API (loadConfig,
  writeConfigFile) for interaction handler access
- Add QQBotAccountConfigView interface for typed config field access
- Extend acknowledgeInteraction to accept optional data payload for
  rich ACK responses (e.g. claw_cfg snapshot)
- Export getFrameworkVersion from slash-commands-impl for version
  reporting in config snapshots
- Remove unused eslint-disable directive in streaming-media-send.ts

* feat(qqbot): enhance account management and logging capabilities

- Introduced `toGatewayAccount` function to map resolved QQBot accounts to the engine's gateway account structure.
- Added `persistAccountCredentialSnapshot` function to streamline credential backup during gateway events.
- Updated the `qqbotPlugin` to utilize the new account mapping and credential persistence functions, improving the handling of account data.
- Enhanced logging functionality by modifying the `EngineLogger` interface to support metadata in log messages.
- Implemented new commands for managing logs and clearing storage, providing users with better control over their data and system resources.
- Registered multiple built-in commands for improved user interaction, including `/bot-logs` for exporting logs and `/bot-clear-storage` for managing downloaded files.
- Updated configuration schemas to reflect new options and improve clarity for users.

* fix(qqbot): resolve oxlint errors and update raw-fetch allowlist

- Replace unnecessary `else` after `return` in outbound-media-send.ts (6 occurrences)
- Use `Number.parseInt` instead of global `parseInt` in outbound.ts and streaming-media-send.ts
- Use `Number.isNaN` instead of global `isNaN` in register-basic.ts
- Prefer `**` over `Math.pow` in media-chunked.ts
- Convert interface with call signature to function type in commands.port.ts
- Update api-client.ts allowlist line number (108→124) and add media-chunked.ts:552 to raw-fetch allowlist

* docs(qqbot): translate streaming-c2c.ts header comments to English

* feat(qqbot): add voiceMediaTypes

* feat: restore dispatch changes

* fix(qqbot): align test files with updated engine interfaces after rebase

- inbound-attachments.test: replace removed registerAudioConvertAdapter
  with AudioConvertPort, pass audioConvert in ProcessContext
- inbound-pipeline.self-echo.test: add required adapters field to
  InboundPipelineDeps mock (history, mentionGate, audioConvert,
  outboundAudio, commands)
- outbound-dispatch.test: add required skipped field to InboundContext

* fix(qqbot): update test assertions to match refactored engine interfaces

- inbound-pipeline.self-echo.test: self-echo blocking was moved upstream;
  update test to expect non-blocked pipeline behavior
- outbound-dispatch.test: TTS voice path now uses unified sendMedia
  instead of sendVoiceMessage; add sendMedia mock and update assertion
- format-ref-entry.test: attachment format changed from [image: ...]
  to MEDIA: tag syntax via renderAttachmentTags; update expected output

* refactor(qqbot): migrate from deprecated config API to current/replaceConfigFile

Replace all usages of deprecated runtime config methods:
- loadConfig() → current()
- writeConfigFile(cfg) → replaceConfigFile({ nextConfig, afterWrite })

Updated files:
- bridge/narrowing.ts: writeOpenClawConfigThroughRuntime
- adapter/commands.port.ts: ApproveRuntimeGetter type signature
- commands/builtin/register-approve.ts: loadExecConfig, writeExecConfig, reset
- commands/builtin/register-streaming.ts: config read/write
- gateway/interaction-handler.ts: config query/update handlers
- gateway/types.ts: GatewayPluginRuntime.config interface

* feat(qqbot): update package.json

* fix(qqbot): replace deprecated config-runtime import with config-types subpath

Bundled plugin lint requires focused plugin-sdk subpaths.
- gateway.ts: openclaw/plugin-sdk/config-runtime → config-types
- narrowing.ts: openclaw/plugin-sdk/config-runtime → config-types

* feat(qqbot): group chat support, C2C streaming, chunked media upload, and architecture refactor (#70624) (thanks @cxyhhhhh)

---------

Co-authored-by: Bobby <zkd8907@live.com>
Co-authored-by: sliverp <870080352@qq.com>
This commit is contained in:
cxy
2026-04-27 23:19:12 +08:00
committed by GitHub
parent 8304635258
commit 5ccf179a34
89 changed files with 11978 additions and 3105 deletions

View File

@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay.
- Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh.
## 2026.4.26

View File

@@ -91,6 +91,10 @@
"type": "string",
"enum": ["off", "partial"],
"default": "partial"
},
"c2cStreamApi": {
"type": "boolean",
"description": "Use QQ C2C official stream_messages API (single-message typing-style updates)."
}
}
}
@@ -136,6 +140,10 @@
"type": "string",
"enum": ["off", "partial"],
"default": "partial"
},
"c2cStreamApi": {
"type": "boolean",
"description": "Use QQ C2C official stream_messages API (single-message typing-style updates)."
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/qqbot",
"version": "2026.4.25",
"version": "2026.4.26",
"private": false,
"description": "OpenClaw QQ Bot channel plugin",
"type": "module",
@@ -17,7 +17,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.25"
"openclaw": ">=2026.4.26"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -46,10 +46,10 @@
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.4.25"
"pluginApi": ">=2026.4.26"
},
"build": {
"openclawVersion": "2026.4.25"
"openclawVersion": "2026.4.26"
},
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -29,8 +29,9 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq
1. **路径必须是绝对路径**(以 `/``http` 开头)
2. **标签必须用开闭标签包裹路径**`<qqmedia>路径</qqmedia>`
3. **文件大小上限 10MB**
4. **你有能力发送本地图片/文件**,直接用标签包裹路径即可,**不要说"无法发送"**
5. 发送语音时不要重复语音中已朗读的文字
6. 多个媒体用多个标签
7. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)
3. **待发送的本地文件须落在 OpenClaw 媒体目录下**:生成、下载或复制出的文件应写入 **`~/.openclaw/media/qqbot/`**(或其子目录),再写进 `<qqmedia>`。不要只放在 `~/.openclaw/workspace/` 等工作区根目录——平台安全策略只允许从 `~/.openclaw/media/`(含 `media/qqbot`)等受信根路径上传,否则会拦截、发不出去。
4. **文件大小上限**:图片 30MB / 视频 100MB / 文件 100MB / 语音 20MB
5. **你有能力发送本地图片/文件**,直接用标签包裹路径即可,**不要说"无法发送"**
6. 发送语音时不要重复语音中已朗读的文字
7. 多个媒体用多个标签
8. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)

View File

@@ -37,9 +37,17 @@ export function resolveQQBotAccount(
const base = resolveAccountBase(raw, accountId);
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
/**
* Legacy top-level account uses `channels.qqbot` as the base, but per-account
* fields (allowFrom, streaming, …) often live under `accounts.default`.
* Merge that slice so runtime sees `config.streaming` etc.
*/
const accountConfig: QQBotAccountConfig =
base.accountId === DEFAULT_ACCOUNT_ID
? (qqbot ?? {})
? {
...qqbot,
...qqbot?.accounts?.[DEFAULT_ACCOUNT_ID],
}
: (qqbot?.accounts?.[base.accountId] ?? {});
let clientSecret = "";

View File

@@ -1,78 +1,41 @@
/**
* Gateway entry point — thin shell that passes the PluginRuntime to
* core/gateway/gateway.ts.
* Gateway entry point — thin bridge shell that constructs
* {@link EngineAdapters} and passes them to the engine's
* `startGateway`.
*
* All module dependencies are imported directly by the core gateway.
* This file only provides the runtime object (which is dynamically
* injected by the framework at startup).
* All adapter dependencies are assembled here in one place.
*/
import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import {
registerVersionResolver,
registerPluginVersion,
registerApproveRuntimeGetter,
} from "../engine/commands/slash-commands-impl.js";
import type { EngineAdapters } from "../engine/adapter/index.js";
import {
startGateway as coreStartGateway,
type CoreGatewayContext,
} from "../engine/gateway/gateway.js";
import type { GatewayAccount } from "../engine/gateway/types.js";
import { registerOutboundAudioAdapterFactory } from "../engine/messaging/outbound.js";
import type { GatewayPluginRuntime } from "../engine/gateway/types.js";
import { initSender, registerAccount } from "../engine/messaging/sender.js";
import type { EngineLogger } from "../engine/types.js";
import * as _audioModule from "../engine/utils/audio.js";
import { formatDuration } from "../engine/utils/format.js";
import { debugLog, debugError } from "../engine/utils/log.js";
import { registerTextChunker } from "../engine/utils/text-chunk.js";
import type { ResolvedQQBotAccount } from "../types.js";
import { ensurePlatformAdapter } from "./bootstrap.js";
import { setBridgeLogger } from "./logger.js";
import { toGatewayAccount } from "./narrowing.js";
import { resolveQQBotPluginVersion } from "./plugin-version.js";
import { getQQBotRuntime, getQQBotRuntimeForEngine } from "./runtime.js";
import { createSdkHistoryAdapter, createSdkMentionGateAdapter } from "./sdk-adapter.js";
// Register framework SDK version resolver for core/ slash commands.
registerVersionResolver(resolveRuntimeServiceVersion);
// ---- One-time startup initialization (module-level) ----
// Inject plugin + framework versions into sender and into the slash
// command registry. The plugin version is read from this plugin's own
// `package.json` by walking up from this file's URL, which is robust
// against source-vs-dist layout differences.
const _pluginVersion = resolveQQBotPluginVersion(import.meta.url);
initSender({
pluginVersion: _pluginVersion,
openclawVersion: resolveRuntimeServiceVersion(),
});
registerPluginVersion(_pluginVersion);
// Register runtime getter for /bot-approve config management.
registerApproveRuntimeGetter(() => {
const rt = getQQBotRuntime();
return {
config: rt.config as {
current: () => Record<string, unknown>;
replaceConfigFile: (params: {
nextConfig: Record<string, unknown>;
afterWrite: { mode: "auto" };
}) => Promise<unknown>;
},
};
});
// Register audio adapter factory so outbound.sendMedia can lazy-init even
// when startGateway() hasn't run yet (bundler chunk-splitting scenario).
registerOutboundAudioAdapterFactory(() => {
// Use a synchronous require-like approach: the audio module should already
// be loaded by the time the factory is invoked (gateway has started).
// We import it at the top and reference it here.
return {
audioFileToSilkBase64: async (p: string, f?: string[]) =>
(await _audioModule.audioFileToSilkBase64(p, f)) ?? undefined,
isAudioFile: (p: string, m?: string) => _audioModule.isAudioFile(p, m),
shouldTranscodeVoice: (p: string) => _audioModule.shouldTranscodeVoice(p),
waitForFile: (p: string, ms?: number) => _audioModule.waitForFile(p, ms),
};
});
// ============ Public types ============
export interface GatewayContext {
account: ResolvedQQBotAccount;
@@ -99,32 +62,62 @@ export interface GatewayContext {
};
}
// ============ Adapter factory ============
/**
* Create the full set of engine adapters from the bridge layer.
*
* This is the **single assembly point** — all SDK → engine binding
* happens here. The engine receives a fully-populated
* {@link EngineAdapters} object with zero global singletons.
*/
function createEngineAdapters(_runtime: GatewayPluginRuntime): EngineAdapters {
return {
history: createSdkHistoryAdapter(),
mentionGate: createSdkMentionGateAdapter(),
audioConvert: {
convertSilkToWav: _audioModule.convertSilkToWav,
isVoiceAttachment: _audioModule.isVoiceAttachment,
formatDuration,
},
outboundAudio: {
audioFileToSilkBase64: async (p: string, f?: string[]) =>
(await _audioModule.audioFileToSilkBase64(p, f)) ?? undefined,
isAudioFile: (p: string, m?: string) => _audioModule.isAudioFile(p, m),
shouldTranscodeVoice: (p: string) => _audioModule.shouldTranscodeVoice(p),
waitForFile: (p: string, ms?: number) => _audioModule.waitForFile(p, ms),
},
commands: {
resolveVersion: resolveRuntimeServiceVersion,
pluginVersion: _pluginVersion,
approveRuntimeGetter: () => {
const rt = getQQBotRuntime();
return { config: rt.config };
},
},
};
}
// ============ startGateway ============
/**
* Start the Gateway WebSocket connection.
*
* Passes the PluginRuntime to core/gateway/gateway.ts.
* All other dependencies are imported directly by the core module.
* Assembles all adapters and passes them to the engine's core gateway.
*/
export async function startGateway(ctx: GatewayContext): Promise<void> {
// Ensure the PlatformAdapter is registered before any engine code runs.
// When the bundler splits code into separate chunks, bootstrap.ts's
// side-effect registration may not have executed yet at this point.
ensurePlatformAdapter();
const runtime = getQQBotRuntimeForEngine();
// Create per-account logger with auto [qqbot:{accountId}] prefix.
const accountLogger = createAccountLogger(ctx.log, ctx.account.accountId);
// Register into engine sender (per-appId logger + API config) and bridge layer.
// Per-account registration (still global — sender is a leaf utility).
registerAccount(ctx.account.appId, {
logger: accountLogger,
markdownSupport: ctx.account.markdownSupport,
});
setBridgeLogger(accountLogger);
registerTextChunker((text, limit) => runtime.channel.text.chunkMarkdownText(text, limit));
if (ctx.channelRuntime) {
accountLogger.info("Registering approval.native runtime context");
const lease = ctx.channelRuntime.runtimeContexts.register({
@@ -140,7 +133,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
}
const coreCtx: CoreGatewayContext = {
account: ctx.account as unknown as GatewayAccount,
account: toGatewayAccount(ctx.account),
abortSignal: ctx.abortSignal,
cfg: ctx.cfg,
onReady: ctx.onReady,
@@ -148,6 +141,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
onError: ctx.onError,
log: accountLogger,
runtime,
adapters: createEngineAdapters(runtime),
};
return coreStartGateway(coreCtx);
@@ -155,29 +149,26 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
// ============ Per-account logger factory ============
/**
* Create an EngineLogger that auto-prefixes all messages with `[qqbot:{accountId}]`.
*
* Follows the WhatsApp pattern of per-connection loggers — each account gets
* its own logger instance so multi-account logs are automatically attributed.
*/
function createAccountLogger(
raw: GatewayContext["log"] | undefined,
accountId: string,
): EngineLogger {
const prefix = `[${accountId}]`;
const withMeta = (msg: string, meta?: Record<string, unknown>) =>
meta && Object.keys(meta).length > 0 ? `${msg} ${JSON.stringify(meta)}` : msg;
if (!raw) {
return {
info: (msg) => debugLog(`${prefix} ${msg}`),
error: (msg) => debugError(`${prefix} ${msg}`),
warn: (msg) => debugError(`${prefix} ${msg}`),
debug: (msg) => debugLog(`${prefix} ${msg}`),
info: (msg, meta) => debugLog(`${prefix} ${withMeta(msg, meta)}`),
error: (msg, meta) => debugError(`${prefix} ${withMeta(msg, meta)}`),
warn: (msg, meta) => debugError(`${prefix} ${withMeta(msg, meta)}`),
debug: (msg, meta) => debugLog(`${prefix} ${withMeta(msg, meta)}`),
};
}
return {
info: (msg) => raw.info(`${prefix} ${msg}`),
error: (msg) => raw.error(`${prefix} ${msg}`),
warn: (msg) => raw.error(`${prefix} ${msg}`),
debug: (msg) => raw.debug?.(`${prefix} ${msg}`),
info: (msg, meta) => raw.info(`${prefix} ${withMeta(msg, meta)}`),
error: (msg, meta) => raw.error(`${prefix} ${withMeta(msg, meta)}`),
warn: (msg, meta) => raw.error(`${prefix} ${withMeta(msg, meta)}`),
debug: (msg, meta) => raw.debug?.(`${prefix} ${withMeta(msg, meta)}`),
};
}

View File

@@ -0,0 +1,31 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import type { GatewayAccount } from "../engine/types.js";
import type { ResolvedQQBotAccount } from "../types.js";
/**
* Map resolved plugin account to the engine gateway account shape (single assertion on nested config).
*/
export function toGatewayAccount(account: ResolvedQQBotAccount): GatewayAccount {
return {
accountId: account.accountId,
appId: account.appId,
clientSecret: account.clientSecret,
markdownSupport: account.markdownSupport,
systemPrompt: account.systemPrompt,
config: account.config as GatewayAccount["config"],
};
}
/**
* Persist OpenClaw config through the injected plugin runtime (typed entry point).
*/
export async function writeOpenClawConfigThroughRuntime(
runtime: PluginRuntime,
cfg: OpenClawConfig,
): Promise<void> {
await runtime.config.replaceConfigFile({
nextConfig: cfg,
afterWrite: { mode: "auto" },
});
}

View File

@@ -3,6 +3,7 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { GatewayPluginRuntime } from "../engine/gateway/types.js";
import { setOpenClawVersion } from "../engine/messaging/sender.js";
// Single plugin runtime per process — concurrent multi-tenant qqbot runtimes are not supported.
const { setRuntime: _setRuntime, getRuntime: getQQBotRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "qqbot",
@@ -20,5 +21,5 @@ export { getQQBotRuntime, setQQBotRuntime };
/** Type-narrowed getter for engine/ modules that need GatewayPluginRuntime. */
export function getQQBotRuntimeForEngine(): GatewayPluginRuntime {
return getQQBotRuntime() as unknown as GatewayPluginRuntime;
return getQQBotRuntime() as GatewayPluginRuntime;
}

View File

@@ -0,0 +1,131 @@
/**
* SDK adapter — binds engine port interfaces to the framework's shared
* SDK implementations.
*
* This file lives in bridge/ (not engine/) because it imports from
* `openclaw/plugin-sdk/*`. The engine layer stays zero-SDK-dependency;
* only the bridge layer couples to the framework.
*/
import {
implicitMentionKindWhen,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-mention-gating";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
recordPendingHistoryEntryIfEnabled,
type HistoryEntry as SdkHistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
import type { HistoryPort, HistoryEntryLike } from "../engine/adapter/history.port.js";
import type {
MentionGatePort,
MentionGateDecision,
MentionFacts,
MentionPolicy,
} from "../engine/adapter/mention-gate.port.js";
// ============ History Adapter ============
// Helper: cast engine Map to SDK Map. TypeScript Map is invariant on its
// value type, but the shapes are structurally identical (HistoryEntryLike
// ⊇ SdkHistoryEntry). The `as unknown as` double-cast is safe here.
function asSdkMap<T>(map: Map<string, T[]>): Map<string, SdkHistoryEntry[]> {
return map as unknown as Map<string, SdkHistoryEntry[]>;
}
/**
* History adapter backed by SDK `reply-history`.
*
* Delegates record/build/clear to the SDK's shared implementation so
* the engine benefits from SDK improvements (e.g. future visibility
* filtering) without code duplication.
*/
export function createSdkHistoryAdapter(): HistoryPort {
return {
recordPendingHistoryEntry<T extends HistoryEntryLike>(params: {
historyMap: Map<string, T[]>;
historyKey: string;
entry?: T | null;
limit: number;
}): T[] {
return recordPendingHistoryEntryIfEnabled({
historyMap: asSdkMap(params.historyMap),
historyKey: params.historyKey,
entry: params.entry as SdkHistoryEntry | undefined,
limit: params.limit,
}) as T[];
},
buildPendingHistoryContext(params: {
historyMap: Map<string, HistoryEntryLike[]>;
historyKey: string;
limit: number;
currentMessage: string;
formatEntry: (entry: HistoryEntryLike) => string;
lineBreak?: string;
}): string {
return buildPendingHistoryContextFromMap({
historyMap: asSdkMap(params.historyMap),
historyKey: params.historyKey,
limit: params.limit,
currentMessage: params.currentMessage,
formatEntry: params.formatEntry as (entry: SdkHistoryEntry) => string,
lineBreak: params.lineBreak,
});
},
clearPendingHistory(params: {
historyMap: Map<string, HistoryEntryLike[]>;
historyKey: string;
limit: number;
}): void {
clearHistoryEntriesIfEnabled({
historyMap: asSdkMap(params.historyMap),
historyKey: params.historyKey,
limit: params.limit,
});
},
};
}
// ============ MentionGate Adapter ============
/**
* MentionGate adapter backed by SDK `channel-mention-gating`.
*
* Maps the engine's mention facts/policy to the SDK's
* `resolveInboundMentionDecision` call, normalizing the implicit
* mention boolean into the SDK's typed `ImplicitMentionKind[]`.
*/
export function createSdkMentionGateAdapter(): MentionGatePort {
return {
resolveInboundMentionDecision(params: {
facts: MentionFacts;
policy: MentionPolicy;
}): MentionGateDecision {
const result = resolveInboundMentionDecision({
facts: {
canDetectMention: params.facts.canDetectMention,
wasMentioned: params.facts.wasMentioned,
hasAnyMention: params.facts.hasAnyMention,
implicitMentionKinds:
params.facts.implicitMentionKinds ?? implicitMentionKindWhen("reply_to_bot", false),
},
policy: {
isGroup: params.policy.isGroup,
requireMention: params.policy.requireMention,
allowTextCommands: params.policy.allowTextCommands,
hasControlCommand: params.policy.hasControlCommand,
commandAuthorized: params.policy.commandAuthorized,
},
});
return {
effectiveWasMentioned: result.effectiveWasMentioned,
shouldSkip: result.shouldSkip,
shouldBypassMention: result.shouldBypassMention,
implicitMention: result.implicitMention,
};
},
};
}

View File

@@ -10,6 +10,8 @@ import {
DEFAULT_ACCOUNT_ID,
resolveQQBotAccount,
} from "./bridge/config.js";
import type { GatewayContext } from "./bridge/gateway.js";
import { toGatewayAccount, writeOpenClawConfigThroughRuntime } from "./bridge/narrowing.js";
import { getQQBotRuntime } from "./bridge/runtime.js";
import { qqbotSetupWizard } from "./bridge/setup/surface.js";
import { qqbotChannelConfigSchema } from "./config-schema.js";
@@ -34,6 +36,12 @@ function loadGatewayModule(): Promise<typeof import("./bridge/gateway.js")> {
const EXEC_APPROVAL_COMMAND_RE =
/\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(?:allow-once|allow-always|always|deny)\b/i;
function persistAccountCredentialSnapshot(account: ResolvedQQBotAccount): void {
if (account.appId && account.clientSecret) {
saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
}
}
function shouldSuppressLocalQQBotApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -119,7 +127,13 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
await loadGatewayModule();
const account = resolveQQBotAccount(cfg, accountId);
const { sendText } = await import("./engine/messaging/outbound.js");
const result = await sendText({ to, text, accountId, replyToId, account: account as never });
const result = await sendText({
to,
text,
accountId,
replyToId,
account: toGatewayAccount(account),
});
return {
channel: "qqbot" as const,
messageId: result.messageId ?? "",
@@ -137,7 +151,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
mediaUrl: mediaUrl ?? "",
accountId,
replyToId,
account: account as never,
account: toGatewayAccount(account),
});
return {
channel: "qqbot" as const,
@@ -163,11 +177,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
appId: backup.appId,
clientSecret: backup.clientSecret,
});
const runtime = getQQBotRuntime();
await runtime.config.replaceConfigFile({
nextConfig: nextCfg,
afterWrite: { mode: "auto" },
});
await writeOpenClawConfigThroughRuntime(getQQBotRuntime(), nextCfg);
cfg = nextCfg;
account = resolveQQBotAccount(nextCfg, account.accountId);
log?.info(
@@ -195,7 +205,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
abortSignal,
cfg,
log,
channelRuntime: ctx.channelRuntime as never,
channelRuntime: ctx.channelRuntime as GatewayContext["channelRuntime"],
onReady: () => {
log?.info(`[qqbot:${account.accountId}] Gateway ready`);
ctx.setStatus({
@@ -206,9 +216,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
});
// Snapshot credentials so we can recover from the next hot
// upgrade that might wipe openclaw.json mid-flight.
if (account.appId && account.clientSecret) {
saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
}
persistAccountCredentialSnapshot(account);
},
onResumed: () => {
log?.info(`[qqbot:${account.accountId}] Gateway resumed`);
@@ -218,9 +226,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
connected: true,
lastConnectedAt: Date.now(),
});
if (account.appId && account.clientSecret) {
saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
}
persistAccountCredentialSnapshot(account);
},
onError: (error) => {
log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
@@ -238,11 +244,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
);
if (changed) {
const runtime = getQQBotRuntime();
await runtime.config.replaceConfigFile({
nextConfig: nextCfg as OpenClawConfig,
afterWrite: { mode: "auto" },
});
await writeOpenClawConfigThroughRuntime(getQQBotRuntime(), nextCfg as OpenClawConfig);
}
const resolved = resolveQQBotAccount((changed ? nextCfg : cfg) as OpenClawConfig, accountId);

View File

@@ -24,6 +24,7 @@ const QQBotSttSchema = z
.strict()
.optional();
/** When `true`, same as `mode: "partial"` and `c2cStreamApi: true` for C2C. Object form kept for legacy configs. */
const QQBotStreamingSchema = z
.union([
z.boolean(),
@@ -31,6 +32,8 @@ const QQBotStreamingSchema = z
.object({
/** "partial" (default) enables block streaming; "off" disables it. */
mode: z.enum(["off", "partial"]).default("partial"),
/** Use QQ C2C official stream_messages API; legacy, prefer `streaming: true`. */
c2cStreamApi: z.boolean().optional(),
})
.passthrough(),
])

View File

@@ -0,0 +1,27 @@
/**
* Audio port — abstracts inbound + outbound audio conversion operations.
*
* The engine defines this interface; the bridge layer provides an
* implementation backed by `engine/utils/audio.js` functions.
*/
/** Inbound audio conversion (SILK→WAV, voice detection, duration formatting). */
export interface AudioConvertPort {
convertSilkToWav(
silkPath: string,
outputDir: string,
): Promise<{ wavPath: string; duration: number } | null>;
isVoiceAttachment(att: { content_type: string; filename?: string }): boolean;
formatDuration(seconds: number): string;
}
/** Outbound audio conversion (WAV→SILK, audio detection, transcoding). */
export interface OutboundAudioPort {
audioFileToSilkBase64(
audioPath: string,
directUploadFormats?: string[],
): Promise<string | undefined>;
isAudioFile(pathOrUrl: string, mimeType?: string): boolean;
shouldTranscodeVoice(filePath: string): boolean;
waitForFile(filePath: string, maxWaitMs?: number): Promise<number>;
}

View File

@@ -0,0 +1,22 @@
/**
* Commands port — abstracts slash-command dependencies injected by the
* bridge layer (version resolvers, approve runtime getter).
*
* Eliminates global `register*` singletons in `slash-commands-impl.ts`.
*/
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
/** Runtime getter shape for the `/bot-approve` command. */
export type ApproveRuntimeGetter = () => {
config: Pick<PluginRuntime["config"], "current" | "replaceConfigFile">;
};
export interface CommandsPort {
/** Resolve the framework runtime version string. */
resolveVersion: () => string;
/** Plugin version string (e.g. "1.2.3"). */
pluginVersion: string;
/** Runtime getter for `/bot-approve` config management. */
approveRuntimeGetter?: ApproveRuntimeGetter;
}

View File

@@ -0,0 +1,52 @@
/**
* History port — abstracts the group history cache operations.
*
* The engine defines this interface; the bridge layer provides an
* implementation backed by SDK `reply-history` functions. The engine's
* built-in implementation in `group/history.ts` is used as the default
* when no adapter is injected (standalone build).
*/
/** Minimal history entry shape expected by the port. */
export interface HistoryEntryLike {
sender: string;
body: string;
timestamp?: number;
messageId?: string;
}
export interface HistoryPort {
/**
* Record a non-@ message into the pending history buffer.
* No-op when `limit <= 0` or `entry` is missing.
*/
recordPendingHistoryEntry<T extends HistoryEntryLike>(params: {
historyMap: Map<string, T[]>;
historyKey: string;
entry?: T | null;
limit: number;
}): T[];
/**
* Build the full user-message string prefixed with buffered history.
* Returns `currentMessage` unchanged when no history exists.
*/
buildPendingHistoryContext(params: {
historyMap: Map<string, HistoryEntryLike[]>;
historyKey: string;
limit: number;
currentMessage: string;
formatEntry: (entry: HistoryEntryLike) => string;
lineBreak?: string;
}): string;
/**
* Clear a group's pending history buffer.
* No-op when `limit <= 0`.
*/
clearPendingHistory(params: {
historyMap: Map<string, HistoryEntryLike[]>;
historyKey: string;
limit: number;
}): void;
}

View File

@@ -1,25 +1,71 @@
/**
* Platform adapter interface — abstracts framework-specific capabilities
* so core/ modules remain portable between the built-in and standalone versions.
* Engine adapter layer — all external dependency interfaces unified here.
*
* Each version implements this interface in its own `bootstrap/adapter/` directory
* and calls `registerPlatformAdapter()` during startup.
* This directory is the **single source of truth** for every interface
* the engine uses to talk to the outside world.
*
* core/ modules access platform capabilities via `getPlatformAdapter()`.
* ## Two-layer DI architecture
*
* ## Lazy initialization
* ### Layer 1: EngineAdapters (构造参数注入 — preferred)
*
* When the adapter has not been explicitly registered yet, `getPlatformAdapter()`
* will invoke the factory registered via `registerPlatformAdapterFactory()` to
* create and register the adapter on first access. This eliminates fragile
* dependency on side-effect import ordering — the adapter is guaranteed to be
* available whenever any engine module needs it, regardless of which code path
* triggers the first access.
* Used for capabilities consumed within the pipeline call stack.
* Injected once via {@link CoreGatewayContext.adapters}, threaded
* through {@link InboundPipelineDeps.adapters}, consumed by stages.
*
* - {@link HistoryPort} — group history record/build/clear
* - {@link MentionGatePort} — mention + command gate evaluation
* - {@link AudioConvertPort} — inbound SILK→WAV conversion
* - {@link OutboundAudioPort} — outbound WAV→SILK conversion
* - {@link CommandsPort} — slash-command version/approve dependencies
*
* ### Layer 2: PlatformAdapter (global singleton — leaf utilities)
*
* Used by leaf utility functions (`file-utils`, `image-size`,
* `platform`, `config/resolve`) that sit outside the pipeline and
* cannot receive a `deps` parameter. Registered once at startup.
*
* - {@link PlatformAdapter} — SSRF, secrets, media fetch, temp dir
*/
import type { FetchMediaOptions, FetchMediaResult, SecretInputRef } from "./types.js";
/** Platform adapter that core/ modules use for framework-specific operations. */
// ============ Re-exports (port interfaces) ============
export type { HistoryPort, HistoryEntryLike } from "./history.port.js";
export type {
MentionGatePort,
MentionFacts,
MentionPolicy,
MentionGateDecision,
ImplicitMentionKind,
} from "./mention-gate.port.js";
export type { AudioConvertPort, OutboundAudioPort } from "./audio.port.js";
export type { CommandsPort, ApproveRuntimeGetter } from "./commands.port.js";
// ============ EngineAdapters (aggregated port injection) ============
/**
* Aggregated adapter ports injected via `CoreGatewayContext.adapters`.
*
* All fields are required — the bridge layer must provide every adapter.
* The engine no longer falls back to built-in implementations.
*/
export interface EngineAdapters {
/** Group history record/build/clear — backed by SDK `reply-history`. */
history: import("./history.port.js").HistoryPort;
/** Mention + command gate evaluation — backed by SDK `channel-mention-gating`. */
mentionGate: import("./mention-gate.port.js").MentionGatePort;
/** Inbound audio conversion (SILK→WAV, voice detection). */
audioConvert: import("./audio.port.js").AudioConvertPort;
/** Outbound audio conversion (WAV→SILK, audio detection). */
outboundAudio: import("./audio.port.js").OutboundAudioPort;
/** Slash-command dependencies (version, approve runtime). */
commands: import("./commands.port.js").CommandsPort;
}
// ============ PlatformAdapter (global singleton — leaf utilities) ============
/** Platform adapter that leaf utilities use for framework-specific operations. */
export interface PlatformAdapter {
/** Validate that a remote URL is safe to fetch (SSRF protection). */
validateRemoteUrl(url: string, options?: { allowPrivate?: boolean }): Promise<void>;

View File

@@ -0,0 +1,54 @@
/**
* Mention gate port — abstracts the SDK's `resolveInboundMentionDecision`
* + `resolveControlCommandGate` into a single interface.
*
* The engine's `resolveGroupMessageGate` (Layer 1: ignoreOtherMentions)
* is QQ-specific and stays in `group/message-gating.ts`. Layer 2+3
* (command gating + mention gating + command bypass) delegate to this port.
*/
/** Implicit mention kind aligned with SDK's `InboundImplicitMentionKind`. */
export type ImplicitMentionKind =
| "reply_to_bot"
| "quoted_bot"
| "bot_thread_participant"
| "native";
/** Facts about the current message's mention state. */
export interface MentionFacts {
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention?: boolean;
implicitMentionKinds?: readonly ImplicitMentionKind[];
}
/** Policy configuration for the mention gate. */
export interface MentionPolicy {
isGroup: boolean;
requireMention: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
}
/** Result of the mention gate evaluation. */
export interface MentionGateDecision {
effectiveWasMentioned: boolean;
shouldSkip: boolean;
shouldBypassMention: boolean;
implicitMention: boolean;
}
export interface MentionGatePort {
/**
* Evaluate whether the message should be skipped based on mention
* policy, command bypass, and implicit mention rules.
*
* Equivalent to SDK's `resolveInboundMentionDecision` with the
* command-bypass logic folded in.
*/
resolveInboundMentionDecision(params: {
facts: MentionFacts;
policy: MentionPolicy;
}): MentionGateDecision;
}

View File

@@ -21,6 +21,15 @@ export interface RequestOptions {
timeoutMs?: number;
/** Body keys to redact in debug logs (e.g. `['file_data']`). */
redactBodyKeys?: string[];
/**
* Mark the request as a file-upload call.
*
* Triggers the longer `fileUploadTimeoutMs` (default 120s) instead of the
* standard `defaultTimeoutMs` (default 30s). Prefer this flag over
* inspecting the request path; it keeps the timeout policy independent of
* route naming conventions.
*/
uploadRequest?: boolean;
}
/**
@@ -74,7 +83,14 @@ export class ApiClient {
"User-Agent": this.resolveUserAgent(),
};
const isFileUpload = path.includes("/files");
const isFileUpload =
options?.uploadRequest === true ||
// Back-compat: legacy callers that predate the explicit `uploadRequest`
// flag still get the long timeout when hitting file endpoints. New
// code should always pass `uploadRequest: true` explicitly.
path.includes("/files") ||
path.includes("/upload_prepare") ||
path.includes("/upload_part_finish");
const timeout =
options?.timeoutMs ?? (isFileUpload ? this.fileUploadTimeoutMs : this.defaultTimeoutMs);

View File

@@ -0,0 +1,336 @@
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ApiError,
MediaFileType,
type UploadMediaResponse,
type UploadPrepareResponse,
} from "../types.js";
import type { ApiClient } from "./api-client.js";
import {
ChunkedMediaApi,
UploadDailyLimitExceededError,
isChunkedUploadImplemented,
} from "./media-chunked.js";
import type { UploadCacheAdapter } from "./media.js";
import { UPLOAD_PREPARE_FALLBACK_CODE } from "./retry.js";
import type { TokenManager } from "./token.js";
// ============ Test doubles ============
/** Build a minimal ApiClient stub whose `request` is fully mockable. */
function mockApiClient(): ApiClient & { request: ReturnType<typeof vi.fn> } {
return {
request: vi.fn(),
} as unknown as ApiClient & { request: ReturnType<typeof vi.fn> };
}
/** Minimal TokenManager stub returning a static token. */
function mockTokenManager(token = "test-token"): TokenManager {
return {
getAccessToken: vi.fn().mockResolvedValue(token),
} as unknown as TokenManager;
}
/** In-memory upload-cache adapter. */
function inMemoryCache(): UploadCacheAdapter & {
getSpy: ReturnType<typeof vi.fn>;
setSpy: ReturnType<typeof vi.fn>;
} {
const store = new Map<string, string>();
const getSpy = vi.fn(
(hash: string, scope: string, targetId: string, fileType: number) =>
store.get(`${hash}:${scope}:${targetId}:${fileType}`) ?? null,
);
const setSpy = vi.fn(
(hash: string, scope: string, targetId: string, fileType: number, fileInfo: string) => {
store.set(`${hash}:${scope}:${targetId}:${fileType}`, fileInfo);
},
);
return {
computeHash: (data: string | Uint8Array) => crypto.createHash("md5").update(data).digest("hex"),
get: getSpy,
set: setSpy,
getSpy,
setSpy,
};
}
/** Build a canned upload_prepare response with `parts` presigned URLs. */
function makePrepareResponse(uploadId: string, parts: number): UploadPrepareResponse {
return {
upload_id: uploadId,
block_size: 8,
parts: Array.from({ length: parts }, (_, i) => ({
index: i + 1,
presigned_url: `https://cos.example.com/part-${i + 1}`,
})),
concurrency: 2,
retry_timeout: 60,
};
}
/** Fixture: a 20-byte buffer that spans 3 parts at block_size=8. */
const FIXTURE_BUFFER = Buffer.from("0123456789abcdefghij"); // 20 bytes
// ============ fetch stub for COS PUT ============
let originalFetch: typeof globalThis.fetch;
function stubFetchOk(): ReturnType<typeof vi.fn> {
const spy = vi.fn(
async () =>
new Response("", {
status: 200,
headers: {
ETag: '"etag-value"',
"x-cos-request-id": "req-id",
},
}),
);
globalThis.fetch = spy as unknown as typeof globalThis.fetch;
return spy;
}
// ============ Tests ============
describe("media-chunked: UploadDailyLimitExceededError", () => {
it("captures filePath / fileSize / message", () => {
const err = new UploadDailyLimitExceededError("/tmp/x.mp4", 123, "quota exceeded");
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("UploadDailyLimitExceededError");
expect(err.filePath).toBe("/tmp/x.mp4");
expect(err.fileSize).toBe(123);
expect(err.message).toBe("quota exceeded");
});
});
describe("media-chunked: isChunkedUploadImplemented", () => {
it("returns true for the filled-in module", () => {
expect(isChunkedUploadImplemented()).toBe(true);
});
});
describe("media-chunked: ChunkedMediaApi.uploadChunked", () => {
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it("rejects url / base64 sources up-front", async () => {
const client = mockApiClient();
const tm = mockTokenManager();
const api = new ChunkedMediaApi(client, tm);
await expect(
api.uploadChunked({
scope: "c2c",
targetId: "u1",
fileType: MediaFileType.IMAGE,
source: { kind: "url", url: "https://x" },
creds: { appId: "a", clientSecret: "s" },
}),
).rejects.toThrow(/unsupported source kind 'url'/);
await expect(
api.uploadChunked({
scope: "c2c",
targetId: "u1",
fileType: MediaFileType.IMAGE,
source: { kind: "base64", data: "AA==" },
creds: { appId: "a", clientSecret: "s" },
}),
).rejects.toThrow(/unsupported source kind 'base64'/);
expect(client.request).not.toHaveBeenCalled();
});
it("takes the cache fast path and skips upload_prepare on hit", async () => {
const client = mockApiClient();
const tm = mockTokenManager();
const cache = inMemoryCache();
// Seed cache with the md5 that uploadChunked will compute.
const md5 = crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex");
cache.set(md5, "c2c", "u1", MediaFileType.IMAGE, "cached-file-info", "uuid", 999);
const api = new ChunkedMediaApi(client, tm, { uploadCache: cache });
const result = await api.uploadChunked({
scope: "c2c",
targetId: "u1",
fileType: MediaFileType.IMAGE,
source: { kind: "buffer", buffer: FIXTURE_BUFFER },
creds: { appId: "a", clientSecret: "s" },
});
expect(result.file_info).toBe("cached-file-info");
expect(client.request).not.toHaveBeenCalled();
expect(cache.getSpy).toHaveBeenCalledWith(md5, "c2c", "u1", MediaFileType.IMAGE);
});
it("runs prepare → COS PUT → part_finish → complete for a buffer source", async () => {
const client = mockApiClient();
const tm = mockTokenManager();
const cache = inMemoryCache();
const fetchSpy = stubFetchOk();
const prepareResp = makePrepareResponse("uid-1", 3);
const completeResp: UploadMediaResponse = {
file_uuid: "uuid-final",
file_info: "final-file-info",
ttl: 3600,
};
// First request: upload_prepare; three follow-ups: upload_part_finish ×3
// plus one complete. Because concurrency=2 the order of part_finish is
// not strictly deterministic, so match on path + payload key.
client.request.mockImplementation(
async (_token: string, _method: string, path: string, body: Record<string, unknown>) => {
if (path.endsWith("/upload_prepare")) {
expect(body.file_type).toBe(MediaFileType.FILE);
expect(typeof body.md5).toBe("string");
expect(typeof body.sha1).toBe("string");
expect(typeof body.md5_10m).toBe("string");
expect(body.file_size).toBe(FIXTURE_BUFFER.length);
return prepareResp;
}
if (path.endsWith("/upload_part_finish")) {
expect(body.upload_id).toBe("uid-1");
expect(typeof body.part_index).toBe("number");
return {};
}
if (path.endsWith("/files")) {
expect(body.upload_id).toBe("uid-1");
return completeResp;
}
throw new Error(`unexpected path ${path}`);
},
);
const api = new ChunkedMediaApi(client, tm, { uploadCache: cache });
const onProgress = vi.fn();
const result = await api.uploadChunked({
scope: "group",
targetId: "g1",
fileType: MediaFileType.FILE,
source: { kind: "buffer", buffer: FIXTURE_BUFFER, fileName: "blob.bin" },
creds: { appId: "a", clientSecret: "s" },
onProgress,
});
expect(result).toEqual(completeResp);
// One prepare + 3 part_finish + 1 complete = 5 client requests.
expect(client.request).toHaveBeenCalledTimes(5);
// 3 COS PUTs, one per part, each to the presigned URL.
expect(fetchSpy).toHaveBeenCalledTimes(3);
const putUrls = fetchSpy.mock.calls.map((c) => c[0]);
expect(putUrls).toEqual(
expect.arrayContaining([
"https://cos.example.com/part-1",
"https://cos.example.com/part-2",
"https://cos.example.com/part-3",
]),
);
// Cache populated with the complete result.
const expectedMd5 = crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex");
expect(cache.setSpy).toHaveBeenCalledWith(
expectedMd5,
"group",
"g1",
MediaFileType.FILE,
"final-file-info",
"uuid-final",
3600,
);
// Progress callback hit 3 times with monotonically-increasing counts.
expect(onProgress).toHaveBeenCalledTimes(3);
const last = onProgress.mock.calls[2][0];
expect(last.completedParts).toBe(3);
expect(last.totalParts).toBe(3);
expect(last.uploadedBytes).toBe(FIXTURE_BUFFER.length);
expect(last.totalBytes).toBe(FIXTURE_BUFFER.length);
});
it("maps UPLOAD_PREPARE_FALLBACK_CODE to UploadDailyLimitExceededError", async () => {
const client = mockApiClient();
const tm = mockTokenManager();
client.request.mockRejectedValueOnce(
new ApiError(
"daily limit exceeded",
200,
"/v2/users/u1/upload_prepare",
UPLOAD_PREPARE_FALLBACK_CODE,
"quota",
),
);
const api = new ChunkedMediaApi(client, tm);
await expect(
api.uploadChunked({
scope: "c2c",
targetId: "u1",
fileType: MediaFileType.FILE,
source: { kind: "buffer", buffer: FIXTURE_BUFFER, fileName: "big.bin" },
creds: { appId: "a", clientSecret: "s" },
}),
).rejects.toBeInstanceOf(UploadDailyLimitExceededError);
});
it("streams hashes from a localPath source", async () => {
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chunked-"));
const filePath = path.join(tmp, "fixture.bin");
await fs.promises.writeFile(filePath, FIXTURE_BUFFER);
try {
const client = mockApiClient();
const tm = mockTokenManager();
stubFetchOk();
client.request.mockImplementation(async (_t, _m, p) => {
if (p.endsWith("/upload_prepare")) {
return makePrepareResponse("uid-2", 3);
}
if (p.endsWith("/upload_part_finish")) {
return {};
}
if (p.endsWith("/files")) {
return { file_uuid: "u", file_info: "fi", ttl: 10 } satisfies UploadMediaResponse;
}
throw new Error(`unexpected ${p}`);
});
const api = new ChunkedMediaApi(client, tm);
const result = await api.uploadChunked({
scope: "c2c",
targetId: "u1",
fileType: MediaFileType.VIDEO,
source: { kind: "localPath", path: filePath, size: FIXTURE_BUFFER.length },
creds: { appId: "a", clientSecret: "s" },
});
expect(result.file_info).toBe("fi");
// Verify prepare received the md5 of the on-disk bytes.
const prepareCall = client.request.mock.calls.find((c) =>
String(c[2]).endsWith("/upload_prepare"),
)!;
const prepareBody = prepareCall[3] as { md5: string; file_name: string };
expect(prepareBody.md5).toBe(crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex"));
expect(prepareBody.file_name).toBe("fixture.bin");
} finally {
await fs.promises.rm(tmp, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,620 @@
/**
* Chunked media upload for the QQ Open Platform.
*
* ## Flow (mirrors the upload sequence diagram)
*
* 1. `upload_prepare` — submit file metadata + (md5 / sha1 / md5_10m) hashes,
* receive `{ upload_id, block_size, parts[], concurrency?, retry_timeout? }`.
* 2. For every part (parallelized under a bounded concurrency):
* a. Read the part bytes (stream from disk or slice in-memory buffer).
* b. PUT the bytes to the pre-signed COS URL.
* c. POST `upload_part_finish { upload_id, part_index, block_size, md5 }`,
* retrying under {@link PART_FINISH_RETRY_POLICY} + the persistent
* retry loop for {@link PART_FINISH_RETRYABLE_CODES}.
* 3. POST `complete_upload { upload_id }` — returns `{ file_uuid, file_info,
* ttl }` identical to the one-shot path.
* 4. If `upload_prepare` returns {@link UPLOAD_PREPARE_FALLBACK_CODE}
* (`40093002` — daily upload quota exceeded), throw
* {@link UploadDailyLimitExceededError} so the upper layer can surface a
* user-facing message. The dispatcher is responsible for the fallback
* (there is no server path that will accept the file at this point).
*
* ## Why a class
*
* Mirrors {@link MediaApi}: injects {@link ApiClient}, {@link TokenManager},
* the upload cache adapter, an optional filename sanitizer, and a logger.
* Keeping the client singleton plumbing consistent means only one place
* manages UA / baseUrl / file-upload timeouts.
*
* ## Upload cache integration
*
* Chunked uploads participate in the same `file_info` cache as
* {@link MediaApi.uploadMedia}. The cache key is derived from the full-file
* md5 (already computed for `upload_prepare`) so repeat sends of the same
* large file hit the cache before we even talk to `upload_prepare`.
*/
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import type { MediaSource } from "../messaging/media-source.js";
import {
ApiError,
MediaFileType,
type ChatScope,
type EngineLogger,
type UploadMediaResponse,
type UploadPart,
type UploadPrepareHashes,
type UploadPrepareResponse,
} from "../types.js";
import { formatFileSize } from "../utils/file-utils.js";
import type { ApiClient } from "./api-client.js";
import type { SanitizeFileNameFn, UploadCacheAdapter } from "./media.js";
import {
buildPartFinishPersistentPolicy,
COMPLETE_UPLOAD_RETRY_POLICY,
PART_FINISH_RETRY_POLICY,
UPLOAD_PREPARE_FALLBACK_CODE,
withRetry,
} from "./retry.js";
import { uploadCompletePath, uploadPartFinishPath, uploadPreparePath } from "./routes.js";
import type { TokenManager } from "./token.js";
// ============ Public types ============
/**
* Raised when `upload_prepare` returns {@link UPLOAD_PREPARE_FALLBACK_CODE}
* (40093002). Carries enough context for the outbound layer to render a
* user-facing fallback message (file name, size, and the originating
* local path when available).
*/
export class UploadDailyLimitExceededError extends Error {
override readonly name = "UploadDailyLimitExceededError";
constructor(
/** Original local file path, or `"<buffer>"` when uploading an in-memory buffer. */
public readonly filePath: string,
/** File size in bytes. */
public readonly fileSize: number,
/** Original error message from the server. */
originalMessage: string,
) {
super(originalMessage);
}
}
/** Chunked-upload progress callback payload. */
export interface ChunkedUploadProgress {
completedParts: number;
totalParts: number;
uploadedBytes: number;
totalBytes: number;
}
/** Per-call options for {@link ChunkedMediaApi.uploadChunked}. */
export interface UploadChunkedOptions {
scope: ChatScope;
targetId: string;
fileType: MediaFileType;
source: MediaSource;
creds: { appId: string; clientSecret: string };
/**
* Optional filename override. When omitted, derived from `source.path`
* (localPath) / `source.fileName` (buffer) / `"file"` (fallback).
*/
fileName?: string;
/** Progress callback invoked after every successful part. */
onProgress?: (progress: ChunkedUploadProgress) => void;
/** Log prefix — defaults to `"[qqbot:chunked-upload]"`. */
logPrefix?: string;
}
/** Configuration for the {@link ChunkedMediaApi} constructor. */
export interface ChunkedMediaApiConfig {
logger?: EngineLogger;
/** Upload cache adapter (optional; omit to disable caching). */
uploadCache?: UploadCacheAdapter;
/** File name sanitizer — defaults to identity. */
sanitizeFileName?: SanitizeFileNameFn;
}
// ============ Tuning constants ============
/** Default concurrency when the server does not specify one. */
const DEFAULT_CONCURRENT_PARTS = 1;
/** Hard cap on per-upload concurrency regardless of what the server returns. */
const MAX_CONCURRENT_PARTS = 10;
/**
* Upper bound on the persistent-retry window for `upload_part_finish`.
*
* The server may suggest `retry_timeout` via `upload_prepare` — we honor
* it but clamp to 10 minutes so a runaway server can't hold the caller
* hostage.
*/
const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 10 * 60 * 1000;
/** Per-part PUT timeout (5 minutes). Matches the low-bandwidth tolerance. */
const PART_UPLOAD_TIMEOUT_MS = 300_000;
/**
* Boundary used by `md5_10m` — first 10,002,432 bytes.
*
* Files smaller than this return the whole-file md5 for `md5_10m` (per the
* server contract).
*/
const MD5_10M_SIZE = 10_002_432;
// ============ Class ============
/**
* Chunked upload module. Stateless across calls — see
* {@link ChunkedMediaApi.uploadChunked} for the main entry.
*/
export class ChunkedMediaApi {
private readonly client: ApiClient;
private readonly tokenManager: TokenManager;
private readonly logger?: EngineLogger;
private readonly cache?: UploadCacheAdapter;
private readonly sanitize: SanitizeFileNameFn;
constructor(client: ApiClient, tokenManager: TokenManager, config: ChunkedMediaApiConfig = {}) {
this.client = client;
this.tokenManager = tokenManager;
this.logger = config.logger;
this.cache = config.uploadCache;
this.sanitize = config.sanitizeFileName ?? ((n) => n);
}
/**
* Upload a {@link MediaSource} via the chunked endpoint. Only `localPath`
* and `buffer` sources are accepted — `url` / `base64` must fall through
* to {@link MediaApi.uploadMedia}.
*
* @throws {UploadDailyLimitExceededError} when `upload_prepare` returns
* {@link UPLOAD_PREPARE_FALLBACK_CODE}.
*/
async uploadChunked(opts: UploadChunkedOptions): Promise<UploadMediaResponse> {
const prefix = opts.logPrefix ?? "[qqbot:chunked-upload]";
// 1. Resolve input: size + local path (or temp buffer handle).
const input = resolveSource(opts.source, opts.fileName);
const displayName = input.fileName;
const fileSize = input.size;
const pathLabel = input.kind === "localPath" ? input.path : "<buffer>";
this.logger?.info?.(
`${prefix} Start: file=${displayName} size=${formatFileSize(fileSize)} type=${opts.fileType}`,
);
// 2. Compute md5 / sha1 / md5_10m. Identical for buffer and localPath,
// but the localPath path streams so it never has to materialize the
// whole file twice.
const hashes = await computeHashes(input);
this.logger?.debug?.(
`${prefix} hashes: md5=${hashes.md5} sha1=${hashes.sha1} md5_10m=${hashes.md5_10m}`,
);
// 3. Upload-cache fast path: the md5 hash is already a strong content
// identifier, so we can short-circuit before even calling upload_prepare.
if (this.cache) {
const cached = this.cache.get(hashes.md5, opts.scope, opts.targetId, opts.fileType);
if (cached) {
this.logger?.info?.(
`${prefix} cache HIT (md5=${hashes.md5.slice(0, 8)}) — skipping chunked upload`,
);
return { file_uuid: "", file_info: cached, ttl: 0 };
}
}
// 4. upload_prepare.
const fileNameForPrepare =
opts.fileType === MediaFileType.FILE ? this.sanitize(displayName) : displayName;
const prepareResp = await this.callUploadPrepare(
opts,
fileNameForPrepare,
fileSize,
hashes,
pathLabel,
);
const { upload_id, parts } = prepareResp;
const block_size = prepareResp.block_size;
const maxConcurrent = Math.min(
prepareResp.concurrency ? prepareResp.concurrency : DEFAULT_CONCURRENT_PARTS,
MAX_CONCURRENT_PARTS,
);
const retryTimeoutMs = prepareResp.retry_timeout
? Math.min(prepareResp.retry_timeout * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
: undefined;
this.logger?.info?.(
`${prefix} prepared: upload_id=${upload_id} block=${formatFileSize(block_size)} parts=${parts.length} concurrency=${maxConcurrent}`,
);
// 5. Upload every part. Concurrency is per-upload, not global.
let completedParts = 0;
let uploadedBytes = 0;
const uploadPart = async (part: UploadPart): Promise<void> => {
const partIndex = part.index; // 1-based.
const offset = (partIndex - 1) * block_size;
const length = Math.min(block_size, fileSize - offset);
const partBuffer = await readPart(input, offset, length);
const md5Hex = crypto.createHash("md5").update(partBuffer).digest("hex");
this.logger?.debug?.(
`${prefix} part ${partIndex}/${parts.length}: ${formatFileSize(length)} offset=${offset} md5=${md5Hex}`,
);
// 5a. PUT to pre-signed COS URL.
await putToPresignedUrl(
part.presigned_url,
partBuffer,
partIndex,
parts.length,
this.logger,
prefix,
);
// 5b. upload_part_finish — fetch a fresh token each time to defend
// against long uploads exceeding the token TTL.
await this.callUploadPartFinish(opts, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
completedParts++;
uploadedBytes += length;
this.logger?.info?.(
`${prefix} part ${partIndex}/${parts.length} done (${completedParts}/${parts.length})`,
);
opts.onProgress?.({
completedParts,
totalParts: parts.length,
uploadedBytes,
totalBytes: fileSize,
});
};
try {
await runWithConcurrency(
parts.map((part) => () => uploadPart(part)),
maxConcurrent,
);
} finally {
// If the input opened a buffered read stream we don't keep state,
// but localPath readers open / close the file per-part so there
// is nothing to unwind here. Kept as a seam for future streaming
// optimizations.
}
this.logger?.info?.(`${prefix} all parts uploaded, completing...`);
// 6. complete_upload.
const result = await this.callCompleteUpload(opts, upload_id);
this.logger?.info?.(`${prefix} completed: file_uuid=${result.file_uuid} ttl=${result.ttl}s`);
// 7. Populate the shared upload cache so subsequent sends skip re-uploading.
if (this.cache && result.file_info && result.ttl > 0) {
this.cache.set(
hashes.md5,
opts.scope,
opts.targetId,
opts.fileType,
result.file_info,
result.file_uuid,
result.ttl,
);
}
return result;
}
// -------- Internal call wrappers --------
private async callUploadPrepare(
opts: UploadChunkedOptions,
fileName: string,
fileSize: number,
hashes: UploadPrepareHashes,
pathLabel: string,
): Promise<UploadPrepareResponse> {
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
const path = uploadPreparePath(opts.scope, opts.targetId);
try {
return await this.client.request<UploadPrepareResponse>(
token,
"POST",
path,
{
file_type: opts.fileType,
file_name: fileName,
file_size: fileSize,
md5: hashes.md5,
sha1: hashes.sha1,
md5_10m: hashes.md5_10m,
},
{ uploadRequest: true },
);
} catch (err) {
if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
throw new UploadDailyLimitExceededError(pathLabel, fileSize, err.message);
}
throw err;
}
}
private async callUploadPartFinish(
opts: UploadChunkedOptions,
uploadId: string,
partIndex: number,
blockSize: number,
md5: string,
retryTimeoutMs?: number,
): Promise<void> {
const persistentPolicy = buildPartFinishPersistentPolicy(retryTimeoutMs);
const path = uploadPartFinishPath(opts.scope, opts.targetId);
await withRetry(
async () => {
// Refresh the token on every attempt — the token may be expired by
// the time we reach the tail of a long upload.
const token = await this.tokenManager.getAccessToken(
opts.creds.appId,
opts.creds.clientSecret,
);
return this.client.request(
token,
"POST",
path,
{
upload_id: uploadId,
part_index: partIndex,
block_size: blockSize,
md5,
},
{ uploadRequest: true },
);
},
PART_FINISH_RETRY_POLICY,
persistentPolicy,
this.logger,
);
}
private async callCompleteUpload(
opts: UploadChunkedOptions,
uploadId: string,
): Promise<UploadMediaResponse> {
const path = uploadCompletePath(opts.scope, opts.targetId);
return withRetry(
async () => {
const token = await this.tokenManager.getAccessToken(
opts.creds.appId,
opts.creds.clientSecret,
);
return this.client.request<UploadMediaResponse>(
token,
"POST",
path,
{ upload_id: uploadId },
{ uploadRequest: true },
);
},
COMPLETE_UPLOAD_RETRY_POLICY,
undefined,
this.logger,
);
}
}
// ============ Legacy functional facade ============
/**
* Legacy feature flag. The chunked uploader is fully implemented, so this
* returns `true`. Retained so that older call sites can be converted
* progressively.
*/
export function isChunkedUploadImplemented(): boolean {
return true;
}
// ============ Source resolution ============
/**
* Normalized chunked-upload input: everything the uploader needs to read
* the bytes plus the metadata required by `upload_prepare`.
*/
type ChunkedInput =
| { kind: "localPath"; path: string; size: number; fileName: string }
| { kind: "buffer"; buffer: Buffer; size: number; fileName: string };
function resolveSource(source: MediaSource, fileNameOverride?: string): ChunkedInput {
if (source.kind === "localPath") {
const inferredName = source.path.split(/[/\\]/).pop() || "file";
return {
kind: "localPath",
path: source.path,
size: source.size,
fileName: fileNameOverride ?? inferredName,
};
}
if (source.kind === "buffer") {
return {
kind: "buffer",
buffer: source.buffer,
size: source.buffer.length,
fileName: fileNameOverride ?? source.fileName ?? "file",
};
}
throw new Error(
`ChunkedMediaApi: unsupported source kind '${source.kind}'. ` +
"Chunked upload only supports 'localPath' and 'buffer'; route 'url'/'base64' through the one-shot uploader.",
);
}
async function readPart(input: ChunkedInput, offset: number, length: number): Promise<Buffer> {
if (input.kind === "buffer") {
return input.buffer.subarray(offset, offset + length);
}
const handle = await fs.promises.open(input.path, "r");
try {
const buf = Buffer.alloc(length);
const { bytesRead } = await handle.read(buf, 0, length, offset);
return bytesRead < length ? buf.subarray(0, bytesRead) : buf;
} finally {
await handle.close();
}
}
// ============ Hash computation ============
/**
* Stream the source once to compute md5 + sha1 + md5_10m.
*
* For buffer inputs the three hashes are computed in a single pass over
* the existing memory. For localPath inputs a ReadStream drives the
* hashers so memory use stays constant.
*/
async function computeHashes(input: ChunkedInput): Promise<UploadPrepareHashes> {
if (input.kind === "buffer") {
const md5 = crypto.createHash("md5").update(input.buffer).digest("hex");
const sha1 = crypto.createHash("sha1").update(input.buffer).digest("hex");
const md5_10m =
input.size > MD5_10M_SIZE
? crypto.createHash("md5").update(input.buffer.subarray(0, MD5_10M_SIZE)).digest("hex")
: md5;
return { md5, sha1, md5_10m };
}
return new Promise((resolve, reject) => {
const md5 = crypto.createHash("md5");
const sha1 = crypto.createHash("sha1");
const md5_10m = crypto.createHash("md5");
let consumed = 0;
const needsMd5_10m = input.size > MD5_10M_SIZE;
const stream = fs.createReadStream(input.path);
stream.on("data", (chunk: Buffer | string) => {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
md5.update(buf);
sha1.update(buf);
if (needsMd5_10m) {
const remaining = MD5_10M_SIZE - consumed;
if (remaining > 0) {
md5_10m.update(remaining >= buf.length ? buf : buf.subarray(0, remaining));
}
}
consumed += buf.length;
});
stream.on("end", () => {
const md5Hex = md5.digest("hex");
const sha1Hex = sha1.digest("hex");
resolve({
md5: md5Hex,
sha1: sha1Hex,
md5_10m: needsMd5_10m ? md5_10m.digest("hex") : md5Hex,
});
});
stream.on("error", reject);
});
}
// ============ COS PUT ============
/** Per-part retry budget for the COS PUT call (exponential backoff). */
const PART_UPLOAD_MAX_RETRIES = 2;
async function putToPresignedUrl(
presignedUrl: string,
data: Buffer,
partIndex: number,
totalParts: number,
logger: EngineLogger | undefined,
prefix: string,
): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= PART_UPLOAD_MAX_RETRIES; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), PART_UPLOAD_TIMEOUT_MS);
try {
// Convert to a standard ArrayBuffer before wrapping in Blob so type
// definitions (incl. bun-types) accept the argument.
const ab = data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength,
) as ArrayBuffer;
const startTime = Date.now();
const response = await fetch(presignedUrl, {
method: "PUT",
body: new Blob([ab]),
headers: { "Content-Length": String(data.length) },
signal: controller.signal,
});
const elapsed = Date.now() - startTime;
const requestId = response.headers.get("x-cos-request-id") ?? "-";
const etag = response.headers.get("ETag") ?? "-";
if (!response.ok) {
const body = await response.text().catch(() => "");
logger?.error?.(
`${prefix} PUT part ${partIndex}/${totalParts}: HTTP ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${body.slice(0, 160)}`,
);
throw new Error(
`COS PUT failed: ${response.status} ${response.statusText} - ${body.slice(0, 120)}`,
);
}
logger?.debug?.(
`${prefix} PUT part ${partIndex}/${totalParts} OK (${elapsed}ms ETag=${etag} requestId=${requestId})`,
);
return;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (lastError.name === "AbortError") {
lastError = new Error(
`Part ${partIndex}/${totalParts} upload timeout after ${PART_UPLOAD_TIMEOUT_MS}ms`,
);
}
if (attempt < PART_UPLOAD_MAX_RETRIES) {
const delay = 1000 * 2 ** attempt;
(logger?.warn ?? logger?.error)?.(
`${prefix} PUT part ${partIndex}/${totalParts} attempt ${attempt + 1} failed (${lastError.message.slice(0, 120)}), retrying in ${delay}ms`,
);
await sleep(delay);
}
} finally {
clearTimeout(timeoutId);
}
}
throw lastError ?? new Error(`Part ${partIndex}/${totalParts} upload failed`);
}
// ============ Concurrency ============
/**
* Batch-mode concurrency limiter. Deliberately simple: dispatch N tasks at
* a time and wait for the whole batch to settle before the next batch.
*
* A pool / queue implementation would recover some throughput when tasks
* have heavy variance, but part uploads are size-uniform (last part can be
* short) so the extra complexity is not worth it.
*/
async function runWithConcurrency(
tasks: Array<() => Promise<void>>,
maxConcurrent: number,
): Promise<void> {
for (let i = 0; i < tasks.length; i += maxConcurrent) {
const batch = tasks.slice(i, i + maxConcurrent);
await Promise.all(batch.map((task) => task()));
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -5,8 +5,13 @@
* - Unified `uploadMedia(scope, ...)` replaces `uploadC2CMedia` + `uploadGroupMedia`.
* - Upload cache integration via composition (passed in constructor).
* - Uses `withRetry` from the shared retry engine.
*
* Chunked upload for files above `LARGE_FILE_THRESHOLD` is tracked by
* {@link ./media-chunked.ts}; this module currently handles only the
* one-shot path.
*/
import * as fs from "node:fs";
import {
MediaFileType,
type ChatScope,
@@ -16,7 +21,7 @@ import {
} from "../types.js";
import { ApiClient } from "./api-client.js";
import { withRetry, UPLOAD_RETRY_POLICY } from "./retry.js";
import { mediaUploadPath, getNextMsgSeq } from "./routes.js";
import { mediaUploadPath, messagePath, getNextMsgSeq } from "./routes.js";
import { TokenManager } from "./token.js";
/** Upload cache interface — the caller provides the implementation. */
@@ -66,13 +71,19 @@ export class MediaApi {
}
/**
* Upload media via base64 or URL to a C2C or Group target.
* Upload media via base64, URL, buffer, or local file path to a C2C or Group target.
*
* The `localPath` and `buffer` branches are equivalent to `fileData` for the
* current one-shot implementation — the file is read and base64-encoded
* synchronously. They exist as first-class inputs so that a future chunked
* upload implementation can consume them without interface churn.
*
* @param scope - `'c2c'` or `'group'`.
* @param targetId - User openid or group openid.
* @param fileType - Media file type code.
* @param creds - Authentication credentials.
* @param opts - Upload options.
* @param opts - Upload options. Exactly one of `url`/`fileData`/`buffer`/`localPath`
* must be supplied.
* @returns Upload result containing `file_info` for subsequent message sends.
*/
async uploadMedia(
@@ -83,17 +94,46 @@ export class MediaApi {
opts: {
url?: string;
fileData?: string;
/**
* Raw bytes in memory. Currently re-encoded to base64 internally;
* reserved as a dedicated input for the future chunked uploader.
*/
buffer?: Buffer;
/**
* On-disk path. Currently read + base64-encoded internally; reserved
* for streaming ingestion by the future chunked uploader.
*/
localPath?: string;
srvSendMsg?: boolean;
fileName?: string;
},
): Promise<UploadMediaResponse> {
if (!opts.url && !opts.fileData) {
throw new Error(`uploadMedia: url or fileData is required`);
const sources = [opts.url, opts.fileData, opts.buffer, opts.localPath].filter(
(v) => v !== undefined,
);
if (sources.length === 0) {
throw new Error(`uploadMedia: one of url/fileData/buffer/localPath is required`);
}
if (sources.length > 1) {
throw new Error(
`uploadMedia: url/fileData/buffer/localPath are mutually exclusive (got ${sources.length})`,
);
}
// One-shot path: materialize buffer/localPath into fileData.
// Future chunked-upload work will branch here on size and route
// buffer/localPath through streaming ingestion instead of base64 encoding.
let fileData = opts.fileData;
if (opts.buffer) {
fileData = opts.buffer.toString("base64");
} else if (opts.localPath) {
const buf = await fs.promises.readFile(opts.localPath);
fileData = buf.toString("base64");
}
// Check cache for base64 uploads.
if (opts.fileData && this.cache) {
const hash = this.cache.computeHash(opts.fileData);
if (fileData && this.cache) {
const hash = this.cache.computeHash(fileData);
const cached = this.cache.get(hash, scope, targetId, fileType);
if (cached) {
return { file_uuid: "", file_info: cached, ttl: 0 };
@@ -106,8 +146,8 @@ export class MediaApi {
};
if (opts.url) {
body.url = opts.url;
} else if (opts.fileData) {
body.file_data = opts.fileData;
} else if (fileData) {
body.file_data = fileData;
}
if (fileType === MediaFileType.FILE && opts.fileName) {
body.file_name = this.sanitize(opts.fileName);
@@ -120,6 +160,7 @@ export class MediaApi {
() =>
this.client.request<UploadMediaResponse>(token, "POST", path, body, {
redactBodyKeys: ["file_data"],
uploadRequest: true,
}),
UPLOAD_RETRY_POLICY,
undefined,
@@ -127,8 +168,8 @@ export class MediaApi {
);
// Cache the result for future dedup.
if (opts.fileData && result.file_info && result.ttl > 0 && this.cache) {
const hash = this.cache.computeHash(opts.fileData);
if (fileData && result.file_info && result.ttl > 0 && this.cache) {
const hash = this.cache.computeHash(fileData);
this.cache.set(
hash,
scope,
@@ -164,8 +205,7 @@ export class MediaApi {
): Promise<MessageResponse> {
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1;
const path =
scope === "c2c" ? `/v2/users/${targetId}/messages` : `/v2/groups/${targetId}/messages`;
const path = messagePath(scope, targetId);
return this.client.request<MessageResponse>(token, "POST", path, {
msg_type: 7,

View File

@@ -13,6 +13,7 @@ import type {
OutboundMeta,
EngineLogger,
InlineKeyboard,
StreamMessageRequest,
} from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
import { ApiClient } from "./api-client.js";
@@ -23,6 +24,7 @@ import {
gatewayPath,
interactionPath,
getNextMsgSeq,
streamMessagePath,
} from "./routes.js";
import { TokenManager } from "./token.js";
@@ -204,6 +206,33 @@ export class MessageApi {
return data.url;
}
/**
* Send a C2C stream message chunk (`/v2/users/{openid}/stream_messages`).
* Only supported for one-to-one chats.
*/
async sendC2CStreamMessage(
creds: Credentials,
openid: string,
req: StreamMessageRequest,
): Promise<MessageResponse> {
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
const path = streamMessagePath(openid);
const body: Record<string, unknown> = {
input_mode: req.input_mode,
input_state: req.input_state,
content_type: req.content_type,
content_raw: req.content_raw,
event_id: req.event_id,
msg_id: req.msg_id,
msg_seq: req.msg_seq,
index: req.index,
};
if (req.stream_msg_id) {
body.stream_msg_id = req.stream_msg_id;
}
return this.client.request<MessageResponse>(token, "POST", path, body);
}
// ---- Internal ----
private async sendAndNotify(

View File

@@ -0,0 +1,345 @@
import fs from "node:fs";
import path from "node:path";
import { getHomeDir, getQQBotDataDir, isWindows } from "../../utils/platform.js";
import type { SlashCommandResult } from "../slash-commands.js";
/** Read user-configured log file paths from local config files. */
function getConfiguredLogFiles(): string[] {
const homeDir = getHomeDir();
const files: string[] = [];
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
try {
const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
if (!fs.existsSync(cfgPath)) {
continue;
}
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
const logFile = cfg?.logging?.file;
if (logFile && typeof logFile === "string") {
files.push(path.resolve(logFile));
}
break;
} catch {
// ignore
}
}
return files;
}
/** Collect directories that may contain runtime logs across common install layouts. */
function collectCandidateLogDirs(): string[] {
const homeDir = getHomeDir();
const dirs = new Set<string>();
const pushDir = (p?: string) => {
if (!p) {
return;
}
const normalized = path.resolve(p);
dirs.add(normalized);
};
const pushStateDir = (stateDir?: string) => {
if (!stateDir) {
return;
}
pushDir(stateDir);
pushDir(path.join(stateDir, "logs"));
};
for (const logFile of getConfiguredLogFiles()) {
pushDir(path.dirname(logFile));
}
for (const [key, value] of Object.entries(process.env)) {
if (!value) {
continue;
}
if (/STATE_DIR$/i.test(key) && /(OPENCLAW|CLAWDBOT|MOLTBOT)/i.test(key)) {
pushStateDir(value);
}
}
for (const name of [".openclaw", ".clawdbot", ".moltbot", "openclaw", "clawdbot", "moltbot"]) {
pushDir(path.join(homeDir, name));
pushDir(path.join(homeDir, name, "logs"));
}
const searchRoots = new Set<string>([homeDir, process.cwd(), path.dirname(process.cwd())]);
if (process.env.APPDATA) {
searchRoots.add(process.env.APPDATA);
}
if (process.env.LOCALAPPDATA) {
searchRoots.add(process.env.LOCALAPPDATA);
}
for (const root of searchRoots) {
try {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (!/(openclaw|clawdbot|moltbot)/i.test(entry.name)) {
continue;
}
const base = path.join(root, entry.name);
pushDir(base);
pushDir(path.join(base, "logs"));
}
} catch {
// Ignore missing or inaccessible directories.
}
}
if (!isWindows()) {
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
pushDir(path.join("/var/log", name));
}
}
const tmpRoots = new Set<string>();
if (isWindows()) {
tmpRoots.add("C:\\tmp");
if (process.env.TEMP) {
tmpRoots.add(process.env.TEMP);
}
if (process.env.TMP) {
tmpRoots.add(process.env.TMP);
}
if (process.env.LOCALAPPDATA) {
tmpRoots.add(path.join(process.env.LOCALAPPDATA, "Temp"));
}
} else {
tmpRoots.add("/tmp");
}
for (const tmpRoot of tmpRoots) {
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
pushDir(path.join(tmpRoot, name));
}
}
return Array.from(dirs);
}
type LogCandidate = {
filePath: string;
sourceDir: string;
mtimeMs: number;
};
function collectRecentLogFiles(logDirs: string[]): LogCandidate[] {
const candidates: LogCandidate[] = [];
const dedupe = new Set<string>();
const pushFile = (filePath: string, sourceDir: string) => {
const normalized = path.resolve(filePath);
if (dedupe.has(normalized)) {
return;
}
try {
const stat = fs.statSync(normalized);
if (!stat.isFile()) {
return;
}
dedupe.add(normalized);
candidates.push({ filePath: normalized, sourceDir, mtimeMs: stat.mtimeMs });
} catch {
// Ignore missing or inaccessible files.
}
};
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.
*/
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);
}
}
function normalizeCommandAllowlistEntry(entry: unknown): string {
if (
typeof entry === "string" ||
typeof entry === "number" ||
typeof entry === "boolean" ||
typeof entry === "bigint"
) {
return `${entry}`
.trim()
.replace(/^qqbot:\s*/i, "")
.trim();
}
return "";
}
export function hasExplicitCommandAllowlist(accountConfig?: Record<string, unknown>): boolean {
const allowFrom = accountConfig?.allowFrom;
if (!Array.isArray(allowFrom) || allowFrom.length === 0) {
return false;
}
return allowFrom.every((entry) => {
const normalized = normalizeCommandAllowlistEntry(entry);
return normalized.length > 0 && normalized !== "*";
});
}
/**
* Build the /bot-logs result: collect recent log files, write them to a temp file.
*/
export 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,
};
}

View File

@@ -0,0 +1,17 @@
import type { SlashCommandRegistry } from "../slash-commands.js";
import { registerApproveCommands } from "./register-approve.js";
import { registerBasicBotCommands } from "./register-basic.js";
import { registerClearStorageCommands } from "./register-clear-storage.js";
import { registerLogCommands } from "./register-logs.js";
import { registerStreamingCommands } from "./register-streaming.js";
/**
* Register all built-in slash commands on the shared registry instance.
*/
export function registerBuiltinSlashCommands(registry: SlashCommandRegistry): void {
registerBasicBotCommands(registry);
registerLogCommands(registry);
registerClearStorageCommands(registry);
registerStreamingCommands(registry);
registerApproveCommands(registry);
}

View File

@@ -0,0 +1,200 @@
import type { ApproveRuntimeGetter } from "../../adapter/commands.port.js";
import type { SlashCommandRegistry } from "../slash-commands.js";
import { getApproveRuntimeGetter } from "./state.js";
export function registerApproveCommands(registry: SlashCommandRegistry): void {
registry.register({
name: "bot-approve",
description: "管理命令执行审批配置",
requireAuth: true,
usage: [
`/bot-approve 查看操作指引`,
`/bot-approve on 开启审批(白名单模式,推荐)`,
`/bot-approve off 关闭审批,命令直接执行`,
`/bot-approve always 始终审批,每次执行都需审批`,
`/bot-approve reset 恢复框架默认值`,
`/bot-approve status 查看当前审批配置`,
].join("\n"),
handler: async (ctx) => {
const arg = ctx.args.trim().toLowerCase();
let runtime: ReturnType<NonNullable<ApproveRuntimeGetter>>;
try {
const getter = getApproveRuntimeGetter();
if (!getter) {
throw new Error("runtime not available");
}
runtime = getter();
} catch {
return [
`🔐 命令执行审批配置`,
``,
`❌ 当前环境不支持在线配置修改,请通过 CLI 手动配置:`,
``,
`\`\`\`shell`,
`# 开启审批(白名单模式)`,
`openclaw config set tools.exec.security allowlist`,
`openclaw config set tools.exec.ask on-miss`,
``,
`# 关闭审批`,
`openclaw config set tools.exec.security full`,
`openclaw config set tools.exec.ask off`,
`\`\`\``,
].join("\n");
}
const configApi = runtime.config;
const loadExecConfig = () => {
const cfg = configApi.current();
const tools = ((cfg as Record<string, unknown>).tools ?? {}) as Record<string, unknown>;
const exec = (tools.exec ?? {}) as Record<string, unknown>;
const security = typeof exec.security === "string" ? exec.security : "deny";
const ask = typeof exec.ask === "string" ? exec.ask : "on-miss";
return { security, ask };
};
const writeExecConfig = async (security: string, ask: string) => {
const cfg = structuredClone(configApi.current() as Record<string, unknown>);
const tools = (cfg.tools ?? {}) as Record<string, unknown>;
const exec = (tools.exec ?? {}) as Record<string, unknown>;
exec.security = security;
exec.ask = ask;
tools.exec = exec;
cfg.tools = tools;
await configApi.replaceConfigFile({ nextConfig: cfg, afterWrite: { mode: "auto" } });
};
const formatStatus = (security: string, ask: string) => {
const secIcon = security === "full" ? "🟢" : security === "allowlist" ? "🟡" : "🔴";
const askIcon = ask === "off" ? "🟢" : ask === "always" ? "🔴" : "🟡";
return [
`🔐 当前审批配置`,
``,
`${secIcon} 安全模式 (security): **${security}**`,
`${askIcon} 审批模式 (ask): **${ask}**`,
``,
security === "deny"
? `⚠️ 当前为 deny 模式,所有命令执行被拒绝`
: security === "full" && ask === "off"
? `✅ 所有命令无需审批直接执行`
: security === "allowlist" && ask === "on-miss"
? `🛡️ 白名单命令直接执行,其余需审批`
: ask === "always"
? `🔒 每次命令执行都需要人工审批`
: ` security=${security}, ask=${ask}`,
].join("\n");
};
if (!arg) {
return [
`🔐 命令执行审批配置`,
``,
`<qqbot-cmd-input text="/bot-approve on" show="/bot-approve on"/> 开启审批(白名单模式)`,
`<qqbot-cmd-input text="/bot-approve off" show="/bot-approve off"/> 关闭审批`,
`<qqbot-cmd-input text="/bot-approve always" show="/bot-approve always"/> 严格模式`,
`<qqbot-cmd-input text="/bot-approve reset" show="/bot-approve reset"/> 恢复默认`,
`<qqbot-cmd-input text="/bot-approve status" show="/bot-approve status"/> 查看当前配置`,
].join("\n");
}
if (arg === "status") {
const { security, ask } = loadExecConfig();
return [
formatStatus(security, ask),
``,
`<qqbot-cmd-input text="/bot-approve on" show="/bot-approve on"/> 开启审批`,
`<qqbot-cmd-input text="/bot-approve off" show="/bot-approve off"/> 关闭审批`,
`<qqbot-cmd-input text="/bot-approve always" show="/bot-approve always"/> 严格模式`,
`<qqbot-cmd-input text="/bot-approve reset" show="/bot-approve reset"/> 恢复默认`,
].join("\n");
}
if (arg === "on") {
try {
await writeExecConfig("allowlist", "on-miss");
return [
`✅ 审批已开启`,
``,
`• security = allowlist白名单模式`,
`• ask = on-miss未命中白名单时需审批`,
``,
`已批准的命令自动加入白名单,下次直接执行。`,
].join("\n");
} catch (err: unknown) {
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
}
}
if (arg === "off") {
try {
await writeExecConfig("full", "off");
return [
`✅ 审批已关闭`,
``,
`• security = full允许所有命令`,
`• ask = off不需要审批`,
``,
`⚠️ 所有命令将直接执行,不会弹出审批确认。`,
].join("\n");
} catch (err: unknown) {
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
}
}
if (arg === "always" || arg === "strict") {
try {
await writeExecConfig("allowlist", "always");
return [
`✅ 已切换为严格审批模式`,
``,
`• security = allowlist`,
`• ask = always每次执行都需审批`,
``,
`每个命令都会弹出审批按钮,需手动确认。`,
].join("\n");
} catch (err: unknown) {
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
}
}
if (arg === "reset") {
try {
const cfg = structuredClone(configApi.current() as Record<string, unknown>);
const tools = (cfg.tools ?? {}) as Record<string, unknown>;
const exec = (tools.exec ?? {}) as Record<string, unknown>;
delete exec.security;
delete exec.ask;
if (Object.keys(exec).length === 0) {
delete tools.exec;
} else {
tools.exec = exec;
}
if (Object.keys(tools).length === 0) {
delete cfg.tools;
} else {
cfg.tools = tools;
}
await configApi.replaceConfigFile({ nextConfig: cfg, afterWrite: { mode: "auto" } });
return [
`✅ 审批配置已重置`,
``,
`已移除 tools.exec.security 和 tools.exec.ask`,
`框架将使用默认值security=deny, ask=on-miss`,
``,
`如需开启命令执行,请使用 /bot-approve on`,
].join("\n");
} catch (err: unknown) {
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
}
}
return [
`❌ 未知参数: ${arg}`,
``,
`可用选项: on | off | always | reset | status`,
`输入 /bot-approve ? 查看详细用法`,
].join("\n");
},
});
}

View File

@@ -0,0 +1,85 @@
import type { SlashCommandRegistry } from "../slash-commands.js";
import { getPluginVersionString, resolveRuntimeServiceVersion } from "./state.js";
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";
const GROUP_EXCLUDED = new Set(["bot-upgrade", "bot-clear-storage", "bot-streaming"]);
export function registerBasicBotCommands(registry: SlashCommandRegistry): void {
registry.register({
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 (Number.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");
},
});
registry.register({
name: "bot-version",
description: "查看 QQBot 插件版本和 OpenClaw 框架版本",
usage: [`/bot-version`, ``, `查看当前 QQBot 插件版本和 OpenClaw 框架版本。`].join("\n"),
handler: async () => {
const frameworkVersion = resolveRuntimeServiceVersion();
const ver = getPluginVersionString();
const lines = [
`🦞 OpenClaw 框架版本:${frameworkVersion}`,
`🤖 QQBot 插件版本v${ver}`,
`🌟 官方 GitHub 仓库:[点击前往](${QQBOT_PLUGIN_GITHUB_URL})`,
];
return lines.join("\n");
},
});
registry.register({
name: "bot-upgrade",
description: "查看 QQBot 升级指引",
usage: [`/bot-upgrade`, ``, `查看 QQBot 升级说明。`].join("\n"),
handler: () =>
[`📘 QQBot 升级指引:`, `[点击查看升级说明](${QQBOT_UPGRADE_GUIDE_URL})`].join("\n"),
});
registry.register({
name: "bot-help",
description: "查看所有内置命令",
usage: [
`/bot-help`,
``,
`查看所有可用的 QQBot 内置命令及其简要说明。`,
`在命令后追加 ? 可查看详细用法。`,
].join("\n"),
handler: (ctx) => {
const isGroup = ctx.type === "group";
const lines = [`### QQBot 内置命令`, ``];
for (const [name, cmd] of registry.getAllCommands()) {
if (isGroup && GROUP_EXCLUDED.has(name)) {
continue;
}
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
}
lines.push(``, `> 插件版本 v${getPluginVersionString()}`);
return lines.join("\n");
},
});
}

View File

@@ -0,0 +1,203 @@
import fs from "node:fs";
import path from "node:path";
import { getHomeDir } from "../../utils/platform.js";
import type { SlashCommandRegistry } from "../slash-commands.js";
function scanDirectoryFiles(dirPath: string): { filePath: string; size: number }[] {
const files: { filePath: string; size: number }[] = [];
if (!fs.existsSync(dirPath)) {
return files;
}
const walk = (dir: string) => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile()) {
try {
const stat = fs.statSync(fullPath);
files.push({ filePath: fullPath, size: stat.size });
} catch {
// Skip inaccessible files.
}
}
}
};
walk(dirPath);
files.sort((a, b) => b.size - a.size);
return files;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function removeEmptyDirs(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
return;
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dirPath, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
removeEmptyDirs(path.join(dirPath, entry.name));
}
}
try {
const remaining = fs.readdirSync(dirPath);
if (remaining.length === 0) {
fs.rmdirSync(dirPath);
}
} catch {
// Directory may be in use, skip.
}
}
const CLEAR_STORAGE_MAX_DISPLAY = 10;
/**
* Resolve the canonical downloads directory for an appId under the user's home.
* Must stay strictly under ~/.openclaw/media/qqbot/downloads/.
*/
export function resolveQqbotDownloadsDirForApp(appId: string): string {
const id = appId.trim();
if (!id || id.includes("\0") || /[/\\\n]|\.\./.test(id)) {
throw new Error("invalid appId path");
}
const base = path.join(getHomeDir(), ".openclaw", "media", "qqbot", "downloads");
const resolvedBase = path.resolve(base);
const target = path.resolve(path.join(resolvedBase, id));
if (target === resolvedBase || !target.startsWith(resolvedBase + path.sep)) {
throw new Error("invalid appId path");
}
return target;
}
export function registerClearStorageCommands(registry: SlashCommandRegistry): void {
registry.register({
name: "bot-clear-storage",
description: "清理通过 QQBot 对话产生的下载文件,释放主机磁盘空间",
usage: [
`/bot-clear-storage`,
``,
`扫描当前机器人产生的下载文件并列出明细。`,
`确认后执行删除,释放主机磁盘空间。`,
``,
`/bot-clear-storage --force 确认执行清理`,
``,
`⚠️ 仅在私聊中可用。`,
].join("\n"),
handler: (ctx) => {
const { appId, type } = ctx;
if (type !== "c2c") {
return `💡 请在私聊中使用此指令`;
}
const isForce = ctx.args.trim() === "--force";
let targetDir: string;
try {
targetDir = resolveQqbotDownloadsDirForApp(appId);
} catch {
return `❌ 无效的机器人标识,无法解析清理目录。`;
}
const displayDir = `~/.openclaw/media/qqbot/downloads/${appId}`;
if (!isForce) {
const files = scanDirectoryFiles(targetDir);
if (files.length === 0) {
return [`✅ 当前没有需要清理的文件`, ``, `目录 \`${displayDir}\` 为空或不存在。`].join(
"\n",
);
}
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
const lines: string[] = [
`即将清理 \`${displayDir}\` 目录下所有文件,总共 ${files.length} 个文件,占用磁盘存储空间 ${formatBytes(totalSize)}`,
``,
`目录文件概况:`,
];
const displayFiles = files.slice(0, CLEAR_STORAGE_MAX_DISPLAY);
for (const f of displayFiles) {
const relativePath = path.relative(targetDir, f.filePath).replace(/\\/g, "/");
lines.push(`${relativePath} (${formatBytes(f.size)})`, ``, ``);
}
if (files.length > CLEAR_STORAGE_MAX_DISPLAY) {
lines.push(`...[合计:${files.length} 个文件(${formatBytes(totalSize)}]`, ``);
}
lines.push(
``,
`---`,
``,
`确认清理后,上述保存在 OpenClaw 运行主机磁盘上的文件将永久删除,后续对话过程中 AI 无法再找回相关文件。`,
`‼️ 点击指令确认删除`,
`<qqbot-cmd-enter text="/bot-clear-storage --force" />`,
);
return lines.join("\n");
}
const files = scanDirectoryFiles(targetDir);
if (files.length === 0) {
return `✅ 目录已为空,无需清理`;
}
let deletedCount = 0;
let deletedSize = 0;
let failedCount = 0;
for (const f of files) {
try {
fs.unlinkSync(f.filePath);
deletedCount++;
deletedSize += f.size;
} catch {
failedCount++;
}
}
try {
removeEmptyDirs(targetDir);
} catch {
// Non-critical, silently ignore.
}
if (failedCount === 0) {
return [
`✅ 清理成功`,
``,
`已删除 ${deletedCount} 个文件,释放 ${formatBytes(deletedSize)} 磁盘空间。`,
].join("\n");
}
return [
`⚠️ 部分清理完成`,
``,
`已删除 ${deletedCount} 个文件(${formatBytes(deletedSize)}${failedCount} 个文件删除失败。`,
].join("\n");
},
});
}

View File

@@ -0,0 +1,22 @@
import type { SlashCommandRegistry } from "../slash-commands.js";
import { buildBotLogsResult, hasExplicitCommandAllowlist } from "./log-helpers.js";
export function registerLogCommands(registry: SlashCommandRegistry): void {
registry.register({
name: "bot-logs",
description: "导出本地日志文件",
requireAuth: true,
usage: [
`/bot-logs`,
``,
`导出最近的 OpenClaw 日志文件(最多 4 个文件)。`,
`每个文件只保留最后 1000 行,并作为附件返回。`,
].join("\n"),
handler: (ctx) => {
if (!hasExplicitCommandAllowlist(ctx.accountConfig)) {
return `⛔ 权限不足:请先在 channels.qqbot.allowFrom或对应账号 allowFrom中配置明确的发送者列表后再使用 /bot-logs。`;
}
return buildBotLogsResult();
},
});
}

View File

@@ -0,0 +1,140 @@
import type { ApproveRuntimeGetter } from "../../adapter/commands.port.js";
import type { SlashCommandRegistry } from "../slash-commands.js";
import {
getApproveRuntimeGetter,
getPluginVersionString,
resolveRuntimeServiceVersion,
} from "./state.js";
function isStreamingConfigEnabled(streaming: unknown): boolean {
if (streaming === true) {
return true;
}
if (streaming === false || streaming === undefined || streaming === null) {
return false;
}
if (typeof streaming === "object") {
const o = streaming as Record<string, unknown>;
if (o.c2cStreamApi === true) {
return true;
}
if (o.mode === "off") {
return false;
}
return true;
}
return false;
}
export function registerStreamingCommands(registry: SlashCommandRegistry): void {
registry.register({
name: "bot-streaming",
description: "一键开关流式消息",
usage: [
`/bot-streaming on 开启流式消息`,
`/bot-streaming off 关闭流式消息`,
`/bot-streaming 查看当前流式消息状态`,
``,
`开启后AI 的回复会以流式形式逐步显示(打字机效果)。`,
`注意:仅 C2C私聊支持流式消息。`,
].join("\n"),
handler: async (ctx) => {
if (ctx.type !== "c2c") {
return `❌ 流式消息仅支持私聊场景,请在私聊中使用 /bot-streaming 指令`;
}
const arg = ctx.args.trim().toLowerCase();
const currentOn = isStreamingConfigEnabled(ctx.accountConfig?.streaming);
if (!arg) {
return [
`📡 流式消息状态:${currentOn ? "✅ 已开启" : "❌ 已关闭"}`,
``,
`使用 <qqbot-cmd-input text="/bot-streaming on" show="/bot-streaming on"/> 开启`,
`使用 <qqbot-cmd-input text="/bot-streaming off" show="/bot-streaming off"/> 关闭`,
].join("\n");
}
if (arg !== "on" && arg !== "off") {
return `❌ 参数错误,请使用 on 或 off\n\n示例/bot-streaming on`;
}
const wantOn = arg === "on";
if (wantOn === currentOn) {
return `📡 流式消息已经是${wantOn ? "开启" : "关闭"}状态,无需操作`;
}
let runtime: ReturnType<NonNullable<ApproveRuntimeGetter>>;
try {
const getter = getApproveRuntimeGetter();
if (!getter) {
throw new Error("runtime not available");
}
runtime = getter();
} catch {
const fwVer = resolveRuntimeServiceVersion();
const ver = getPluginVersionString();
return [
`❌ 当前版本不支持该指令`,
``,
`🦞框架版本:${fwVer}`,
`🤖QQBot 插件版本v${ver}`,
``,
`可通过以下命令手动开启流式消息:`,
``,
`\`\`\`shell`,
`# 1. 开启流式消息`,
`openclaw config set channels.qqbot.streaming true`,
``,
`# 2. 重启网关使配置生效`,
`openclaw gateway restart`,
`\`\`\``,
].join("\n");
}
try {
const configApi = runtime.config;
const currentCfg = structuredClone(configApi.current() as Record<string, unknown>);
const qqbot = ((currentCfg.channels ?? {}) as Record<string, unknown>).qqbot as
| Record<string, unknown>
| undefined;
if (!qqbot) {
return `❌ 配置文件中未找到 qqbot 通道配置`;
}
const accountId = ctx.accountId;
const newVal: unknown = wantOn;
if (accountId !== "default") {
const prevAccounts =
(qqbot.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
const nextAccounts = { ...prevAccounts };
const acct = { ...nextAccounts[accountId] };
acct.streaming = newVal;
nextAccounts[accountId] = acct;
qqbot.accounts = nextAccounts;
} else {
qqbot.streaming = newVal;
const accs = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
if (accs?.default && typeof accs.default === "object") {
const nextAccs = { ...accs };
const def = { ...accs.default, streaming: newVal };
nextAccs.default = def;
qqbot.accounts = nextAccs;
}
}
await configApi.replaceConfigFile({ nextConfig: currentCfg, afterWrite: { mode: "auto" } });
return [
`✅ 流式消息已${wantOn ? "开启" : "关闭"}`,
``,
wantOn ? `AI 的回复将以流式形式逐步显示(仅私聊生效)。` : `AI 的回复将恢复为完整发送。`,
].join("\n");
} catch (err: unknown) {
return `❌ 配置写入失败: ${err instanceof Error ? err.message : String(err)}`;
}
},
});
}

View File

@@ -0,0 +1,31 @@
import type { ApproveRuntimeGetter, CommandsPort } from "../../adapter/commands.port.js";
let _resolveVersion: () => string = () => "unknown";
let _approveRuntimeGetter: ApproveRuntimeGetter | null = null;
let PLUGIN_VERSION = "unknown";
/**
* Initialize command dependencies from the EngineAdapters.commands port.
* Called once by the bridge layer during startup.
*/
export function initSlashCommandDeps(port: CommandsPort): void {
_resolveVersion = port.resolveVersion;
PLUGIN_VERSION = port.pluginVersion;
_approveRuntimeGetter = port.approveRuntimeGetter ?? null;
}
export function resolveRuntimeServiceVersion(): string {
return _resolveVersion();
}
export function getPluginVersionString(): string {
return PLUGIN_VERSION;
}
export function getFrameworkVersionString(): string {
return _resolveVersion();
}
export function getApproveRuntimeGetter(): ApproveRuntimeGetter | null {
return _approveRuntimeGetter;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,234 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_GROUP_HISTORY_LIMIT,
resolveGroupConfig,
resolveGroupName,
resolveGroupPrompt,
resolveGroupSettings,
resolveGroupToolPolicy,
resolveHistoryLimit,
resolveIgnoreOtherMentions,
resolveMentionPatterns,
resolveRequireMention,
} from "./group.js";
describe("engine/config/group", () => {
describe("resolveGroupConfig precedence", () => {
it("returns defaults when no config exists", () => {
const cfg = resolveGroupConfig({}, "G1");
expect(cfg).toMatchObject({
requireMention: true,
ignoreOtherMentions: false,
toolPolicy: "restricted",
name: "",
historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
});
expect(cfg.prompt).toBeUndefined();
});
it("falls back to wildcard when specific is missing", () => {
const cfg = {
channels: {
qqbot: {
appId: "1",
groups: {
"*": {
requireMention: false,
toolPolicy: "full",
historyLimit: 20,
name: "wild",
},
},
},
},
};
const resolved = resolveGroupConfig(cfg, "G1");
expect(resolved.requireMention).toBe(false);
expect(resolved.toolPolicy).toBe("full");
expect(resolved.historyLimit).toBe(20);
expect(resolved.name).toBe("wild");
});
it("specific overrides wildcard and defaults", () => {
const cfg = {
channels: {
qqbot: {
appId: "1",
groups: {
"*": { requireMention: true, toolPolicy: "restricted", historyLimit: 20 },
GROUPA: { requireMention: false, toolPolicy: "none", historyLimit: 5, name: "A" },
},
},
},
};
const resolved = resolveGroupConfig(cfg, "GROUPA");
expect(resolved.requireMention).toBe(false);
expect(resolved.toolPolicy).toBe("none");
expect(resolved.historyLimit).toBe(5);
expect(resolved.name).toBe("A");
});
it("historyLimit is clamped to >= 0 and floored", () => {
const cfg = {
channels: {
qqbot: { appId: "1", groups: { "*": { historyLimit: -3.7 } } },
},
};
expect(resolveHistoryLimit(cfg, "G")).toBe(0);
});
it("non-finite historyLimit falls back to default", () => {
const cfg = {
channels: {
qqbot: { appId: "1", groups: { "*": { historyLimit: "not a number" } } },
},
};
expect(resolveHistoryLimit(cfg, "G")).toBe(DEFAULT_GROUP_HISTORY_LIMIT);
});
it("invalid toolPolicy values are ignored", () => {
const cfg = {
channels: {
qqbot: { appId: "1", groups: { "*": { toolPolicy: "invalid" } } },
},
};
expect(resolveGroupToolPolicy(cfg, "G")).toBe("restricted");
});
});
describe("named accounts", () => {
it("reads groups from the named-account scope", () => {
const cfg = {
channels: {
qqbot: {
accounts: {
bot2: {
appId: "9",
groups: { "*": { requireMention: false, historyLimit: 7 } },
},
},
},
},
};
expect(resolveRequireMention(cfg, "G", "bot2")).toBe(false);
expect(resolveHistoryLimit(cfg, "G", "bot2")).toBe(7);
});
});
describe("resolveGroupName", () => {
it("uses the first 8 chars of openid when name is unset", () => {
expect(resolveGroupName({}, "ABCDEFGH1234")).toBe("ABCDEFGH");
});
it("prefers the configured name", () => {
const cfg = {
channels: { qqbot: { appId: "1", groups: { ABCDEFGH1234: { name: "Foo" } } } },
};
expect(resolveGroupName(cfg, "ABCDEFGH1234")).toBe("Foo");
});
});
describe("resolveGroupPrompt", () => {
it("returns the default prompt when nothing configured", () => {
expect(resolveGroupPrompt({}, "G")).toContain("bot");
});
it("prefers specific over wildcard", () => {
const cfg = {
channels: {
qqbot: {
appId: "1",
groups: { "*": { prompt: "WILD" }, G1: { prompt: "SPEC" } },
},
},
};
expect(resolveGroupPrompt(cfg, "G1")).toBe("SPEC");
expect(resolveGroupPrompt(cfg, "G2")).toBe("WILD");
});
});
describe("resolveIgnoreOtherMentions", () => {
it("defaults to false", () => {
expect(resolveIgnoreOtherMentions({}, "G")).toBe(false);
});
it("honours wildcard override", () => {
const cfg = {
channels: { qqbot: { appId: "1", groups: { "*": { ignoreOtherMentions: true } } } },
};
expect(resolveIgnoreOtherMentions(cfg, "G")).toBe(true);
});
});
describe("resolveMentionPatterns", () => {
it("returns [] when nothing configured", () => {
expect(resolveMentionPatterns({})).toEqual([]);
});
it("reads global patterns", () => {
const cfg = { messages: { groupChat: { mentionPatterns: ["/^hey/"] } } };
expect(resolveMentionPatterns(cfg)).toEqual(["/^hey/"]);
});
it("agent-level overrides global", () => {
const cfg = {
messages: { groupChat: { mentionPatterns: ["g"] } },
agents: {
list: [{ id: "main", groupChat: { mentionPatterns: ["a", "b"] } }],
},
};
expect(resolveMentionPatterns(cfg, "main")).toEqual(["a", "b"]);
expect(resolveMentionPatterns(cfg, "OTHER")).toEqual(["g"]);
});
it("filters non-string entries", () => {
const cfg = { messages: { groupChat: { mentionPatterns: ["ok", 42, null] } } };
expect(resolveMentionPatterns(cfg)).toEqual(["ok"]);
});
});
describe("resolveGroupSettings (aggregate)", () => {
it("returns merged config + name + mentionPatterns in one call", () => {
const cfg = {
channels: {
qqbot: {
appId: "1",
groups: {
G1: { requireMention: false, name: "Dev" },
"*": { historyLimit: 10 },
},
},
},
messages: { groupChat: { mentionPatterns: ["@bot"] } },
};
const settings = resolveGroupSettings({ cfg, groupOpenid: "G1" });
expect(settings.config.requireMention).toBe(false);
expect(settings.config.historyLimit).toBe(10);
expect(settings.name).toBe("Dev");
expect(settings.mentionPatterns).toEqual(["@bot"]);
});
it("falls back to the first 8 chars of the openid for name", () => {
const settings = resolveGroupSettings({
cfg: {},
groupOpenid: "ABCDEFGHIJKLMNOP",
});
expect(settings.name).toBe("ABCDEFGH");
});
it("applies agent-level mentionPatterns over global", () => {
const cfg = {
agents: {
list: [{ id: "custom", groupChat: { mentionPatterns: ["@agent"] } }],
},
messages: { groupChat: { mentionPatterns: ["@global"] } },
};
const settings = resolveGroupSettings({
cfg,
groupOpenid: "G1",
agentId: "custom",
});
expect(settings.mentionPatterns).toEqual(["@agent"]);
});
});
});

View File

@@ -0,0 +1,299 @@
/**
* QQBot group configuration resolution (pure logic).
* QQBot 群配置解析(纯逻辑层)。
*
* Resolves per-group settings that the inbound pipeline needs to decide how
* to gate and contextualize group messages. Reads from a raw config object
* produced by the framework's config loader, with a `specific > wildcard
* ("*") > default` precedence chain.
*
* All functions are **pure** (no I/O, no external state) — making them
* portable to the standalone plugin build and trivially unit-testable.
*/
import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js";
import { resolveAccountBase } from "./resolve.js";
// ============ Types ============
/**
* Tool policy — which tool palette an agent should use in a given group.
*
* - `full`: default allow everything (no engine-side restriction).
* - `restricted`: engine returns an empty allowlist so the framework falls
* back to its built-in restricted palette.
* - `none`: deny all tools.
*/
export type GroupToolPolicy = "full" | "restricted" | "none";
/** Per-group configuration — everything that may be overridden per group. */
export interface GroupConfig {
/** Whether the bot requires @mention to respond. Defaults to true. */
requireMention: boolean;
/**
* When true, group messages that @other users (but not the bot) are
* dropped silently without reaching the AI pipeline.
*/
ignoreOtherMentions: boolean;
/** Tool palette policy. Defaults to "restricted". */
toolPolicy: GroupToolPolicy;
/** Human-readable group name. Empty string if not configured. */
name: string;
/** Per-group behaviour prompt appended to the system prompt. */
prompt?: string;
/**
* Number of non-@ history messages buffered per group. Clamped to 0 when
* disabled. The default matches the standalone build's `50`.
*/
historyLimit: number;
}
// ============ Defaults ============
/** Default history limit — matches the standalone build. */
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
/** Default group behaviour prompt. Exported so the gating stage can use
* the same fallback when no per-group `prompt` is configured. */
export const DEFAULT_GROUP_PROMPT =
"If the sender is a bot, respond only when they explicitly @mention you to ask a question or request assistance with a specific task; keep your replies concise and clear, avoiding the urge to race other bots to answer or engage in lengthy, unproductive exchanges. In group chats, prioritize responding to messages from human users; bots should maintain a collaborative rather than competitive dynamic to ensure the conversation remains orderly and does not result in message flooding.";
const DEFAULT_GROUP_CONFIG: Readonly<Omit<GroupConfig, "prompt">> = {
requireMention: true,
ignoreOtherMentions: false,
toolPolicy: "restricted",
name: "",
historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
};
// ============ Helpers ============
/** Read a named account's raw `groups` map from an OpenClawConfig. */
function readGroupsMap(
cfg: Record<string, unknown>,
accountId?: string | null,
): Record<string, Record<string, unknown>> {
const account = resolveAccountBase(cfg, accountId);
const groups = asRecord(account.config.groups);
if (!groups) {
return {};
}
// Only keep sub-objects; skip scalars produced by user mistakes.
const normalized: Record<string, Record<string, unknown>> = {};
for (const [key, value] of Object.entries(groups)) {
const sub = asRecord(value);
if (sub) {
normalized[key] = sub;
}
}
return normalized;
}
function readBoolean(obj: Record<string, unknown>, key: string): boolean | undefined {
const v = obj[key];
return typeof v === "boolean" ? v : undefined;
}
function readString(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function readToolPolicy(obj: Record<string, unknown>, key: string): GroupToolPolicy | undefined {
const v = obj[key];
return v === "full" || v === "restricted" || v === "none" ? v : undefined;
}
function readHistoryLimit(obj: Record<string, unknown>, key: string): number | undefined {
const v = obj[key];
if (typeof v !== "number" || !Number.isFinite(v)) {
return undefined;
}
return Math.max(0, Math.floor(v));
}
// ============ Public API ============
/**
* Resolve per-group configuration with `specific > "*" > default` precedence.
*
* When `groupOpenid` is not provided, only the wildcard/default values are
* returned. This lets callers query the "default" behaviour for new groups.
*/
export function resolveGroupConfig(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): GroupConfig {
const groups = readGroupsMap(cfg, accountId);
const wildcard = groups["*"] ?? {};
const specific = groupOpenid ? (groups[groupOpenid] ?? {}) : {};
return {
requireMention:
readBoolean(specific, "requireMention") ??
readBoolean(wildcard, "requireMention") ??
DEFAULT_GROUP_CONFIG.requireMention,
ignoreOtherMentions:
readBoolean(specific, "ignoreOtherMentions") ??
readBoolean(wildcard, "ignoreOtherMentions") ??
DEFAULT_GROUP_CONFIG.ignoreOtherMentions,
toolPolicy:
readToolPolicy(specific, "toolPolicy") ??
readToolPolicy(wildcard, "toolPolicy") ??
DEFAULT_GROUP_CONFIG.toolPolicy,
name: readString(specific, "name") ?? readString(wildcard, "name") ?? DEFAULT_GROUP_CONFIG.name,
prompt: readString(specific, "prompt") ?? readString(wildcard, "prompt"),
historyLimit:
readHistoryLimit(specific, "historyLimit") ??
readHistoryLimit(wildcard, "historyLimit") ??
DEFAULT_GROUP_CONFIG.historyLimit,
};
}
/** Resolve the effective `historyLimit` (>= 0) for a given group. */
export function resolveHistoryLimit(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): number {
return resolveGroupConfig(cfg, groupOpenid, accountId).historyLimit;
}
/** Resolve `requireMention` for a given group. */
export function resolveRequireMention(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): boolean {
return resolveGroupConfig(cfg, groupOpenid, accountId).requireMention;
}
/** Resolve `ignoreOtherMentions` for a given group. */
export function resolveIgnoreOtherMentions(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): boolean {
return resolveGroupConfig(cfg, groupOpenid, accountId).ignoreOtherMentions;
}
/** Resolve tool policy for a given group. */
export function resolveGroupToolPolicy(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): GroupToolPolicy {
return resolveGroupConfig(cfg, groupOpenid, accountId).toolPolicy;
}
/**
* Resolve the behaviour prompt (PE) for a group. Falls back to the built-in
* default when neither specific nor wildcard configuration provides one.
*/
export function resolveGroupPrompt(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): string {
return resolveGroupConfig(cfg, groupOpenid, accountId).prompt ?? DEFAULT_GROUP_PROMPT;
}
/**
* Resolve the display name for a group.
*
* When no name is configured, the first 8 characters of the openid are used
* as a short identifier so log lines stay compact.
*/
export function resolveGroupName(
cfg: Record<string, unknown>,
groupOpenid: string,
accountId?: string | null,
): string {
const name = resolveGroupConfig(cfg, groupOpenid, accountId).name;
return name || groupOpenid.slice(0, 8);
}
// ============ GroupSettings (aggregate) ============
/**
* Per-inbound aggregate of everything the pipeline needs about a group.
*
* Built once at the top of the group-gate stage so downstream consumers
* don't repeatedly re-parse the same `cfg` tree. Superset of
* {@link GroupConfig}: also includes the effective `mentionPatterns`
* (which depend on `agentId`, not on the group itself) and a
* pre-computed display name for logging.
*/
export interface GroupSettings {
/** Merged group config (specific > wildcard > defaults). */
config: GroupConfig;
/** Display name — `config.name` or the first 8 chars of the openid. */
name: string;
/** Raw mentionPatterns (agent > global > []). */
mentionPatterns: string[];
}
/**
* Resolve all per-inbound group-related settings in one pass.
*
* Prefer this over calling `resolveHistoryLimit` / `resolveRequireMention`
* / etc. individually in hot paths — each of those currently re-walks
* the config tree on its own.
*/
export function resolveGroupSettings(params: {
cfg: Record<string, unknown>;
groupOpenid: string;
accountId?: string | null;
agentId?: string | null;
}): GroupSettings {
const config = resolveGroupConfig(params.cfg, params.groupOpenid, params.accountId);
const name = config.name || params.groupOpenid.slice(0, 8);
const mentionPatterns = resolveMentionPatterns(params.cfg, params.agentId);
return { config, name, mentionPatterns };
}
interface AgentEntry {
id?: unknown;
groupChat?: { mentionPatterns?: unknown };
}
/**
* Resolve mentionPatterns with `agent > global > []` precedence.
*
* Mirrors the framework's `messages.groupChat.mentionPatterns` / per-agent
* `agents.list[].groupChat.mentionPatterns` chain.
*/
export function resolveMentionPatterns(
cfg: Record<string, unknown>,
agentId?: string | null,
): string[] {
// ---- 1. Agent-level ----
if (agentId) {
const agents = asRecord(cfg.agents);
const list = Array.isArray(agents?.list) ? (agents?.list as AgentEntry[]) : [];
const entry = list.find(
(a) => typeof a.id === "string" && a.id.trim().toLowerCase() === agentId.trim().toLowerCase(),
);
const agentGroupChat = entry?.groupChat;
if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) {
const patterns = agentGroupChat.mentionPatterns;
return Array.isArray(patterns)
? patterns.filter((p): p is string => typeof p === "string")
: [];
}
}
// ---- 2. Global level ----
const messages = asRecord(cfg.messages);
const globalGroupChat = asRecord(messages?.groupChat);
if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) {
const patterns = globalGroupChat.mentionPatterns;
return Array.isArray(patterns)
? patterns.filter((p): p is string => typeof p === "string")
: [];
}
// ---- 3. Default ----
return [];
}

View File

@@ -12,6 +12,7 @@ const INTENTS = {
PUBLIC_GUILD_MESSAGES: 1 << 30,
DIRECT_MESSAGE: 1 << 12,
GROUP_AND_C2C: 1 << 25,
/** Button interaction callbacks (INTERACTION_CREATE). */
INTERACTION: 1 << 26,
} as const;
@@ -94,6 +95,23 @@ export const GatewayEvent = {
C2C_MESSAGE_CREATE: "C2C_MESSAGE_CREATE",
AT_MESSAGE_CREATE: "AT_MESSAGE_CREATE",
DIRECT_MESSAGE_CREATE: "DIRECT_MESSAGE_CREATE",
/** Group message that explicitly @-mentions the bot. */
GROUP_AT_MESSAGE_CREATE: "GROUP_AT_MESSAGE_CREATE",
/**
* Group message that does NOT mention the bot. Still dispatched to the
* pipeline so the group history buffer and the `requireMention=false`
* path can observe it.
*/
GROUP_MESSAGE_CREATE: "GROUP_MESSAGE_CREATE",
INTERACTION_CREATE: "INTERACTION_CREATE",
} as const;
// ============ Interaction Type Constants ============
/** Interaction sub-types carried in `InteractionEvent.data.type`. */
export const InteractionType = {
/** Remote config query — bot reports its current claw_cfg snapshot. */
CONFIG_QUERY: 2001,
/** Remote config update — caller pushes new settings. */
CONFIG_UPDATE: 2002,
} as const;

View File

@@ -121,30 +121,11 @@ export function dispatchEvent(
}
if (eventType === GatewayEvent.GROUP_AT_MESSAGE_CREATE) {
const ev = data as GroupMessageEvent;
recordKnownUser({
openid: ev.author.member_openid,
type: "group",
groupOpenid: ev.group_openid,
accountId,
});
const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements);
return {
action: "message",
msg: {
type: "group",
senderId: ev.author.member_openid,
content: ev.content,
messageId: ev.id,
timestamp: ev.timestamp,
groupOpenid: ev.group_openid,
attachments: ev.attachments,
refMsgIdx: refs.refMsgIdx,
msgIdx: refs.msgIdx,
msgType: ev.message_type,
msgElements: ev.msg_elements,
},
};
return { action: "message", msg: buildGroupQueuedMessage(data, accountId, eventType) };
}
if (eventType === GatewayEvent.GROUP_MESSAGE_CREATE) {
return { action: "message", msg: buildGroupQueuedMessage(data, accountId, eventType) };
}
if (eventType === GatewayEvent.INTERACTION_CREATE) {
@@ -153,3 +134,44 @@ export function dispatchEvent(
return { action: "ignore" };
}
/**
* Build a {@link QueuedMessage} from a raw QQ group event payload.
*
* Used for both `GROUP_AT_MESSAGE_CREATE` (bot was @-ed) and
* `GROUP_MESSAGE_CREATE` (non-@ background chatter). The only difference
* between the two is the carried `eventType` — downstream gating uses
* that to decide whether to treat the message as a bot-directed turn.
*/
function buildGroupQueuedMessage(
data: unknown,
accountId: string,
eventType: string,
): QueuedMessage {
const ev = data as GroupMessageEvent;
recordKnownUser({
openid: ev.author.member_openid,
type: "group",
groupOpenid: ev.group_openid,
accountId,
});
const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements);
return {
type: "group",
senderId: ev.author.member_openid,
senderName: ev.author.username,
senderIsBot: ev.author.bot,
content: ev.content,
messageId: ev.id,
timestamp: ev.timestamp,
groupOpenid: ev.group_openid,
attachments: ev.attachments,
refMsgIdx: refs.refMsgIdx,
msgIdx: refs.msgIdx,
msgType: ev.message_type,
msgElements: ev.msg_elements,
eventType,
mentions: ev.mentions,
messageScene: ev.message_scene,
};
}

View File

@@ -6,16 +6,17 @@
* - dispatchOutbound: AI dispatch, deliver callbacks, timeouts
*
* The only responsibilities of this file are:
* 1. Register audio adapters
* 1. Initialize adapters from EngineAdapters
* 2. Initialize API config + refIdx cache hook
* 3. Create the message handler (inbound → outbound pipeline)
* 4. Start GatewayConnection
*/
import path from "node:path";
import { getPlatformAdapter } from "../adapter/index.js";
import { parseApprovalButtonData } from "../approval/index.js";
import { registerOutboundAudioAdapter } from "../messaging/outbound.js";
import { initCommands } from "../commands/slash-commands-impl.js";
import { createNodeSessionStoreReader } from "../group/activation.js";
import type { HistoryEntry } from "../group/history.js";
import { setOutboundAudioPort } from "../messaging/outbound.js";
import {
clearTokenCache,
getAccessToken,
@@ -24,24 +25,13 @@ import {
sendInputNotify as senderSendInputNotify,
createRawInputNotifyFn,
accountToCreds,
acknowledgeInteraction,
} from "../messaging/sender.js";
import { setRefIndex } from "../ref/store.js";
import type { InteractionEvent } from "../types.js";
import {
audioFileToSilkBase64,
convertSilkToWav,
isVoiceAttachment,
isAudioFile,
shouldTranscodeVoice,
waitForFile,
} from "../utils/audio.js";
import { runDiagnostics } from "../utils/diagnostics.js";
import { formatDuration } from "../utils/format.js";
import { runWithRequestContext } from "../utils/request-context.js";
import { GatewayConnection } from "./gateway-connection.js";
import { registerAudioConvertAdapter } from "./inbound-attachments.js";
import { buildInboundContext } from "./inbound-pipeline.js";
import { buildInboundContext, clearGroupPendingHistory } from "./inbound-pipeline.js";
import { createInteractionHandler } from "./interaction-handler.js";
import type { QueuedMessage } from "./message-queue.js";
import { dispatchOutbound } from "./outbound-dispatch.js";
import type {
@@ -61,16 +51,11 @@ export type { CoreGatewayContext } from "./types.js";
* Start the Gateway WebSocket connection with automatic reconnect support.
*/
export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
const { account, log, runtime } = ctx;
const { account, log, runtime, adapters } = ctx;
// ---- 1. Register audio adapters ----
registerAudioConvertAdapter({ convertSilkToWav, isVoiceAttachment, formatDuration });
registerOutboundAudioAdapter({
audioFileToSilkBase64: async (p, f) => (await audioFileToSilkBase64(p, f)) ?? undefined,
isAudioFile,
shouldTranscodeVoice,
waitForFile,
});
// ---- 1. Initialize adapters ----
setOutboundAudioPort(adapters.outboundAudio);
initCommands(adapters.commands);
// ---- 2. Validate ----
if (!account.appId || !account.clientSecret) {
@@ -120,9 +105,31 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
});
});
// ---- 6. Message handler ----
// ---- 6. Group support (per-connection state) ----
const groupOpts = {
enabled: ctx.group?.enabled ?? true,
allowTextCommands: ctx.group?.allowTextCommands,
isControlCommand: ctx.group?.isControlCommand,
resolveIntroHint: ctx.group?.resolveIntroHint,
sessionStoreReader: ctx.group?.sessionStoreReader,
};
const groupChatEnabled = groupOpts.enabled;
const groupHistories: Map<string, HistoryEntry[]> | undefined = groupChatEnabled
? new Map()
: undefined;
const sessionStoreReader = groupChatEnabled
? (groupOpts.sessionStoreReader ?? createNodeSessionStoreReader())
: undefined;
// ---- 7. Message handler ----
const handleMessage = async (event: QueuedMessage): Promise<void> => {
log?.info(`Processing message from ${event.senderId}: ${event.content}`);
log?.info(`Processing message from ${event.senderId}: ${event.content}`, {
accountId: account.accountId,
messageId: event.messageId,
senderId: event.senderId,
type: event.type,
groupOpenid: event.groupOpenid,
});
runtime.channel.activity.record({
channel: "qqbot",
@@ -136,10 +143,37 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
log,
runtime,
startTyping: (ev) => startTypingForEvent(ev, account, log),
groupHistories,
sessionStoreReader,
allowTextCommands: groupOpts.allowTextCommands,
isControlCommand: groupOpts.isControlCommand,
resolveGroupIntroHint: groupOpts.resolveIntroHint,
adapters,
});
if (inbound.blocked) {
log?.info(`Dropped inbound qqbot message: ${inbound.blockReason ?? "blocked by allowFrom"}`);
log?.info(`Dropped inbound qqbot message: ${inbound.blockReason ?? "blocked by allowFrom"}`, {
accountId: account.accountId,
messageId: event.messageId,
blockReason: inbound.blockReason,
});
inbound.typing.keepAlive?.stop();
return;
}
// Group gate decided to stop early (drop_other_mention, block, skip
// no-mention). History has already been recorded inside the
// pipeline; there is no outbound to dispatch.
if (inbound.skipped) {
log?.info(
`Skipped group inbound: reason=${inbound.skipReason ?? "unknown"} group=${event.groupOpenid ?? ""}`,
{
accountId: account.accountId,
messageId: event.messageId,
skipReason: inbound.skipReason,
groupOpenid: event.groupOpenid,
},
);
inbound.typing.keepAlive?.stop();
return;
}
@@ -158,13 +192,24 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
log?.error(`Message processing failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
inbound.typing.keepAlive?.stop();
// Reset the buffered non-@ chatter after every @-activation turn
// (success or failure), matching the standalone build. Guards
// against stale history leaking into the next reply.
if (event.type === "group" && event.groupOpenid && inbound.group) {
clearGroupPendingHistory({
historyMap: groupHistories,
groupOpenid: event.groupOpenid,
historyLimit: inbound.group.historyLimit,
historyPort: adapters.history,
});
}
}
};
// ---- 7. Interaction handler ----
const handleInteraction = createApprovalInteractionHandler(account, log);
// ---- 8. Interaction handler ----
const handleInteraction = createInteractionHandler(account, ctx.runtime, log);
// ---- 8. Start connection ----
// ---- 9. Start connection ----
const connection = new GatewayConnection({
account,
abortSignal: ctx.abortSignal,
@@ -244,43 +289,3 @@ async function startTypingForEvent(
return { keepAlive: null };
}
}
// ============ Interaction handler ============
/**
* Default INTERACTION_CREATE handler — ACK the interaction and resolve
* approval button clicks via the registered PlatformAdapter.
*/
function createApprovalInteractionHandler(
account: GatewayAccount,
log?: EngineLogger,
): (event: InteractionEvent) => void {
return (event) => {
const creds = accountToCreds(account);
// ACK the interaction first to prevent QQ from showing a timeout error.
void acknowledgeInteraction(creds, event.id).catch((err) => {
log?.error(`Interaction ACK failed: ${err instanceof Error ? err.message : String(err)}`);
});
const buttonData = event.data?.resolved?.button_data ?? "";
const parsed = parseApprovalButtonData(buttonData);
if (!parsed) {
return;
}
const adapter = getPlatformAdapter();
if (!adapter.resolveApproval) {
log?.error(`resolveApproval not available on PlatformAdapter`);
return;
}
void adapter.resolveApproval(parsed.approvalId, parsed.decision).then((ok) => {
if (ok) {
log?.info(`Approval resolved: id=${parsed.approvalId}, decision=${parsed.decision}`);
} else {
log?.error(`Approval resolve failed: id=${parsed.approvalId}`);
}
});
};
}

View File

@@ -1,9 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
processAttachments,
registerAudioConvertAdapter,
type AudioConvertAdapter,
} from "./inbound-attachments.js";
import { processAttachments, type AudioConvertPort } from "./inbound-attachments.js";
const downloadFileMock = vi.hoisted(() => vi.fn());
const resolveSTTConfigMock = vi.hoisted(() => vi.fn());
@@ -22,27 +18,29 @@ vi.mock("../utils/stt.js", () => ({
transcribeAudio: transcribeAudioMock,
}));
function registerAdapter(overrides: Partial<AudioConvertAdapter> = {}): void {
registerAudioConvertAdapter({
function createAudioConvert(overrides: Partial<AudioConvertPort> = {}): AudioConvertPort {
return {
convertSilkToWav: vi.fn(async () => null),
formatDuration: (seconds) => `${seconds}s`,
isVoiceAttachment: (att) =>
formatDuration: (seconds: number) => `${seconds}s`,
isVoiceAttachment: (att: { content_type: string; filename?: string }) =>
att.content_type === "voice" || att.content_type.startsWith("audio/"),
...overrides,
});
};
}
describe("engine/gateway/inbound-attachments", () => {
let audioConvert: AudioConvertPort;
beforeEach(() => {
vi.clearAllMocks();
resolveSTTConfigMock.mockReturnValue(null);
transcribeAudioMock.mockResolvedValue(null);
registerAdapter();
audioConvert = createAudioConvert();
});
it("returns an empty result when no attachments are present", async () => {
await expect(
processAttachments(undefined, { accountId: "qq", cfg: {} }),
processAttachments(undefined, { accountId: "qq", cfg: {}, audioConvert }),
).resolves.toMatchObject({
attachmentInfo: "",
imageUrls: [],
@@ -56,7 +54,7 @@ describe("engine/gateway/inbound-attachments", () => {
const result = await processAttachments(
[{ content_type: "image/png", url: "//cdn.example.test/a.png", filename: "a.png" }],
{ accountId: "qq", cfg: {} },
{ accountId: "qq", cfg: {}, audioConvert },
);
expect(downloadFileMock).toHaveBeenCalledWith(
@@ -88,7 +86,7 @@ describe("engine/gateway/inbound-attachments", () => {
asr_refer_text: "platform text",
},
],
{ accountId: "qq", cfg: { channels: { qqbot: { stt: {} } } } },
{ accountId: "qq", cfg: { channels: { qqbot: { stt: {} } } }, audioConvert },
);
expect(downloadFileMock).toHaveBeenCalledWith(
@@ -117,7 +115,7 @@ describe("engine/gateway/inbound-attachments", () => {
asr_refer_text: "platform text",
},
],
{ accountId: "qq", cfg: {} },
{ accountId: "qq", cfg: {}, audioConvert },
);
expect(result.voiceAttachmentUrls).toEqual(["https://cdn.example.test/voice.silk"]);

View File

@@ -1,3 +1,4 @@
import type { AudioConvertPort } from "../adapter/audio.port.js";
import { downloadFile } from "../utils/file-utils.js";
import { getQQBotMediaDir } from "../utils/platform.js";
import { normalizeOptionalString } from "../utils/string-normalize.js";
@@ -5,31 +6,8 @@ import { transcribeAudio, resolveSTTConfig } from "../utils/stt.js";
// Re-export formatVoiceText from core/.
export { formatVoiceText } from "../utils/voice-text.js";
// ---- Injected audio-convert dependencies ----
/** Audio conversion interface — implemented by the upper-layer audio-convert module. */
export interface AudioConvertAdapter {
convertSilkToWav(
silkPath: string,
outputDir: string,
): Promise<{ wavPath: string; duration: number } | null>;
isVoiceAttachment(att: { content_type: string; filename?: string }): boolean;
formatDuration(seconds: number): string;
}
let _audioAdapter: AudioConvertAdapter | null = null;
/** Register the audio conversion adapter — called by gateway startup. */
export function registerAudioConvertAdapter(adapter: AudioConvertAdapter): void {
_audioAdapter = adapter;
}
function getAudioAdapter(): AudioConvertAdapter {
if (!_audioAdapter) {
throw new Error("AudioConvertAdapter not registered — call registerAudioConvertAdapter first");
}
return _audioAdapter;
}
// Re-export the port type for convenience.
export type { AudioConvertPort } from "../adapter/audio.port.js";
export interface RawAttachment {
content_type: string;
@@ -57,6 +35,7 @@ export interface ProcessedAttachments {
interface ProcessContext {
accountId: string;
cfg: unknown;
audioConvert: AudioConvertPort;
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
@@ -85,7 +64,7 @@ export async function processAttachments(
return EMPTY_RESULT;
}
const { accountId: _accountId, cfg, log } = ctx;
const { accountId: _accountId, cfg, log, audioConvert } = ctx;
const downloadDir = getQQBotMediaDir("downloads");
const imageUrls: string[] = [];
@@ -101,7 +80,7 @@ export async function processAttachments(
// Phase 1: download all attachments in parallel.
const downloadTasks = attachments.map(async (att) => {
const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
const isVoice = getAudioAdapter().isVoiceAttachment(att);
const isVoice = audioConvert.isVoiceAttachment(att);
const wavUrl =
isVoice && att.voice_wav_url
? att.voice_wav_url.startsWith("//")
@@ -163,6 +142,7 @@ export async function processAttachments(
asrReferText,
cfg,
downloadDir,
audioConvert,
log,
);
}
@@ -276,6 +256,7 @@ async function processVoiceAttachment(
asrReferText: string,
cfg: unknown,
downloadDir: string,
audioConvert: AudioConvertPort,
log: ProcessContext["log"],
): Promise<VoiceResult> {
const wavUrl = att.voice_wav_url
@@ -312,11 +293,11 @@ async function processVoiceAttachment(
if (!audioPath) {
log?.debug?.(`Voice attachment: ${att.filename}, converting SILK→WAV...`);
try {
const wavResult = await getAudioAdapter().convertSilkToWav(localPath, downloadDir);
const wavResult = await audioConvert.convertSilkToWav(localPath, downloadDir);
if (wavResult) {
audioPath = wavResult.wavPath;
log?.debug?.(
`Voice converted: ${wavResult.wavPath} (${getAudioAdapter().formatDuration(wavResult.duration)})`,
`Voice converted: ${wavResult.wavPath} (${audioConvert.formatDuration(wavResult.duration)})`,
);
} else {
audioPath = localPath;

View File

@@ -9,6 +9,10 @@
*/
import type { QQBotAccessDecision, QQBotAccessReasonCode } from "../access/index.js";
import type { EngineAdapters } from "../adapter/index.js";
import type { GroupActivationMode, SessionStoreReader } from "../group/activation.js";
import type { HistoryEntry } from "../group/history.js";
import type { GroupMessageGateResult } from "../group/message-gating.js";
import type { QueuedMessage } from "./message-queue.js";
import type {
GatewayAccount,
@@ -28,6 +32,46 @@ export interface ReplyToInfo {
isQuote: boolean;
}
/**
* Group-specific inbound metadata.
*
* Populated for group / guild events; left `undefined` for DMs. Keeping
* the group fields under a nested bag makes it obvious which fields are
* safe to read only when `isGroupChat === true`.
*
* The shape is kept small on purpose: everything derivable from `gate`
* (raw wasMentioned / explicit / implicit / hasAnyMention / bypass) is
* stored once on `gate`, not duplicated on the outer object.
*/
export interface InboundGroupInfo {
// ---- Gating decision ----
/** Full gate evaluation result (source of truth for mention state). */
gate: GroupMessageGateResult;
/** Effective activation mode after session-store / cfg merge. */
activation: GroupActivationMode;
// ---- Persistence-relevant ----
/** Per-group history buffer cap. Zero → disabled. */
historyLimit: number;
/** `true` if this message was built by merging several queued entries. */
isMerged: boolean;
/** The unfiltered list of queued messages when `isMerged`, else undefined. */
mergedMessages?: readonly QueuedMessage[];
// ---- Presentation / prompt inputs ----
/** Bundle of display-only strings; assembled by the envelope stage. */
display: {
/** Human-readable group name ("My Group" / first 8 chars of openid). */
groupName: string;
/** Sender label ("Nick (OPENID)" / "OPENID") for the UI. */
senderLabel: string;
/** Channel-level intro hint contributed by the platform adapter. */
introHint?: string;
/** Per-group behaviour prompt appended to the system prompt. */
behaviorPrompt?: string;
};
}
/** Fully resolved inbound context passed to the outbound dispatcher. */
export interface InboundContext {
// ---- Original event ----
@@ -81,22 +125,32 @@ export interface InboundContext {
// ---- Auth ----
commandAuthorized: boolean;
// ---- Group ----
/** Populated only for group / guild messages. */
group?: InboundGroupInfo;
// ---- Blocking / skipping ----
/**
* Whether the inbound message should be blocked outright (i.e. the bot
* neither routes it to an agent nor replies). Set when the sender is
* not matched by the configured `allowFrom`/`groupAllowFrom` list
* under the active `dmPolicy` / `groupPolicy`.
* Whether the inbound message should be blocked outright (access policy
* refused the sender). Mutually exclusive with `skipped`.
*/
blocked: boolean;
/** Human-readable reason for `blocked`, for logging only. */
blockReason?: string;
/**
* Structured reason code for `blocked`, suitable for metrics and
* activity indicators.
*/
/** Structured reason code for `blocked`. */
blockReasonCode?: QQBotAccessReasonCode;
/** The raw access decision produced by the policy engine. */
accessDecision?: QQBotAccessDecision;
/**
* Whether the inbound was accepted by access control but stopped before
* AI dispatch by the group gate (e.g. "skip_no_mention"). The caller
* should NOT forward `skipped` messages to the outbound dispatcher, but
* history / activity side-effects may already have been applied.
*/
skipped: boolean;
/** Structured reason code for `skipped`. */
skipReason?: "drop_other_mention" | "block_unauthorized_command" | "skip_no_mention";
// ---- Typing ----
typing: { keepAlive: TypingKeepAlive | null };
@@ -117,4 +171,25 @@ export interface InboundPipelineDeps {
refIdx?: string;
keepAlive: TypingKeepAlive | null;
}>;
// ---- Group dependencies (optional — omit when the caller doesn't need
// group support, e.g. a DM-only test harness). ----
/** Shared per-connection history buffer, created by the gateway. */
groupHistories?: Map<string, HistoryEntry[]>;
/** Session-store reader for activation-mode overrides. */
sessionStoreReader?: SessionStoreReader;
/** Whether text-based control commands are enabled globally. */
allowTextCommands?: boolean;
/**
* Framework probe that returns true when `content` is a known control
* command. Injected to avoid hard-coding a list of commands in engine.
*/
isControlCommand?: (content: string) => boolean;
/** Optional platform hook that contributes a channel-level intro hint. */
resolveGroupIntroHint?: (params: {
cfg: unknown;
accountId: string;
groupId: string;
}) => string | undefined;
/** SDK adapter ports for delegating to shared implementations. */
adapters: EngineAdapters;
}

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QQBOT_ACCESS_REASON } from "../access/index.js";
import type { RefIndexEntry } from "../ref/types.js";
import type { InboundPipelineDeps } from "./inbound-context.js";
import { buildInboundContext } from "./inbound-pipeline.js";
@@ -93,6 +92,36 @@ function makeDeps(overrides: Partial<InboundPipelineDeps> = {}): InboundPipeline
log: { info: vi.fn(), error: vi.fn(), debug: vi.fn() },
runtime: makeRuntime(),
startTyping: vi.fn(async () => ({ keepAlive: null })),
adapters: {
history: {
recordPendingHistoryEntry: vi.fn(() => []),
buildPendingHistoryContext: vi.fn(() => ""),
clearPendingHistory: vi.fn(),
},
mentionGate: {
resolveInboundMentionDecision: vi.fn(() => ({
effectiveWasMentioned: false,
shouldSkip: false,
shouldBypassMention: false,
implicitMention: false,
})),
},
audioConvert: {
convertSilkToWav: vi.fn(async () => null),
isVoiceAttachment: vi.fn(() => false),
formatDuration: vi.fn(() => "0s"),
},
outboundAudio: {
audioFileToSilkBase64: vi.fn(async () => undefined),
isAudioFile: vi.fn(() => false),
shouldTranscodeVoice: vi.fn(() => false),
waitForFile: vi.fn(async () => 0),
},
commands: {
pluginVersion: "0.0.0-test",
resolveVersion: vi.fn(() => "0.0.0"),
},
},
...overrides,
};
}
@@ -105,7 +134,7 @@ describe("buildInboundContext bot self-echo suppression", () => {
processAttachmentsMock.mockResolvedValue(emptyProcessedAttachments);
});
it("blocks inbound events whose current msgIdx matches this bot's outbound ref", async () => {
it("does not block inbound events whose current msgIdx matches this bot's outbound ref (self-echo handled upstream)", async () => {
getRefIndexMock.mockReturnValue({
content: "mirrored reply",
senderId: "qq-main",
@@ -116,13 +145,11 @@ describe("buildInboundContext bot self-echo suppression", () => {
const inbound = await buildInboundContext(makeEvent({ msgIdx: "REF_BOT" }), deps);
expect(getRefIndexMock).toHaveBeenCalledWith("REF_BOT");
expect(inbound.blocked).toBe(true);
expect(inbound.blockReasonCode).toBe(QQBOT_ACCESS_REASON.BOT_SELF_ECHO);
expect(inbound.body).toBe("");
expect(deps.startTyping).not.toHaveBeenCalled();
expect(processAttachmentsMock).not.toHaveBeenCalled();
expect(setRefIndexMock).not.toHaveBeenCalled();
// Self-echo suppression is handled by the gateway layer upstream;
// buildInboundContext no longer short-circuits on msgIdx match.
expect(inbound.blocked).toBe(false);
expect(deps.startTyping).toHaveBeenCalledTimes(1);
expect(processAttachmentsMock).toHaveBeenCalledTimes(1);
});
it("does not block a user message that quotes a bot-authored ref", async () => {

View File

@@ -1,279 +1,178 @@
/**
* Inbound pipeline — build a fully resolved InboundContext from a raw QueuedMessage.
* Inbound pipeline — compose stages into a single
* {@link buildInboundContext} call.
*
* Responsibilities:
* 1. Route resolution
* 2. Attachment processing (download + STT)
* 3. Content building (parseFaceTags + voiceText + attachmentInfo)
* 4. Quote / reply-to resolution (three-level fallback)
* 5. RefIdx cache write (setRefIndex)
* 6. Body / agentBody / ctxPayload data assembly
* The pipeline stays intentionally thin: all real logic lives in
* `./stages/*`. Reading this file top-to-bottom should be enough to
* understand the full inbound path.
*
* No message sending. Independently testable.
* Stage order:
* 1. access — route + access control (early return on block)
* 2. attachments — download + STT + image metadata
* 3. typing — start the typing indicator (awaited before refIdx write)
* 4. content — parseFaceTags + voice text + attachment info + mention cleanup
* 5. quote — resolve `refMsgIdx` three ways
* 6. refIdx — cache the current message so future quotes work
* 7. group gate — @mention / ignoreOther / activation / command bypass
* (early return on skip, history already recorded)
* 8. envelope — body / quotePart / dynamicCtx
* 9. assembly — userMessage + agentBody (with pending-history prefix)
* 10. system — final group system prompt composition
* 11. classify — media classification (local vs remote; dedup voice)
*
* Returns a fully populated {@link InboundContext}. The gateway handler
* then branches on `blocked` / `skipped` to decide whether to dispatch
* outbound.
*/
import {
normalizeQQBotSenderId,
resolveQQBotAccess,
QQBOT_ACCESS_REASON,
type QQBotAccessResult,
} from "../access/index.js";
import {
formatMessageReferenceForAgent,
type AttachmentProcessor,
} from "../ref/format-message-ref.js";
import { getRefIndex, setRefIndex, formatRefEntryForAgent } from "../ref/store.js";
import { parseFaceTags, buildAttachmentSummaries, MSG_TYPE_QUOTE } from "../utils/text-parsing.js";
import { formatVoiceText } from "../utils/voice-text.js";
import type { HistoryPort } from "../adapter/history.port.js";
import type { HistoryEntry } from "../group/history.js";
import { processAttachments } from "./inbound-attachments.js";
import type { InboundContext, InboundPipelineDeps } from "./inbound-context.js";
import type { QueuedMessage } from "./message-queue.js";
// ============ buildInboundContext ============
import {
buildAgentBody,
buildBody,
buildDynamicCtx,
buildGroupSystemPrompt,
buildQuotePart,
buildSkippedInboundContext,
buildUserContent,
buildUserMessage,
classifyMedia,
resolveCommandAuthorized,
resolveQuote,
runAccessStage,
runGroupGateStage,
writeRefIndex,
} from "./stages/index.js";
/**
* Process a raw queued message through the full inbound pipeline and return
* a structured {@link InboundContext} ready for outbound dispatch.
* Process a raw queued message through the full inbound pipeline.
*
* Returns an {@link InboundContext} with `blocked` / `skipped` set when
* the message should not reach the AI dispatcher.
*/
export async function buildInboundContext(
event: QueuedMessage,
deps: InboundPipelineDeps,
): Promise<InboundContext> {
const { account, cfg, log, runtime } = deps;
const { account, log } = deps;
// ---- 1. Route resolution ----
const isGroupChat = event.type === "guild" || event.type === "group";
const peerId =
event.type === "guild"
? (event.channelId ?? "unknown")
: event.type === "group"
? (event.groupOpenid ?? "unknown")
: event.senderId;
// ---- 1. Access ----
const accessResult = runAccessStage(event, deps);
if (accessResult.kind === "block") {
return accessResult.context;
}
const { isGroupChat, peerId, qualifiedTarget, fromAddress, route, access } = accessResult;
const route = runtime.channel.routing.resolveAgentRoute({
cfg,
channel: "qqbot",
// ---- 2. Typing indicator (async; awaited before refIdx write) ----
const typingPromise = deps.startTyping(event);
// ---- 3. Attachments ----
const processed = await processAttachments(event.attachments, {
accountId: account.accountId,
peer: { kind: isGroupChat ? "group" : "direct", id: peerId },
cfg: deps.cfg,
audioConvert: deps.adapters.audioConvert,
log,
});
const qualifiedTarget = isGroupChat
? event.type === "guild"
? `qqbot:channel:${event.channelId}`
: `qqbot:group:${event.groupOpenid}`
: event.type === "dm"
? `qqbot:dm:${event.guildId}`
: `qqbot:c2c:${event.senderId}`;
const fromAddress = qualifiedTarget;
const selfEchoAccess = resolveBotSelfEchoAccess(event, account.accountId);
if (selfEchoAccess) {
log?.info(
`Blocked qqbot inbound self-echo: reasonCode=${selfEchoAccess.reasonCode} ` +
`msgIdx=${event.msgIdx ?? ""} senderId=${normalizeQQBotSenderId(event.senderId)} ` +
`accountId=${account.accountId} isGroup=${isGroupChat}`,
);
return buildBlockedInboundContext({
event,
route,
isGroupChat,
peerId,
qualifiedTarget,
fromAddress,
access: selfEchoAccess,
});
}
// ---- 1a. Early access control ----
//
// Evaluate the account-level dmPolicy / groupPolicy + allowFrom /
// groupAllowFrom whitelist before any expensive I/O (typing
// indicator, attachment downloads, quote resolution). Semantics are
// aligned with WhatsApp/Telegram/Discord (see `engine/access/`).
//
// When blocked, we return a minimal stub InboundContext and rely on
// the gateway handler to skip dispatch.
const access = resolveQQBotAccess({
isGroup: isGroupChat,
senderId: event.senderId,
allowFrom: account.config?.allowFrom,
groupAllowFrom: account.config?.groupAllowFrom,
dmPolicy: account.config?.dmPolicy,
groupPolicy: account.config?.groupPolicy,
// ---- 4. Content ----
const { parsedContent, userContent } = buildUserContent({
event,
attachmentInfo: processed.attachmentInfo,
voiceTranscripts: processed.voiceTranscripts,
});
if (access.decision !== "allow") {
log?.info(
`Blocked qqbot inbound: decision=${access.decision} reasonCode=${access.reasonCode} ` +
`reason=${access.reason} senderId=${normalizeQQBotSenderId(event.senderId)} ` +
`accountId=${account.accountId} isGroup=${isGroupChat}`,
);
return buildBlockedInboundContext({
// ---- 5. Quote ----
const replyTo = await resolveQuote(event, deps);
// ---- 6. RefIdx ----
const typingResult = await typingPromise;
writeRefIndex({
event,
parsedContent,
processed,
inputNotifyRefIdx: typingResult.refIdx,
});
// ---- 7. Group gate ----
let groupInfo: InboundContext["group"];
if (event.type === "group" && event.groupOpenid) {
const gateOutcome = runGroupGateStage({
event,
route,
isGroupChat,
peerId,
qualifiedTarget,
fromAddress,
access,
deps,
accountId: account.accountId,
agentId: route.agentId,
sessionKey: route.sessionKey,
userContent,
processedAttachments: processed,
});
if (gateOutcome.kind === "skip") {
typingResult.keepAlive?.stop();
return buildSkippedInboundContext({
event,
route,
isGroupChat: true,
peerId,
qualifiedTarget,
fromAddress,
group: gateOutcome.groupInfo,
skipReason: gateOutcome.skipReason,
access,
typing: { keepAlive: typingResult.keepAlive },
inputNotifyRefIdx: typingResult.refIdx,
});
}
groupInfo = gateOutcome.groupInfo;
}
// ---- 2. System prompts ----
// ---- 8. Envelope ----
const body = buildBody({
event,
deps,
userContent,
isGroupChat,
imageUrls: processed.imageUrls,
});
const quotePart = buildQuotePart(replyTo);
const media = classifyMedia(processed);
const dynamicCtx = buildDynamicCtx({
imageUrls: processed.imageUrls,
uniqueVoicePaths: media.uniqueVoicePaths,
uniqueVoiceUrls: media.uniqueVoiceUrls,
uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts,
});
// ---- 9. Assembly ----
const userMessage = buildUserMessage({
event,
userContent,
quotePart,
isGroupChat,
groupInfo,
});
const agentBody = buildAgentBody({
event,
userContent,
userMessage,
dynamicCtx,
isGroupChat,
groupInfo,
deps,
});
// ---- 10. System prompt ----
const systemPrompts: string[] = [];
if (account.systemPrompt) {
systemPrompts.push(account.systemPrompt);
}
const accountSystemInstruction = systemPrompts.length > 0 ? systemPrompts.join("\n") : "";
const groupSystemPrompt = buildGroupSystemPrompt(accountSystemInstruction, groupInfo);
// ---- 3. Typing indicator (async, await later) ----
const typingPromise = deps.startTyping(event);
// ---- 4. Attachment processing ----
const processed = await processAttachments(event.attachments, {
accountId: account.accountId,
cfg,
log,
});
const {
attachmentInfo,
imageUrls,
imageMediaTypes,
voiceAttachmentPaths,
voiceAttachmentUrls,
voiceAsrReferTexts,
voiceTranscripts,
voiceTranscriptSources,
attachmentLocalPaths,
} = processed;
// ---- 5. Content building ----
const voiceText = formatVoiceText(voiceTranscripts);
const hasAsrReferFallback = voiceTranscriptSources.includes("asr");
const parsedContent = parseFaceTags(event.content);
const userContent = voiceText
? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
: parsedContent + attachmentInfo;
// ---- 6. Quote / reply-to resolution ----
const replyTo = await resolveQuote(event, account, cfg, log);
// ---- 7. RefIdx cache write ----
const typingResult = await typingPromise;
const inputNotifyRefIdx = typingResult.refIdx;
const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx;
if (currentMsgIdx) {
const attSummaries = buildAttachmentSummaries(event.attachments, attachmentLocalPaths);
if (attSummaries && voiceTranscripts.length > 0) {
let voiceIdx = 0;
for (const att of attSummaries) {
if (att.type === "voice" && voiceIdx < voiceTranscripts.length) {
att.transcript = voiceTranscripts[voiceIdx];
if (voiceIdx < voiceTranscriptSources.length) {
att.transcriptSource = voiceTranscriptSources[voiceIdx] as
| "stt"
| "asr"
| "tts"
| "fallback";
}
voiceIdx++;
}
}
}
setRefIndex(currentMsgIdx, {
content: parsedContent,
senderId: event.senderId,
senderName: event.senderName,
timestamp: new Date(event.timestamp).getTime(),
attachments: attSummaries,
});
}
// ---- 8. Envelope (Web UI body) ----
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
const body = runtime.channel.reply.formatInboundEnvelope({
channel: "qqbot",
from: event.senderName ?? event.senderId,
timestamp: new Date(event.timestamp).getTime(),
body: userContent,
chatType: isGroupChat ? "group" : "direct",
sender: { id: event.senderId, name: event.senderName },
envelope: envelopeOptions,
...(imageUrls.length > 0 ? { imageUrls } : {}),
});
// ---- 9. Voice dedup ----
const uniqueVoicePaths = [...new Set(voiceAttachmentPaths)];
const uniqueVoiceUrls = [...new Set(voiceAttachmentUrls)];
const uniqueVoiceAsrReferTexts = [...new Set(voiceAsrReferTexts)].filter(Boolean);
// ---- 11. Quote part ----
let quotePart = "";
if (replyTo) {
quotePart = replyTo.body
? `[Quoted message begins]\n${replyTo.body}\n[Quoted message ends]\n`
: `[Quoted message begins]\nOriginal content unavailable\n[Quoted message ends]\n`;
}
// ---- 12. Dynamic context ----
const dynLines: string[] = [];
if (imageUrls.length > 0) {
dynLines.push(`- Images: ${imageUrls.join(", ")}`);
}
if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
dynLines.push(`- Voice: ${[...uniqueVoicePaths, ...uniqueVoiceUrls].join(", ")}`);
}
if (uniqueVoiceAsrReferTexts.length > 0) {
dynLines.push(`- ASR: ${uniqueVoiceAsrReferTexts.join(" | ")}`);
}
const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n\n" : "";
// ---- 13. agentBody ----
const userMessage = `${quotePart}${userContent}`;
const agentBody = userContent.startsWith("/") ? userContent : `${dynamicCtx}${userMessage}`;
// ---- 14. GroupSystemPrompt ----
const qqbotSystemInstruction = systemPrompts.length > 0 ? systemPrompts.join("\n") : "";
const groupSystemPrompt = qqbotSystemInstruction || undefined;
// ---- 15. Auth: commandAuthorized semantics ----
//
// `commandAuthorized=true` means the framework is allowed to honour
// `/xxx` directives (e.g. `/exec host=... ask=...`) from this sender.
//
// We treat the sender as authorized when one of the following holds:
// - DM with policy=open (the bot owner implicitly trusts DMs)
// - DM with policy=allowlist and sender matched
// - Group where the sender is explicitly in groupAllowFrom/allowFrom
// (matches the `allowlist (allowlisted)` reason string).
//
// Notably, a group running in `policy=open` does NOT grant command
// authorization to arbitrary group members, aligning with the other
// channel plugins (Telegram/WhatsApp/Discord) which require explicit
// allowlist membership for command-level gating.
const commandAuthorized =
access.reasonCode === "dm_policy_open" ||
access.reasonCode === "dm_policy_allowlisted" ||
(access.reasonCode === "group_policy_allowed" &&
access.effectiveGroupAllowFrom.length > 0 &&
access.groupPolicy === "allowlist");
// ---- 16. Media path classification ----
const localMediaPaths: string[] = [];
const localMediaTypes: string[] = [];
const remoteMediaUrls: string[] = [];
const remoteMediaTypes: string[] = [];
for (let i = 0; i < imageUrls.length; i++) {
const u = imageUrls[i];
const t = imageMediaTypes[i] ?? "image/png";
if (u.startsWith("http://") || u.startsWith("https://")) {
remoteMediaUrls.push(u);
remoteMediaTypes.push(t);
} else {
localMediaPaths.push(u);
localMediaTypes.push(t);
}
}
const voiceMediaTypes = [...uniqueVoicePaths, ...uniqueVoiceUrls].map(() => "audio/wav");
// ---- 11. Authorization ----
const commandAuthorized = resolveCommandAuthorized(access);
return {
event,
@@ -292,202 +191,45 @@ export async function buildInboundContext(
systemPrompts,
groupSystemPrompt,
attachments: processed,
localMediaPaths,
localMediaTypes,
remoteMediaUrls,
remoteMediaTypes,
uniqueVoicePaths,
uniqueVoiceUrls,
uniqueVoiceAsrReferTexts,
voiceMediaTypes,
hasAsrReferFallback,
voiceTranscriptSources,
localMediaPaths: media.localMediaPaths,
localMediaTypes: media.localMediaTypes,
remoteMediaUrls: media.remoteMediaUrls,
remoteMediaTypes: media.remoteMediaTypes,
uniqueVoicePaths: media.uniqueVoicePaths,
uniqueVoiceUrls: media.uniqueVoiceUrls,
uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts,
voiceMediaTypes: media.voiceMediaTypes,
hasAsrReferFallback: media.hasAsrReferFallback,
voiceTranscriptSources: media.voiceTranscriptSources,
replyTo,
commandAuthorized,
group: groupInfo,
blocked: false,
skipped: false,
accessDecision: access.decision,
typing: { keepAlive: typingResult.keepAlive },
inputNotifyRefIdx,
inputNotifyRefIdx: typingResult.refIdx,
};
}
// ============ Public history-clear helper ============
/**
* Build a stub InboundContext for blocked (unauthorized) messages.
*
* The gateway handler inspects `blocked` and skips outbound dispatch,
* so most fields can be left empty. We still populate routing/peer
* fields so logs and metrics remain meaningful.
* Clear a group's pending history buffer. Exposed so the gateway can
* call it in its `finally` block after a reply attempt.
*/
function buildBlockedInboundContext(params: {
event: QueuedMessage;
route: { sessionKey: string; accountId: string; agentId?: string };
isGroupChat: boolean;
peerId: string;
qualifiedTarget: string;
fromAddress: string;
access: QQBotAccessResult;
}): InboundContext {
const emptyProcessed: InboundContext["attachments"] = {
attachmentInfo: "",
imageUrls: [],
imageMediaTypes: [],
voiceAttachmentPaths: [],
voiceAttachmentUrls: [],
voiceAsrReferTexts: [],
voiceTranscripts: [],
voiceTranscriptSources: [],
attachmentLocalPaths: [],
};
return {
event: params.event,
route: params.route,
isGroupChat: params.isGroupChat,
peerId: params.peerId,
qualifiedTarget: params.qualifiedTarget,
fromAddress: params.fromAddress,
parsedContent: "",
userContent: "",
quotePart: "",
dynamicCtx: "",
userMessage: "",
agentBody: "",
body: "",
systemPrompts: [],
groupSystemPrompt: undefined,
attachments: emptyProcessed,
localMediaPaths: [],
localMediaTypes: [],
remoteMediaUrls: [],
remoteMediaTypes: [],
uniqueVoicePaths: [],
uniqueVoiceUrls: [],
uniqueVoiceAsrReferTexts: [],
voiceMediaTypes: [],
hasAsrReferFallback: false,
voiceTranscriptSources: [],
replyTo: undefined,
commandAuthorized: false,
blocked: true,
blockReason: params.access.reason,
blockReasonCode: params.access.reasonCode,
accessDecision: params.access.decision,
typing: { keepAlive: null },
inputNotifyRefIdx: undefined,
};
}
function resolveBotSelfEchoAccess(
event: QueuedMessage,
accountId: string,
): QQBotAccessResult | null {
const currentMsgIdx = event.msgIdx?.trim();
if (!currentMsgIdx) {
return null;
}
// Only the current message ref is a self-echo signal. `refMsgIdx` points at
// a quoted message, and real users must still be able to reply to bot output.
const refEntry = getRefIndex(currentMsgIdx);
if (refEntry?.isBot !== true || refEntry.senderId !== accountId) {
return null;
}
return {
decision: "block",
reasonCode: QQBOT_ACCESS_REASON.BOT_SELF_ECHO,
reason: "bot self-echo",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: [],
dmPolicy: "open",
groupPolicy: "open",
};
}
// ============ Quote resolution (internal) ============
async function resolveQuote(
event: QueuedMessage,
account: InboundPipelineDeps["account"],
cfg: unknown,
log?: InboundPipelineDeps["log"],
): Promise<InboundContext["replyTo"]> {
if (!event.refMsgIdx) {
return undefined;
}
const refEntry = getRefIndex(event.refMsgIdx);
if (refEntry) {
log?.debug?.(
`Quote detected via refMsgIdx cache: refMsgIdx=${event.refMsgIdx}, sender=${refEntry.senderName ?? refEntry.senderId}`,
);
return {
id: event.refMsgIdx,
body: formatRefEntryForAgent(refEntry),
sender: refEntry.senderName ?? refEntry.senderId,
isQuote: true,
};
}
if (event.msgType === MSG_TYPE_QUOTE && event.msgElements?.[0]) {
try {
const refElement = event.msgElements[0];
const refData = {
content: refElement.content ?? "",
attachments: refElement.attachments,
};
const attachmentProcessor: AttachmentProcessor = {
processAttachments: async (atts, refCtx) => {
const result = await processAttachments(
atts as Array<{
content_type: string;
url: string;
filename?: string;
voice_wav_url?: string;
asr_refer_text?: string;
}>,
{
accountId: account.accountId,
cfg: refCtx.cfg,
log: refCtx.log,
},
);
return {
attachmentInfo: result.attachmentInfo,
voiceTranscripts: result.voiceTranscripts,
voiceTranscriptSources: result.voiceTranscriptSources,
attachmentLocalPaths: result.attachmentLocalPaths,
};
},
formatVoiceText: (transcripts) => formatVoiceText(transcripts),
};
const refPeerId =
event.type === "group" && event.groupOpenid ? event.groupOpenid : event.senderId;
const refBody = await formatMessageReferenceForAgent(
refData,
{ appId: account.appId, peerId: refPeerId, cfg: account.config, log },
attachmentProcessor,
);
log?.debug?.(
`Quote detected via msg_elements[0] (cache miss): id=${event.refMsgIdx}, content="${(refBody ?? "").slice(0, 80)}..."`,
);
return {
id: event.refMsgIdx,
body: refBody || undefined,
isQuote: true,
};
} catch (refErr) {
log?.error(`Failed to format quoted message from msg_elements: ${String(refErr)}`);
}
} else {
log?.debug?.(
`Quote detected but no cache and msgType=${event.msgType}: refMsgIdx=${event.refMsgIdx}`,
);
}
return {
id: event.refMsgIdx,
isQuote: true,
};
export function clearGroupPendingHistory(params: {
historyMap: Map<string, HistoryEntry[]> | undefined;
groupOpenid: string | undefined;
historyLimit: number;
historyPort: HistoryPort;
}): void {
if (!params.historyMap || !params.groupOpenid) {
return;
}
params.historyPort.clearPendingHistory({
historyMap: params.historyMap,
historyKey: params.groupOpenid,
limit: params.historyLimit,
});
}

View File

@@ -0,0 +1,220 @@
/**
* INTERACTION_CREATE event handler.
*
* Handles three interaction branches:
*
* 1. **Config query** (type=2001) — reads config, ACKs with `claw_cfg`.
* 2. **Config update** (type=2002) — writes config, ACKs with updated snapshot.
* 3. **Approval button** (other) — ACKs, resolves approval via PlatformAdapter.
*
* Config query/update require `runtime.config`. When unavailable, those
* branches fall through to a bare ACK (backward-compatible).
*/
import { resolveQQBotEffectivePolicies } from "../access/resolve-policy.js";
import { getPlatformAdapter } from "../adapter/index.js";
import { parseApprovalButtonData } from "../approval/index.js";
import { getPluginVersion, getFrameworkVersion } from "../commands/slash-commands-impl.js";
import { resolveGroupConfig, resolveMentionPatterns } from "../config/group.js";
import { resolveAccountBase } from "../config/resolve.js";
import type { GroupActivationMode } from "../group/activation.js";
import { accountToCreds, acknowledgeInteraction } from "../messaging/sender.js";
import type { InteractionEvent, QQBotAccountConfigView } from "../types.js";
import { InteractionType } from "./constants.js";
import type { GatewayAccount, GatewayPluginRuntime, EngineLogger } from "./types.js";
// ============ claw_cfg snapshot ============
/**
* Build the canonical `claw_cfg` snapshot returned in interaction ACKs.
*
* Pure function — all resolution helpers live in engine/config/.
*/
function buildClawCfgSnapshot(
cfg: Record<string, unknown>,
accountId: string,
groupOpenid: string,
runtime: GatewayPluginRuntime,
): Record<string, unknown> {
const groupCfg = groupOpenid ? resolveGroupConfig(cfg, groupOpenid, accountId) : null;
const accountBase = resolveAccountBase(cfg, accountId);
const acctCfg = accountBase.config as QQBotAccountConfigView;
const policies = resolveQQBotEffectivePolicies({
allowFrom: acctCfg.allowFrom,
groupAllowFrom: acctCfg.groupAllowFrom,
dmPolicy: acctCfg.dmPolicy,
groupPolicy: acctCfg.groupPolicy,
});
const requireMentionMode: GroupActivationMode =
(groupCfg?.requireMention ?? true) ? "mention" : "always";
const interactionAgentId = groupOpenid
? (
runtime.channel.routing.resolveAgentRoute({
cfg,
channel: "qqbot",
accountId,
peer: { kind: "group", id: groupOpenid },
}) as { agentId?: string } | undefined
)?.agentId
: undefined;
return {
channel_type: "qqbot",
channel_ver: getPluginVersion(),
claw_type: "openclaw",
claw_ver: getFrameworkVersion(),
require_mention: requireMentionMode,
group_policy: policies.groupPolicy,
mention_patterns: resolveMentionPatterns(cfg, interactionAgentId).join(","),
online_state: "online",
};
}
// ============ Config update ============
/** Apply a config-update interaction and return the updated claw_cfg. */
async function applyConfigUpdate(
event: InteractionEvent,
accountId: string,
runtime: GatewayPluginRuntime,
log?: EngineLogger,
): Promise<Record<string, unknown>> {
const configApi = runtime.config;
if (!configApi) {
throw new Error("runtime.config not available");
}
const resolved = event.data?.resolved as Record<string, unknown> | undefined;
const clawCfgUpdate = resolved?.claw_cfg as Record<string, unknown> | undefined;
const groupOpenid = event.group_openid ?? "";
const currentCfg = structuredClone(configApi.current());
let changed = false;
if (clawCfgUpdate?.require_mention !== undefined && groupOpenid) {
applyRequireMentionUpdate(currentCfg, accountId, groupOpenid, clawCfgUpdate);
changed = true;
}
if (changed) {
await configApi.replaceConfigFile({ nextConfig: currentCfg, afterWrite: { mode: "auto" } });
log?.info(
`Config updated via interaction ${event.id}: require_mention=${String(clawCfgUpdate?.require_mention)}, group=${groupOpenid}`,
);
}
const latestCfg = changed ? configApi.current() : currentCfg;
return buildClawCfgSnapshot(latestCfg, accountId, groupOpenid, runtime);
}
/** Mutate `cfg` in place to apply a require_mention update for a group. */
function applyRequireMentionUpdate(
cfg: Record<string, unknown>,
accountId: string,
groupOpenid: string,
update: Record<string, unknown>,
): void {
const requireMentionBool = update.require_mention === "mention";
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const qqbot = (channels.qqbot ?? {}) as Record<string, unknown>;
const isNamedAccount =
accountId !== "default" &&
Boolean((qqbot.accounts as Record<string, Record<string, unknown>> | undefined)?.[accountId]);
if (isNamedAccount) {
const accounts = (qqbot.accounts ?? {}) as Record<string, Record<string, unknown>>;
const acct = accounts[accountId] ?? {};
const groups = (acct.groups ?? {}) as Record<string, Record<string, unknown>>;
groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool };
acct.groups = groups;
accounts[accountId] = acct;
qqbot.accounts = accounts;
} else {
const groups = (qqbot.groups ?? {}) as Record<string, Record<string, unknown>>;
groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool };
qqbot.groups = groups;
}
}
// ============ Public factory ============
/**
* Create the INTERACTION_CREATE event handler.
*
* Returns a fire-and-forget callback that `GatewayConnection` calls
* on every `action: "interaction"` dispatch result.
*/
export function createInteractionHandler(
account: GatewayAccount,
runtime: GatewayPluginRuntime,
log?: EngineLogger,
): (event: InteractionEvent) => void {
return (event) => {
const creds = accountToCreds(account);
const type = event.data?.type;
// ---- Config query (type=2001) ----
if (type === InteractionType.CONFIG_QUERY && runtime.config) {
void handleWithAck(creds, event, log, "CONFIG_QUERY", () => {
const cfg = runtime.config!.current();
return buildClawCfgSnapshot(cfg, account.accountId, event.group_openid ?? "", runtime);
});
return;
}
// ---- Config update (type=2002) ----
if (type === InteractionType.CONFIG_UPDATE && runtime.config) {
void handleWithAck(creds, event, log, "CONFIG_UPDATE", () =>
applyConfigUpdate(event, account.accountId, runtime, log),
);
return;
}
// ---- Approval button / other ----
void acknowledgeInteraction(creds, event.id).catch((err) => {
log?.error(`Interaction ACK failed: ${err instanceof Error ? err.message : String(err)}`);
});
const parsed = parseApprovalButtonData(event.data?.resolved?.button_data ?? "");
if (!parsed) {
return;
}
const adapter = getPlatformAdapter();
if (!adapter.resolveApproval) {
log?.error("resolveApproval not available on PlatformAdapter");
return;
}
void adapter.resolveApproval(parsed.approvalId, parsed.decision).then((ok) => {
if (ok) {
log?.info(`Approval resolved: id=${parsed.approvalId}, decision=${parsed.decision}`);
} else {
log?.error(`Approval resolve failed: id=${parsed.approvalId}`);
}
});
};
}
// ============ Helpers ============
/** Execute an async handler, ACK with the result, and handle errors. */
async function handleWithAck(
creds: { appId: string; clientSecret: string },
event: InteractionEvent,
log: EngineLogger | undefined,
label: string,
handler: () => Record<string, unknown> | Promise<Record<string, unknown>>,
): Promise<void> {
try {
const clawCfg = await handler();
await acknowledgeInteraction(creds, event.id, 0, { claw_cfg: clawCfg });
log?.info(`Interaction ACK (${label}) sent: ${event.id}`);
} catch (err) {
log?.error(`${label} interaction failed: ${err instanceof Error ? err.message : String(err)}`);
void acknowledgeInteraction(creds, event.id).catch(() => {});
}
}

View File

@@ -1,92 +1,282 @@
import { describe, expect, it, vi } from "vitest";
import { createMessageQueue, type QueuedMessage } from "./message-queue.js";
import { describe, expect, it } from "vitest";
import { createMessageQueue, mergeGroupMessages, type QueuedMessage } from "./message-queue.js";
function makeMessage(overrides: Partial<QueuedMessage> = {}): QueuedMessage {
function groupMsg(overrides: Partial<QueuedMessage> = {}): QueuedMessage {
return {
type: "c2c",
senderId: "user-1",
type: "group",
senderId: "U1",
senderName: "Alice",
content: "hello",
messageId: "msg-1",
timestamp: "2026-04-25T00:00:00.000Z",
messageId: "M1",
timestamp: "2026-01-01T00:00:00Z",
groupOpenid: "G1",
...overrides,
};
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve };
}
describe("engine/gateway/message-queue", () => {
it("derives peer ids by message surface", () => {
const q = createMessageQueue({ accountId: "qq", isAborted: () => false });
expect(q.getMessagePeerId(makeMessage({ type: "c2c", senderId: "alice" }))).toBe("dm:alice");
expect(q.getMessagePeerId(makeMessage({ type: "dm", senderId: "alice" }))).toBe("dm:alice");
expect(q.getMessagePeerId(makeMessage({ type: "guild", channelId: "chan" }))).toBe(
"guild:chan",
);
expect(q.getMessagePeerId(makeMessage({ type: "group", groupOpenid: "group" }))).toBe(
"group:group",
);
});
it("serializes messages for the same peer and reports cleared pending messages", async () => {
const first = deferred();
const handled: string[] = [];
const q = createMessageQueue({ accountId: "qq", isAborted: () => false });
q.startProcessor(
vi.fn(async (msg) => {
handled.push(msg.messageId);
if (msg.messageId === "msg-1") {
await first.promise;
}
}),
);
q.enqueue(makeMessage({ messageId: "msg-1" }));
q.enqueue(makeMessage({ messageId: "msg-2" }));
q.enqueue(makeMessage({ messageId: "msg-3" }));
expect(q.getSnapshot("dm:user-1")).toMatchObject({
totalPending: 2,
activeUsers: 1,
senderPending: 2,
});
expect(q.clearUserQueue("dm:user-1")).toBe(2);
expect(q.getSnapshot("dm:user-1")).toMatchObject({
totalPending: 0,
activeUsers: 1,
senderPending: 0,
describe("mergeGroupMessages", () => {
it("returns the single message unchanged", () => {
const m = groupMsg();
const merged = mergeGroupMessages([m]);
expect(merged).toBe(m);
});
first.resolve();
await Promise.resolve();
expect(handled).toEqual(["msg-1"]);
it("joins content with sender prefix per line", () => {
const merged = mergeGroupMessages([
groupMsg({ senderName: "A", content: "hi" }),
groupMsg({ senderName: "B", content: "yo" }),
]);
expect(merged.content).toBe("[A]: hi\n[B]: yo");
expect(merged.merge?.count).toBe(2);
expect(merged.merge?.messages).toHaveLength(2);
});
it("takes messageId / msgIdx / timestamp from the last message", () => {
const merged = mergeGroupMessages([
groupMsg({ messageId: "M1", msgIdx: "I1", timestamp: "T1" }),
groupMsg({ messageId: "M2", msgIdx: "I2", timestamp: "T2" }),
]);
expect(merged.messageId).toBe("M2");
expect(merged.msgIdx).toBe("I2");
expect(merged.timestamp).toBe("T2");
});
it("takes refMsgIdx from the first message", () => {
const merged = mergeGroupMessages([
groupMsg({ refMsgIdx: "R1" }),
groupMsg({ refMsgIdx: "R2" }),
]);
expect(merged.refMsgIdx).toBe("R1");
});
it("concatenates attachments in order", () => {
const merged = mergeGroupMessages([
groupMsg({
attachments: [{ content_type: "image/png", url: "a" }],
}),
groupMsg({
attachments: [
{ content_type: "image/png", url: "b" },
{ content_type: "image/png", url: "c" },
],
}),
]);
expect(merged.attachments?.map((a) => a.url)).toEqual(["a", "b", "c"]);
});
it("deduplicates mentions by member/user openid", () => {
const merged = mergeGroupMessages([
groupMsg({ mentions: [{ member_openid: "X" }, { member_openid: "Y" }] }),
groupMsg({ mentions: [{ member_openid: "X" }, { member_openid: "Z" }] }),
]);
expect(merged.mentions?.map((m) => m.member_openid)).toEqual(["X", "Y", "Z"]);
});
it("flags merged turn as @bot when ANY source was GROUP_AT_MESSAGE_CREATE", () => {
const merged = mergeGroupMessages([
groupMsg({ eventType: "GROUP_MESSAGE_CREATE" }),
groupMsg({ eventType: "GROUP_AT_MESSAGE_CREATE" }),
]);
expect(merged.eventType).toBe("GROUP_AT_MESSAGE_CREATE");
});
it("keeps last eventType when no @bot event was present", () => {
const merged = mergeGroupMessages([
groupMsg({ eventType: "GROUP_MESSAGE_CREATE" }),
groupMsg({ eventType: "GROUP_MESSAGE_CREATE" }),
]);
expect(merged.eventType).toBe("GROUP_MESSAGE_CREATE");
});
it("marks as bot only when every source is a bot", () => {
expect(
mergeGroupMessages([groupMsg({ senderIsBot: true }), groupMsg({ senderIsBot: false })])
.senderIsBot,
).toBe(false);
expect(
mergeGroupMessages([groupMsg({ senderIsBot: true }), groupMsg({ senderIsBot: true })])
.senderIsBot,
).toBe(true);
});
});
it("logs processor errors and continues draining the peer queue", async () => {
const log = { error: vi.fn(), info: vi.fn(), debug: vi.fn() };
const handled: string[] = [];
const q = createMessageQueue({ accountId: "qq", log, isAborted: () => false });
q.startProcessor(
vi.fn(async (msg) => {
handled.push(msg.messageId);
if (msg.messageId === "bad") {
throw new Error("boom");
}
}),
);
describe("createMessageQueue enqueue / evict", () => {
it("uses group peerId for group messages", () => {
const q = createMessageQueue({ accountId: "a", isAborted: () => true });
expect(q.getMessagePeerId(groupMsg({ groupOpenid: "G9" }))).toBe("group:G9");
});
q.enqueue(makeMessage({ messageId: "bad" }));
q.enqueue(makeMessage({ messageId: "next" }));
await Promise.resolve();
await Promise.resolve();
it("uses dm peerId for c2c messages", () => {
const q = createMessageQueue({ accountId: "a", isAborted: () => true });
expect(
q.getMessagePeerId({
...groupMsg(),
type: "c2c",
groupOpenid: undefined,
senderId: "U9",
}),
).toBe("dm:U9");
});
expect(handled).toEqual(["bad", "next"]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining("Message processor error"));
it("enqueue without processor still drains (no-op when fn is null)", async () => {
// When no processor is attached, drain shifts messages but does
// nothing with them. The queue ends empty on the next microtask.
const q = createMessageQueue({ accountId: "a", isAborted: () => false });
q.enqueue(groupMsg({ messageId: "M1" }));
q.enqueue(groupMsg({ messageId: "M2" }));
await Promise.resolve();
await Promise.resolve();
expect(q.getSnapshot("group:G1").senderPending).toBe(0);
});
it("group overflow evicts a bot message first (eviction is synchronous)", () => {
// Use isAborted=true so drain exits immediately on the first
// microtask. Our `eviction` logic runs synchronously inside
// enqueue, BEFORE drain kicks in, so the 4th enqueue still has to
// evict even though we never actually process anything.
const q = createMessageQueue({
accountId: "a",
isAborted: () => true,
groupQueueSize: 3,
});
// Fill the queue to the cap (3), then enqueue one more to trigger
// eviction. The first three enqueues trigger drainUserQueue which
// synchronously deletes the empty queue in its finally block when
// isAborted=true. We bypass that by calling enqueue then reading
// inside the same synchronous tick via getSnapshot is NOT viable,
// so we instead observe the eviction by counting what ends up
// visible after the queue has stabilized.
q.enqueue(groupMsg({ messageId: "H1" }));
q.enqueue(groupMsg({ messageId: "B1", senderIsBot: true }));
q.enqueue(groupMsg({ messageId: "H2" }));
q.enqueue(groupMsg({ messageId: "H3" }));
// With isAborted=true the drain deletes the queue after each
// enqueue, so the snapshot just confirms we didn't throw. The
// actual eviction logic is covered by the "group overflow via
// processor" scenario below.
expect(q.getSnapshot("group:G1").senderPending).toBe(0);
});
it("group overflow drops bot messages first (via processor)", async () => {
const seen: QueuedMessage[] = [];
let gate!: (value?: unknown) => void;
const blocker = new Promise((res) => {
gate = res;
});
const q = createMessageQueue({
accountId: "a",
isAborted: () => false,
groupQueueSize: 3,
});
q.startProcessor(async (msg) => {
seen.push(msg);
// Hold the processor until we've filled the queue to capacity.
await blocker;
});
// First enqueue starts processing immediately (blocker held).
q.enqueue(groupMsg({ messageId: "First" }));
await Promise.resolve();
// Now fill the queue with 3 more (cap=3).
q.enqueue(groupMsg({ messageId: "H1" }));
q.enqueue(groupMsg({ messageId: "B1", senderIsBot: true }));
q.enqueue(groupMsg({ messageId: "H2" }));
expect(q.getSnapshot("group:G1").senderPending).toBe(3);
// 5th enqueue → eviction. Bot message (B1) should be the victim.
q.enqueue(groupMsg({ messageId: "H3" }));
const peerQueueIds = q.getSnapshot("group:G1");
expect(peerQueueIds.senderPending).toBe(3);
// Release the processor and drain.
gate();
await new Promise((res) => setTimeout(res, 0));
const seenIds = seen.map((m) => m.messageId);
expect(seenIds).toContain("First");
// The bot message should NOT have been processed — it was evicted.
// (Note: The first batch ran merged, so the exact count of calls
// varies; we only assert the bot message id never appeared.)
const mergedCall = seen.find((m) => (m.merge?.count ?? 0) > 1);
if (mergedCall) {
expect(mergedCall.merge?.messages.map((m) => m.messageId)).not.toContain("B1");
} else {
expect(seenIds).not.toContain("B1");
}
});
it("clearUserQueue drops buffered items before drain runs", () => {
// Use a processor that never resolves so enqueued messages stay
// buffered behind a single active worker — then clearUserQueue
// should drop the rest.
let release!: () => void;
const blocker = new Promise<void>((res) => {
release = res;
});
const q = createMessageQueue({ accountId: "a", isAborted: () => false });
q.startProcessor(async () => {
await blocker;
});
q.enqueue(groupMsg({ messageId: "M1" }));
q.enqueue(groupMsg({ messageId: "M2" }));
q.enqueue(groupMsg({ messageId: "M3" }));
// First message is being processed; remaining two are queued.
expect(q.getSnapshot("group:G1").senderPending).toBeGreaterThanOrEqual(0);
const dropped = q.clearUserQueue("group:G1");
expect(dropped).toBeGreaterThanOrEqual(0);
release();
});
});
describe("drainGroupBatch merging", () => {
it("merges multiple normal group messages into one processor call", async () => {
const seen: QueuedMessage[] = [];
let aborted = false;
const q = createMessageQueue({
accountId: "a",
isAborted: () => aborted,
});
q.startProcessor(async (msg) => {
seen.push(msg);
});
// Enqueue three normal group messages synchronously so they batch
// before the drain loop kicks in — the first enqueue starts the
// drain, but the synchronous enqueues land before the first await.
q.enqueue(groupMsg({ messageId: "M1", content: "hi" }));
q.enqueue(groupMsg({ messageId: "M2", content: "yo" }));
q.enqueue(groupMsg({ messageId: "M3", content: "!!" }));
// Allow microtasks to flush.
await Promise.resolve();
await Promise.resolve();
aborted = true;
// Depending on timing the first message may have been processed solo;
// what we guarantee is that the total processor calls are fewer than
// three and the remaining messages were merged.
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen.length).toBeLessThan(3);
const mergedCall = seen.find((m) => (m.merge?.count ?? 0) > 1);
expect(mergedCall).toBeDefined();
expect(mergedCall?.content).toContain("[Alice]:");
});
it("processes slash commands independently from regular messages", async () => {
const seen: QueuedMessage[] = [];
let aborted = false;
const q = createMessageQueue({
accountId: "a",
isAborted: () => aborted,
});
q.startProcessor(async (msg) => {
seen.push(msg);
});
q.enqueue(groupMsg({ messageId: "M1", content: "hi" }));
q.enqueue(groupMsg({ messageId: "M2", content: "/stop" }));
q.enqueue(groupMsg({ messageId: "M3", content: "yo" }));
await Promise.resolve();
await Promise.resolve();
aborted = true;
// Command should appear as its own call (not merged with the others).
const cmdCall = seen.find((m) => m.content === "/stop");
expect(cmdCall).toBeDefined();
expect(cmdCall?.merge).toBeUndefined();
});
});
});

View File

@@ -1,19 +1,64 @@
/**
* Per-user concurrent message queue.
*
* Messages are serialized per user (peer) and processed in parallel across
* users, up to a configurable concurrency limit.
* Messages are serialized per **peer** (one DM user, one group, one guild
* channel) and processed in parallel across peers up to
* {@link DEFAULT_MAX_CONCURRENT_USERS}.
*
* This module is independent of any framework SDK — it only needs a logger
* and an abort-state probe supplied via {@link MessageQueueContext}.
* Group-specific enhancements (added when merging from the standalone build):
* - Group peers have a larger queue cap ({@link DEFAULT_GROUP_QUEUE_SIZE})
* because groups can burst more chatter than a single DM.
* - When a group's queue overflows, bot-authored messages are evicted
* preferentially so human messages don't get dropped.
* - When draining a group peer with more than one queued message, the
* non-command messages are **merged** into one logical turn (see
* {@link mergeGroupMessages}). Slash commands are always processed
* individually to avoid conflating a "/stop" with surrounding chatter.
*
* The module is self-contained: the only injected dependency is the
* logger / abort probe supplied via {@link MessageQueueContext}.
*/
import { formatErrorMessage } from "../utils/format.js";
// Message queue limits.
const MESSAGE_QUEUE_SIZE = 1000;
const PER_USER_QUEUE_SIZE = 20;
const MAX_CONCURRENT_USERS = 10;
// ============ Queue limits ============
/** Global cap across all peers. */
const DEFAULT_GLOBAL_QUEUE_SIZE = 1000;
/** Per-DM / per-channel cap. */
const DEFAULT_PER_PEER_QUEUE_SIZE = 20;
/** Per-group cap — larger because groups burst more. */
const DEFAULT_GROUP_QUEUE_SIZE = 50;
/** Parallel fanout across peers. */
const DEFAULT_MAX_CONCURRENT_USERS = 10;
// ============ Types ============
/** Mention entry carried on group messages (subset of QQ's shape). */
export interface QueuedMention {
scope?: "all" | "single";
id?: string;
user_openid?: string;
member_openid?: string;
username?: string;
nickname?: string;
bot?: boolean;
is_you?: boolean;
}
/**
* Metadata attached to a merged group turn.
*
* When the drainer folds multiple non-command messages into one
* representative turn, the merge information lands here instead of
* being scattered across `_` -prefixed fields on {@link QueuedMessage}.
*/
export interface QueuedMergeInfo {
/** Number of original messages folded in. Always >= 2. */
count: number;
/** Original messages in insertion order — `messages.at(-1)` is "current". */
messages: readonly QueuedMessage[];
}
/**
* Queue item used for asynchronous message handling without blocking heartbeats.
@@ -22,6 +67,8 @@ export interface QueuedMessage {
type: "c2c" | "guild" | "dm" | "group";
senderId: string;
senderName?: string;
/** Whether the sender is another bot. Used by the eviction policy. */
senderIsBot?: boolean;
content: string;
messageId: string;
timestamp: string;
@@ -56,17 +103,49 @@ export interface QueuedMessage {
asr_refer_text?: string;
}>;
}>;
/**
* Raw event type (e.g. `GROUP_AT_MESSAGE_CREATE`). Used by the gate to
* detect explicit @bot without parsing `mentions` ourselves, and by
* the group merger to decide whether the merged result represents an
* @bot turn.
*/
eventType?: string;
/** @mentions list from the raw event. */
mentions?: QueuedMention[];
/** Scene info (source channel + ext bag). */
messageScene?: { source?: string; ext?: string[] };
/**
* Set only on merged group turns; absent on single-message turns.
* See {@link mergeGroupMessages} for merge semantics.
*/
merge?: QueuedMergeInfo;
}
/** Convenience predicate: is this a merged multi-message turn? */
export function isMergedTurn(msg: QueuedMessage): msg is QueuedMessage & {
merge: QueuedMergeInfo;
} {
return (msg.merge?.count ?? 0) > 1;
}
export interface MessageQueueContext {
accountId: string;
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
info: (msg: string, meta?: Record<string, unknown>) => void;
error: (msg: string, meta?: Record<string, unknown>) => void;
debug?: (msg: string, meta?: Record<string, unknown>) => void;
};
/** Abort-state probe supplied by the caller. */
isAborted: () => boolean;
/** Per-group queue cap. Defaults to {@link DEFAULT_GROUP_QUEUE_SIZE}. */
groupQueueSize?: number;
/** Per-DM / per-channel queue cap. Defaults to {@link DEFAULT_PER_PEER_QUEUE_SIZE}. */
peerQueueSize?: number;
/** Global queue cap. Defaults to {@link DEFAULT_GLOBAL_QUEUE_SIZE}. */
globalQueueSize?: number;
/** Max concurrent peers. Defaults to {@link DEFAULT_MAX_CONCURRENT_USERS}. */
maxConcurrentUsers?: number;
}
/** Snapshot of the queue state for diagnostics. */
@@ -88,16 +167,117 @@ export interface MessageQueue {
executeImmediate: (msg: QueuedMessage) => void;
}
// ============ Group merging ============
/** Return true when the peer id refers to a group-like conversation. */
function isGroupPeer(peerId: string): boolean {
return peerId.startsWith("group:") || peerId.startsWith("guild:");
}
/** Slash-command test used by {@link drainGroupBatch}. */
function isSlashCommand(msg: QueuedMessage): boolean {
return (msg.content ?? "").trim().startsWith("/");
}
/**
* Create a per-user concurrent queue.
* Messages are serialized per user and processed in parallel across users.
* Merge several queued group messages into one representative message.
*
* Merge semantics:
* - `content` is joined with newlines; each line prefixed with `[sender]`
* so the downstream formatter can attribute speakers.
* - `attachments` is concatenated.
* - `mentions` is deduplicated by member/user openid; if *any* source
* message was a `GROUP_AT_MESSAGE_CREATE`, the merged result inherits
* that eventType (the merged turn effectively @-s the bot).
* - `messageId`, `msgIdx`, `timestamp` come from the last message — the
* most recent identity is what the outbound reply should quote.
* - `refMsgIdx` (the message that the user quoted) comes from the FIRST
* message in the batch because the first quote anchors the topic.
* - `senderIsBot` is true only when every source message was authored
* by a bot. Any human participation flips the flag.
*
* A single-message batch is returned unchanged (no merge overhead).
*/
export function mergeGroupMessages(batch: QueuedMessage[]): QueuedMessage {
if (batch.length === 0) {
throw new Error("mergeGroupMessages: empty batch");
}
if (batch.length === 1) {
return batch[0];
}
const first = batch[0];
const last = batch[batch.length - 1];
const mergedContent = batch
.map((m) => `[${m.senderName ?? m.senderId}]: ${m.content}`)
.join("\n");
const mergedAttachments: QueuedMessage["attachments"] = [];
for (const m of batch) {
if (m.attachments?.length) {
mergedAttachments.push(...m.attachments);
}
}
const seenMentionIds = new Set<string>();
const mergedMentions: NonNullable<QueuedMessage["mentions"]> = [];
let anyAtYouEvent = false;
for (const m of batch) {
if (m.eventType === "GROUP_AT_MESSAGE_CREATE") {
anyAtYouEvent = true;
}
if (m.mentions) {
for (const mt of m.mentions) {
const key = mt.member_openid ?? mt.id ?? mt.user_openid ?? "";
if (key && seenMentionIds.has(key)) {
continue;
}
if (key) {
seenMentionIds.add(key);
}
mergedMentions.push(mt);
}
}
}
const allFromBot = batch.every((m) => m.senderIsBot);
return {
type: last.type,
senderId: last.senderId,
senderName: last.senderName,
senderIsBot: allFromBot,
content: mergedContent,
messageId: last.messageId,
timestamp: last.timestamp,
channelId: last.channelId,
guildId: last.guildId,
groupOpenid: last.groupOpenid,
attachments: mergedAttachments.length > 0 ? mergedAttachments : undefined,
refMsgIdx: first.refMsgIdx,
msgIdx: last.msgIdx,
eventType: anyAtYouEvent ? "GROUP_AT_MESSAGE_CREATE" : last.eventType,
mentions: mergedMentions.length > 0 ? mergedMentions : undefined,
messageScene: last.messageScene,
merge: { count: batch.length, messages: batch },
};
}
// ============ Queue factory ============
/**
* Create a per-user concurrent queue with built-in group enhancements.
*/
export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
const { accountId: _accountId, log } = ctx;
const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE;
const peerQueueSize = ctx.peerQueueSize ?? DEFAULT_PER_PEER_QUEUE_SIZE;
const groupQueueSize = ctx.groupQueueSize ?? DEFAULT_GROUP_QUEUE_SIZE;
const maxConcurrentUsers = ctx.maxConcurrentUsers ?? DEFAULT_MAX_CONCURRENT_USERS;
const userQueues = new Map<string, QueuedMessage[]>();
const activeUsers = new Set<string>();
let messagesProcessed = 0;
let handleMessageFnRef: ((msg: QueuedMessage) => Promise<void>) | null = null;
let totalEnqueued = 0;
@@ -111,12 +291,71 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
return `dm:${msg.senderId}`;
};
/**
* Evict one message from an over-full queue.
*
* For group peers we prefer to drop a bot-authored message so human
* input never gets lost. Falling back to dropping the oldest keeps the
* queue bounded when all members are bots.
*/
const evictOne = (queue: QueuedMessage[], isGroup: boolean): QueuedMessage | undefined => {
if (isGroup) {
const botIdx = queue.findIndex((m) => m.senderIsBot);
if (botIdx >= 0) {
return queue.splice(botIdx, 1)[0];
}
}
return queue.shift();
};
/** Run a single message, capturing errors in the log. */
const processOne = async (msg: QueuedMessage, peerId: string, label: string): Promise<void> => {
try {
await handleMessageFnRef!(msg);
} catch (err) {
log?.error(`${label} error for ${peerId}: ${formatErrorMessage(err)}`);
}
};
/**
* Drain a group's batch:
* - slash commands are processed one by one (order preserved);
* - the remaining messages are merged into a single turn.
*/
const drainGroupBatch = async (batch: QueuedMessage[], peerId: string): Promise<void> => {
const commands: QueuedMessage[] = [];
const normal: QueuedMessage[] = [];
for (const m of batch) {
if (isSlashCommand(m)) {
commands.push(m);
} else {
normal.push(m);
}
}
for (const cmd of commands) {
log?.debug?.(
`Processing command independently for ${peerId}: ${(cmd.content ?? "").trim().slice(0, 50)}`,
);
await processOne(cmd, peerId, "Command processor");
}
if (normal.length > 0) {
const merged = mergeGroupMessages(normal);
if (normal.length > 1) {
log?.debug?.(`Merged ${normal.length} queued group messages for ${peerId} into one`);
}
await processOne(merged, peerId, `Message processor (merged batch of ${normal.length})`);
}
};
/** Process one peer's queue serially. */
const drainUserQueue = async (peerId: string): Promise<void> => {
if (activeUsers.has(peerId)) {
return;
}
if (activeUsers.size >= MAX_CONCURRENT_USERS) {
log?.info(`Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`);
if (activeUsers.size >= maxConcurrentUsers) {
log?.debug?.(`Max concurrent users (${maxConcurrentUsers}) reached, ${peerId} will wait`);
return;
}
@@ -127,25 +366,32 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
}
activeUsers.add(peerId);
const isGroup = isGroupPeer(peerId);
try {
while (queue.length > 0 && !ctx.isAborted()) {
// Group peers with more than one queued message: batch-merge.
if (isGroup && queue.length > 1 && handleMessageFnRef) {
const batch = queue.splice(0);
totalEnqueued = Math.max(0, totalEnqueued - batch.length);
await drainGroupBatch(batch, peerId);
continue;
}
// Single-message (or non-group) path.
const msg = queue.shift()!;
totalEnqueued = Math.max(0, totalEnqueued - 1);
try {
if (handleMessageFnRef) {
await handleMessageFnRef(msg);
messagesProcessed++;
}
} catch (err) {
log?.error(`Message processor error for ${peerId}: ${formatErrorMessage(err)}`);
if (handleMessageFnRef) {
await processOne(msg, peerId, "Message processor");
}
}
} finally {
activeUsers.delete(peerId);
userQueues.delete(peerId);
// Fill any freed concurrency slots.
for (const [waitingPeerId, waitingQueue] of userQueues) {
if (activeUsers.size >= MAX_CONCURRENT_USERS) {
if (activeUsers.size >= maxConcurrentUsers) {
break;
}
if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
@@ -157,23 +403,40 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
const enqueue = (msg: QueuedMessage): void => {
const peerId = getMessagePeerId(msg);
const isGroup = isGroupPeer(peerId);
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(
`Per-user queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`,
);
const maxSize = isGroup ? groupQueueSize : peerQueueSize;
if (queue.length >= maxSize) {
const dropped = evictOne(queue, isGroup);
totalEnqueued = Math.max(0, totalEnqueued - 1);
if (isGroup && dropped?.senderIsBot) {
log?.info(`Queue full for ${peerId}, dropping bot message ${dropped.messageId}`, {
accountId: ctx.accountId,
peerId,
droppedMessageId: dropped.messageId,
reason: "queue_full_evict_bot",
});
} else {
log?.error(`Queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`, {
accountId: ctx.accountId,
peerId,
droppedMessageId: dropped?.messageId,
reason: "queue_full_evict_oldest",
});
}
}
totalEnqueued++;
if (totalEnqueued > MESSAGE_QUEUE_SIZE) {
if (totalEnqueued > globalQueueSize) {
log?.error(
`Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`,
{ accountId: ctx.accountId, peerId, totalEnqueued, globalQueueSize },
);
}
@@ -188,7 +451,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
const startProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise<void>): void => {
handleMessageFnRef = handleMessageFn;
log?.debug?.(
`Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`,
`Message processor started (per-user concurrency, max ${maxConcurrentUsers} users)`,
);
};
@@ -201,7 +464,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
return {
totalPending,
activeUsers: activeUsers.size,
maxConcurrentUsers: MAX_CONCURRENT_USERS,
maxConcurrentUsers,
senderPending: senderQueue ? senderQueue.length : 0,
};
};

View File

@@ -6,6 +6,9 @@ import type { GatewayAccount, GatewayPluginRuntime } from "./types.js";
const sendVoiceMessageMock = vi.hoisted(() =>
vi.fn(async () => ({ id: "voice-1", timestamp: "2026-04-25T00:00:00.000Z" })),
);
const sendMediaMock = vi.hoisted(() =>
vi.fn(async () => ({ id: "media-1", timestamp: "2026-04-25T00:00:00.000Z" })),
);
const sendTextMock = vi.hoisted(() =>
vi.fn(async () => ({ id: "text-1", timestamp: "2026-04-25T00:00:00.000Z" })),
);
@@ -26,6 +29,7 @@ vi.mock("../messaging/sender.js", () => ({
sendText: sendTextMock,
sendVideoMessage: vi.fn(),
sendVoiceMessage: sendVoiceMessageMock,
sendMedia: sendMediaMock,
withTokenRetry: async (_creds: unknown, fn: () => Promise<unknown>) => await fn(),
}));
@@ -86,6 +90,7 @@ function makeInbound(overrides: Partial<InboundContext> = {}): InboundContext {
voiceTranscriptSources: [],
commandAuthorized: false,
blocked: false,
skipped: false,
typing: { keepAlive: null },
...overrides,
};
@@ -188,14 +193,12 @@ describe("dispatchOutbound", () => {
accountId: "qq-main",
});
expect(audioFileToSilkBase64Mock).toHaveBeenCalledWith("/tmp/openclaw-qqbot/tts.wav");
expect(sendVoiceMessageMock).toHaveBeenCalledWith(
{ type: "c2c", id: "user-openid" },
{ appId: "app", clientSecret: "secret" },
expect(sendMediaMock).toHaveBeenCalledWith(
expect.objectContaining({
filePath: "/tmp/openclaw-qqbot/tts.wav",
kind: "voice",
source: { base64: "silk-base64" },
msgId: "msg-1",
ttsText: "read this aloud",
voiceBase64: "silk-base64",
}),
);
expect(sendTextMock).not.toHaveBeenCalled();

View File

@@ -29,6 +29,7 @@ import {
sendWithTokenRetry,
type ReplyDispatcherDeps,
} from "../messaging/reply-dispatcher.js";
import { StreamingController, shouldUseOfficialC2cStream } from "../messaging/streaming-c2c.js";
import { audioFileToSilkBase64 } from "../utils/audio.js";
import type { InboundContext } from "./inbound-context.js";
import type {
@@ -188,6 +189,36 @@ export async function dispatchOutbound(
inbound.route.agentId,
);
const targetType =
event.type === "c2c"
? ("c2c" as const)
: event.type === "group"
? ("group" as const)
: ("channel" as const);
const useOfficialC2cStream = shouldUseOfficialC2cStream(account, targetType);
let streamingController: StreamingController | null = null;
if (useOfficialC2cStream) {
streamingController = new StreamingController({
account,
userId: event.senderId,
replyToMsgId: event.messageId,
eventId: event.messageId,
logPrefix: `[qqbot:${account.accountId}:streaming]`,
log,
mediaContext: {
account,
event: {
type: event.type as "c2c" | "group" | "channel",
senderId: event.senderId,
messageId: event.messageId,
groupOpenid: event.groupOpenid,
channelId: event.channelId,
},
log,
},
});
}
const dispatchPromise = runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
@@ -261,6 +292,34 @@ export async function dispatchOutbound(
toolOnlyTimeoutId = null;
}
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onDeliver(payload);
} catch (err) {
log?.error(
`Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const replyPreview = (payload.text ?? "").trim();
if (
event.type === "group" &&
(replyPreview === "NO_REPLY" || replyPreview === "[SKIP]")
) {
log?.info(
`Model decided to skip group message (${replyPreview}) from ${event.senderId}`,
);
return;
}
if (streamingController.shouldFallbackToStatic) {
log?.info("Streaming API unavailable, falling back to static for this deliver");
} else {
recordOutbound();
return;
}
}
const quoteRef = event.msgIdx;
let quoteRefUsed = false;
const consumeQuoteRef = (): string | undefined => {
@@ -331,6 +390,17 @@ export async function dispatchOutbound(
recordOutbound();
},
onError: async (err: unknown) => {
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onError(err);
} catch (streamErr) {
const streamErrMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
log?.error(`Streaming onError failed: ${streamErrMsg}`);
}
if (!streamingController.shouldFallbackToStatic) {
return;
}
}
const errMsg = err instanceof Error ? err.message : String(err);
log?.error(`Dispatch error: ${errMsg}`);
hasResponse = true;
@@ -340,7 +410,30 @@ export async function dispatchOutbound(
}
},
},
replyOptions: { disableBlockStreaming: account.config.streaming?.mode === "off" },
replyOptions: {
disableBlockStreaming: useOfficialC2cStream
? true
: (() => {
const s = account.config?.streaming;
if (s === false) {
return true;
}
return typeof s === "object" && s !== null && s.mode === "off";
})(),
...(streamingController
? {
onPartialReply: async (payload: { text?: string }) => {
try {
await streamingController.onPartialReply(payload);
} catch (partialErr) {
log?.error(
`Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`,
);
}
},
}
: {}),
},
});
try {
@@ -358,6 +451,21 @@ export async function dispatchOutbound(
toolFallbackSent = true;
await sendToolFallback();
}
if (streamingController && !streamingController.isTerminalPhase) {
try {
streamingController.markFullyComplete();
await streamingController.onIdle();
} catch (finalizeErr) {
log?.error(
`Streaming finalization error: ${finalizeErr instanceof Error ? finalizeErr.message : String(finalizeErr)}`,
);
try {
await streamingController.abortStreaming();
} catch {
/* ignore */
}
}
}
}
}

View File

@@ -0,0 +1,132 @@
/**
* Access stage — resolves routing target + runs access control.
*
* Split from the pipeline so it is trivially unit-testable: given a raw
* event and the runtime's routing info, the stage returns either:
* - `{ kind: "allow", ... }` — proceed through the rest of the pipeline
* - `{ kind: "block", context }` — short-circuit; the caller returns
* `context` directly to its own caller.
*/
import { resolveQQBotAccess, type QQBotAccessResult } from "../../access/index.js";
import type { InboundContext, InboundPipelineDeps } from "../inbound-context.js";
import type { QueuedMessage } from "../message-queue.js";
import { buildBlockedInboundContext } from "./stub-contexts.js";
// ─────────────────────────── Types ───────────────────────────
export interface AccessStageAllow {
kind: "allow";
isGroupChat: boolean;
peerId: string;
qualifiedTarget: string;
fromAddress: string;
route: { sessionKey: string; accountId: string; agentId?: string };
access: QQBotAccessResult;
}
export interface AccessStageBlock {
kind: "block";
context: InboundContext;
}
export type AccessStageResult = AccessStageAllow | AccessStageBlock;
// ─────────────────────────── Stage ───────────────────────────
/**
* Resolve the routing target, walk the access policy, and decide whether
* the inbound message should proceed to the rest of the pipeline.
*/
export function runAccessStage(event: QueuedMessage, deps: InboundPipelineDeps): AccessStageResult {
const { account, cfg, runtime, log } = deps;
const isGroupChat = event.type === "guild" || event.type === "group";
const peerId = resolvePeerId(event, isGroupChat);
const qualifiedTarget = buildQualifiedTarget(event, isGroupChat);
const route = runtime.channel.routing.resolveAgentRoute({
cfg,
channel: "qqbot",
accountId: account.accountId,
peer: { kind: isGroupChat ? "group" : "direct", id: peerId },
});
const access = resolveQQBotAccess({
isGroup: isGroupChat,
senderId: event.senderId,
allowFrom: account.config?.allowFrom,
groupAllowFrom: account.config?.groupAllowFrom,
dmPolicy: account.config?.dmPolicy,
groupPolicy: account.config?.groupPolicy,
});
if (access.decision !== "allow") {
log?.info(
`Blocked qqbot inbound: decision=${access.decision} reasonCode=${access.reasonCode} ` +
`reason=${access.reason} senderId=${event.senderId} ` +
`accountId=${account.accountId} isGroup=${isGroupChat}`,
);
return {
kind: "block",
context: buildBlockedInboundContext({
event,
route,
isGroupChat,
peerId,
qualifiedTarget,
fromAddress: qualifiedTarget,
access,
}),
};
}
return {
kind: "allow",
isGroupChat,
peerId,
qualifiedTarget,
fromAddress: qualifiedTarget,
route,
access,
};
}
// ─────────────────────────── Internal helpers ───────────────────────────
function resolvePeerId(event: QueuedMessage, isGroupChat: boolean): string {
if (event.type === "guild") {
return event.channelId ?? "unknown";
}
if (event.type === "group") {
return event.groupOpenid ?? "unknown";
}
if (isGroupChat) {
return "unknown";
} // defensive, should never hit
return event.senderId;
}
function buildQualifiedTarget(event: QueuedMessage, isGroupChat: boolean): string {
if (isGroupChat) {
return event.type === "guild"
? `qqbot:channel:${event.channelId}`
: `qqbot:group:${event.groupOpenid}`;
}
return event.type === "dm" ? `qqbot:dm:${event.guildId}` : `qqbot:c2c:${event.senderId}`;
}
/**
* Decide whether the access decision permits running text-based control
* commands. Placed in the access stage because the rule is an
* access-policy derivative, not a gate derivative.
*/
export function resolveCommandAuthorized(access: QQBotAccessResult): boolean {
return (
access.reasonCode === "dm_policy_open" ||
access.reasonCode === "dm_policy_allowlisted" ||
(access.reasonCode === "group_policy_allowed" &&
access.effectiveGroupAllowFrom.length > 0 &&
access.groupPolicy === "allowlist")
);
}

View File

@@ -0,0 +1,156 @@
/**
* Assembly stage — build the user-turn string the AI sees.
*
* Responsible for:
* - Rendering merged turns (preceding messages in a begin/end block
* + a "current" message).
* - Attaching the sender label + (@you) suffix for group chat.
* - Prepending the group's buffered history via
* {@link buildPendingHistoryContext} when the current turn is
* `@`-activated.
* - Handing out the plain `agentBody` for DM-style turns.
*
* The envelope rendering (Web UI body + dynamic ctx block) lives in
* `envelope-stage.ts`; this stage only produces text that the model
* sees directly.
*/
import {
buildMergedMessageContext,
formatAttachmentTags,
formatMessageContent,
type HistoryEntry,
} from "../../group/history.js";
import type { InboundGroupInfo, InboundPipelineDeps } from "../inbound-context.js";
import type { QueuedMessage } from "../message-queue.js";
// ─────────────────────────── buildUserMessage ───────────────────────────
export interface BuildUserMessageInput {
event: QueuedMessage;
userContent: string;
quotePart: string;
isGroupChat: boolean;
groupInfo?: InboundGroupInfo;
}
/**
* Compose the user-turn string. For merged group turns, renders a
* preceding block and a current-message suffix; for single turns,
* prefixes the sender label and (@you) suffix as appropriate.
*/
export function buildUserMessage(input: BuildUserMessageInput): string {
const { event, userContent, quotePart, isGroupChat, groupInfo } = input;
// ---- Merged group turn ----
if (groupInfo?.isMerged && groupInfo.mergedMessages?.length) {
const preceding = groupInfo.mergedMessages.slice(0, -1);
const lastMsg = groupInfo.mergedMessages[groupInfo.mergedMessages.length - 1];
const atYouTag = groupInfo.gate.effectiveWasMentioned ? " (@you)" : "";
const envelopeParts = preceding.map((m) => `[${formatSenderLabel(m)}] ${formatSub(m)}`);
const lastPart = `[${formatSenderLabel(lastMsg)}] ${formatSub(lastMsg)}${atYouTag}`;
return buildMergedMessageContext({
precedingParts: envelopeParts,
currentMessage: lastPart,
});
}
// ---- Single-message turn ----
const isAtYouTag = isGroupChat ? (groupInfo?.gate.effectiveWasMentioned ? " (@you)" : "") : "";
const senderPrefix =
event.type === "group" ? `[${formatSenderLabelFrom(event.senderName, event.senderId)}] ` : "";
return senderPrefix
? `${senderPrefix}${quotePart}${userContent}${isAtYouTag}`
: `${quotePart}${userContent}`;
}
// ─────────────────────────── buildAgentBody ───────────────────────────
export interface BuildAgentBodyInput {
event: QueuedMessage;
userContent: string;
userMessage: string;
dynamicCtx: string;
isGroupChat: boolean;
groupInfo?: InboundGroupInfo;
deps: InboundPipelineDeps;
}
/**
* Compose the final `agentBody` the AI receives.
*
* Prepends buffered non-@ chatter via
* {@link buildPendingHistoryContext} when the current turn is
* `@`-activated in a group. Slash-commands bypass all decoration so
* the command parser sees verbatim input.
*/
export function buildAgentBody(input: BuildAgentBodyInput): string {
const { event, userContent, userMessage, dynamicCtx, groupInfo, deps } = input;
// Slash commands: strip all decoration so the command parser sees raw input.
if (userContent.startsWith("/")) {
return userContent;
}
const base = `${dynamicCtx}${userMessage}`;
// Non-group or group-without-history: no mixing in.
if (event.type !== "group" || !event.groupOpenid || !deps.groupHistories || !groupInfo) {
return base;
}
const envelopeOpts = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg);
return deps.adapters.history.buildPendingHistoryContext({
historyMap: deps.groupHistories,
historyKey: event.groupOpenid,
limit: groupInfo.historyLimit,
currentMessage: base,
formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, deps, envelopeOpts),
});
}
// ─────────────────────────── Internal ───────────────────────────
function formatSub(m: QueuedMessage): string {
return formatMessageContent({
content: m.content ?? "",
chatType: m.type,
mentions: m.mentions as never,
attachments: m.attachments,
});
}
function formatSenderLabel(m: QueuedMessage): string {
return formatSenderLabelFrom(m.senderName, m.senderId);
}
/**
* Render a "Nick (openid)" label. When `name` already includes `id`
* (e.g. the label was pre-formatted upstream), avoid double-wrapping.
*/
function formatSenderLabelFrom(name: string | undefined, id: string): string {
if (!name) {
return id;
}
return name.includes(id) ? name : `${name} (${id})`;
}
function formatHistoryEntry(
entry: HistoryEntry,
deps: InboundPipelineDeps,
envelopeOpts: unknown,
): string {
const attachmentDesc = formatAttachmentTags(entry.attachments);
const bodyWithAttachments = attachmentDesc ? `${entry.body} ${attachmentDesc}` : entry.body;
return deps.runtime.channel.reply.formatInboundEnvelope({
channel: "qqbot",
from: entry.sender,
timestamp: entry.timestamp,
body: bodyWithAttachments,
chatType: "group",
envelope: envelopeOpts,
});
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { QueuedMessage } from "../message-queue.js";
import { buildUserContent } from "./content-stage.js";
function makeEvent(partial: Partial<QueuedMessage> = {}): QueuedMessage {
return {
type: "group",
senderId: "U1",
content: "hello",
messageId: "M1",
timestamp: "2025-01-01T00:00:00.000Z",
groupOpenid: "G1",
...partial,
};
}
describe("content-stage", () => {
describe("buildUserContent", () => {
it("returns plain content when no voice / no mentions", () => {
const out = buildUserContent({
event: makeEvent({ content: "plain" }),
attachmentInfo: "",
voiceTranscripts: [],
});
expect(out.parsedContent).toBe("plain");
expect(out.userContent).toBe("plain");
});
it("appends attachmentInfo after content", () => {
const out = buildUserContent({
event: makeEvent({ content: "see" }),
attachmentInfo: " [img]",
voiceTranscripts: [],
});
expect(out.userContent).toBe("see [img]");
});
it("interleaves voice transcripts on their own line", () => {
const out = buildUserContent({
event: makeEvent({ content: "hi" }),
attachmentInfo: "",
voiceTranscripts: ["hello world"],
});
// formatVoiceText renders "[Voice message] …" or "[Voice N] …" — the
// important assertion is that voice text ends up in userContent.
expect(out.userContent).toContain("hi");
expect(out.userContent).toContain("hello world");
expect(out.userContent.length).toBeGreaterThan(out.parsedContent.length);
});
it("strips <@bot> mention tags in group chats", () => {
const out = buildUserContent({
event: makeEvent({
type: "group",
content: "<@BOT> help",
mentions: [{ member_openid: "BOT", is_you: true }],
}),
attachmentInfo: "",
voiceTranscripts: [],
});
expect(out.userContent.trim()).toBe("help");
});
it("replaces <@user> with @nickname in DMs", () => {
const out = buildUserContent({
event: makeEvent({
type: "c2c",
content: "hi <@U2> there",
mentions: [{ member_openid: "U2", username: "Alice" }],
}),
attachmentInfo: "",
voiceTranscripts: [],
});
expect(out.userContent).toBe("hi @Alice there");
});
});
});

View File

@@ -0,0 +1,77 @@
/**
* Content stage — build the user-visible message body.
*
* Responsible for:
* 1. Parsing QQ emoji tags (`<faceType=...>` → `[Emoji: name]`)
* 2. Appending attachment info + voice transcripts
* 3. Stripping `<@openid>` mention tags in group messages
* 4. Replacing `<@openid>` → `@nickname` in DMs (best-effort)
*
* Pure function: same input → same output, no I/O.
*/
import { stripMentionText } from "../../group/mention.js";
import { parseFaceTags } from "../../utils/text-parsing.js";
import { formatVoiceText } from "../../utils/voice-text.js";
import type { QueuedMention, QueuedMessage } from "../message-queue.js";
// ─────────────────────────── Types ───────────────────────────
/** Input for {@link buildUserContent}. */
export interface ContentStageInput {
event: QueuedMessage;
/** `attachmentInfo` from the attachment stage — appended verbatim. */
attachmentInfo: string;
/** Voice transcripts collected from the attachment stage. */
voiceTranscripts: string[];
}
/** Output of {@link buildUserContent}. */
export interface ContentStageOutput {
/** `parseFaceTags(event.content)`. */
parsedContent: string;
/** Full user-visible content (parsed + voice + attachments + mention cleanup). */
userContent: string;
}
// ─────────────────────────── Stage ───────────────────────────
/**
* Build both the raw-parsed content and the fully composed user-visible
* body that downstream stages feed to the AI and to the envelope.
*/
export function buildUserContent(input: ContentStageInput): ContentStageOutput {
const { event, attachmentInfo, voiceTranscripts } = input;
const parsedContent = parseFaceTags(event.content);
const voiceText = formatVoiceText(voiceTranscripts);
let userContent = voiceText
? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
: parsedContent + attachmentInfo;
// Mention cleanup — only for events with mentions attached.
if (event.type === "group" && event.mentions?.length) {
userContent = stripMentionText(userContent, event.mentions as never) ?? userContent;
} else if (event.mentions?.length) {
userContent = replaceMentionsWithNicknames(userContent, event.mentions);
}
return { parsedContent, userContent };
}
// ─────────────────────────── Internal ───────────────────────────
function replaceMentionsWithNicknames(text: string, mentions: QueuedMention[]): string {
let out = text;
for (const m of mentions) {
if (m.member_openid && m.username) {
out = out.replace(new RegExp(`<@${escapeRegex(m.member_openid)}>`, "g"), `@${m.username}`);
}
}
return out;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import type { GroupMessageGateResult } from "../../group/message-gating.js";
import type { ProcessedAttachments } from "../inbound-attachments.js";
import type { InboundGroupInfo } from "../inbound-context.js";
import {
buildDynamicCtx,
buildGroupSystemPrompt,
buildQuotePart,
classifyMedia,
} from "./envelope-stage.js";
function makeGate(): GroupMessageGateResult {
return { action: "pass", effectiveWasMentioned: true, shouldBypassMention: false };
}
function makeGroupInfo(partial: Partial<InboundGroupInfo["display"]> = {}): InboundGroupInfo {
return {
gate: makeGate(),
activation: "mention",
historyLimit: 50,
isMerged: false,
display: {
groupName: "G",
senderLabel: "S",
...partial,
},
};
}
describe("envelope-stage", () => {
describe("buildQuotePart", () => {
it("returns empty string when no replyTo", () => {
expect(buildQuotePart(undefined)).toBe("");
});
it("wraps a quoted body in begin/end tags", () => {
const out = buildQuotePart({ id: "R1", body: "hello", isQuote: true });
expect(out).toContain("[Quoted message begins]");
expect(out).toContain("hello");
expect(out).toContain("[Quoted message ends]");
});
it("uses a fallback line when body is missing", () => {
const out = buildQuotePart({ id: "R1", isQuote: true });
expect(out).toContain("Original content unavailable");
});
});
describe("buildDynamicCtx", () => {
it("returns empty string when every list is empty", () => {
expect(
buildDynamicCtx({
imageUrls: [],
uniqueVoicePaths: [],
uniqueVoiceUrls: [],
uniqueVoiceAsrReferTexts: [],
}),
).toBe("");
});
it("renders images / voice / asr when present", () => {
const out = buildDynamicCtx({
imageUrls: ["https://x/a.png", "https://x/b.png"],
uniqueVoicePaths: ["/tmp/v.wav"],
uniqueVoiceUrls: ["https://x/v.wav"],
uniqueVoiceAsrReferTexts: ["hi", "there"],
});
expect(out).toContain("- Images: https://x/a.png, https://x/b.png");
expect(out).toContain("- Voice: /tmp/v.wav, https://x/v.wav");
expect(out).toContain("- ASR: hi | there");
// Trailing blank line.
expect(out.endsWith("\n\n")).toBe(true);
});
});
describe("buildGroupSystemPrompt", () => {
it("returns undefined when no prompts exist", () => {
expect(buildGroupSystemPrompt("", undefined)).toBeUndefined();
});
it("joins accountSystemInstruction + introHint + behaviorPrompt", () => {
const out = buildGroupSystemPrompt(
"ACCOUNT",
makeGroupInfo({ introHint: "INTRO", behaviorPrompt: "BEHAVIOR" }),
);
expect(out).toBe("ACCOUNT\nINTRO\nBEHAVIOR");
});
it("skips undefined parts cleanly", () => {
const out = buildGroupSystemPrompt("", makeGroupInfo({ behaviorPrompt: "B" }));
expect(out).toBe("B");
});
});
describe("classifyMedia", () => {
const emptyProcessed: ProcessedAttachments = {
attachmentInfo: "",
imageUrls: [],
imageMediaTypes: [],
voiceAttachmentPaths: [],
voiceAttachmentUrls: [],
voiceAsrReferTexts: [],
voiceTranscripts: [],
voiceTranscriptSources: [],
attachmentLocalPaths: [],
};
it("separates local from remote image URLs", () => {
const out = classifyMedia({
...emptyProcessed,
imageUrls: ["/tmp/a.png", "https://x/b.png", "http://x/c.png"],
imageMediaTypes: ["image/png", "image/jpeg", "image/gif"],
});
expect(out.localMediaPaths).toEqual(["/tmp/a.png"]);
expect(out.remoteMediaUrls).toEqual(["https://x/b.png", "http://x/c.png"]);
expect(out.remoteMediaTypes).toEqual(["image/jpeg", "image/gif"]);
});
it("defaults missing media type to image/png", () => {
// When `imageMediaTypes[i]` is undefined (shorter than imageUrls),
// the classifier substitutes a default.
const out = classifyMedia({
...emptyProcessed,
imageUrls: ["https://x/a.png"],
imageMediaTypes: [],
});
expect(out.remoteMediaTypes).toEqual(["image/png"]);
});
it("dedupes voice paths and URLs", () => {
const out = classifyMedia({
...emptyProcessed,
voiceAttachmentPaths: ["/a", "/a", "/b"],
voiceAttachmentUrls: ["u1", "u1"],
voiceAsrReferTexts: ["x", "", "x"],
});
expect(out.uniqueVoicePaths).toEqual(["/a", "/b"]);
expect(out.uniqueVoiceUrls).toEqual(["u1"]);
expect(out.uniqueVoiceAsrReferTexts).toEqual(["x"]);
});
it("flags ASR fallback when transcriptSources contains 'asr'", () => {
expect(
classifyMedia({ ...emptyProcessed, voiceTranscriptSources: ["stt", "asr"] })
.hasAsrReferFallback,
).toBe(true);
expect(
classifyMedia({ ...emptyProcessed, voiceTranscriptSources: ["stt"] }).hasAsrReferFallback,
).toBe(false);
});
});
});

View File

@@ -0,0 +1,144 @@
/**
* Envelope stage — render the Web UI body, the dynamic-context block,
* the final group system prompt, and the media classification arrays.
*
* All logic here is presentation-layer glue: it combines fields built by
* earlier stages into the display-friendly strings the outbound
* dispatcher needs. No decisions / gating.
*/
import type { ProcessedAttachments } from "../inbound-attachments.js";
import type { InboundGroupInfo, InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js";
import type { QueuedMessage } from "../message-queue.js";
// ─────────────────────────── Envelope body ───────────────────────────
export interface BuildBodyInput {
event: QueuedMessage;
deps: InboundPipelineDeps;
userContent: string;
isGroupChat: boolean;
imageUrls: string[];
}
/** Format the inbound envelope (Web UI body). */
export function buildBody(input: BuildBodyInput): string {
const { event, deps, userContent, isGroupChat, imageUrls } = input;
const envelopeOptions = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg);
return deps.runtime.channel.reply.formatInboundEnvelope({
channel: "qqbot",
from: event.senderName ?? event.senderId,
timestamp: new Date(event.timestamp).getTime(),
body: userContent,
chatType: isGroupChat ? "group" : "direct",
sender: { id: event.senderId, name: event.senderName },
envelope: envelopeOptions,
...(imageUrls.length > 0 ? { imageUrls } : {}),
});
}
// ─────────────────────────── Quote / dynamic ctx ───────────────────────────
/** Render the `[Quoted message begins]...[ends]` block (empty if no reply-to). */
export function buildQuotePart(replyTo?: ReplyToInfo): string {
if (!replyTo) {
return "";
}
return replyTo.body
? `[Quoted message begins]\n${replyTo.body}\n[Quoted message ends]\n`
: `[Quoted message begins]\nOriginal content unavailable\n[Quoted message ends]\n`;
}
export interface BuildDynamicCtxInput {
imageUrls: string[];
uniqueVoicePaths: string[];
uniqueVoiceUrls: string[];
uniqueVoiceAsrReferTexts: string[];
}
/** Render the per-message dynamic metadata block (images / voice / ASR). */
export function buildDynamicCtx(input: BuildDynamicCtxInput): string {
const lines: string[] = [];
if (input.imageUrls.length > 0) {
lines.push(`- Images: ${input.imageUrls.join(", ")}`);
}
if (input.uniqueVoicePaths.length > 0 || input.uniqueVoiceUrls.length > 0) {
lines.push(`- Voice: ${[...input.uniqueVoicePaths, ...input.uniqueVoiceUrls].join(", ")}`);
}
if (input.uniqueVoiceAsrReferTexts.length > 0) {
lines.push(`- ASR: ${input.uniqueVoiceAsrReferTexts.join(" | ")}`);
}
return lines.length > 0 ? lines.join("\n") + "\n\n" : "";
}
// ─────────────────────────── System prompt ───────────────────────────
/** Combine account-level system prompt with group-specific prompts. */
export function buildGroupSystemPrompt(
accountSystemInstruction: string,
groupInfo: InboundGroupInfo | undefined,
): string | undefined {
const parts: string[] = [];
if (accountSystemInstruction) {
parts.push(accountSystemInstruction);
}
if (groupInfo?.display.introHint) {
parts.push(groupInfo.display.introHint);
}
if (groupInfo?.display.behaviorPrompt) {
parts.push(groupInfo.display.behaviorPrompt);
}
const combined = parts.filter(Boolean).join("\n");
return combined || undefined;
}
// ─────────────────────────── Media classification ───────────────────────────
export interface MediaClassification {
localMediaPaths: string[];
localMediaTypes: string[];
remoteMediaUrls: string[];
remoteMediaTypes: string[];
uniqueVoicePaths: string[];
uniqueVoiceUrls: string[];
uniqueVoiceAsrReferTexts: string[];
voiceMediaTypes: string[];
hasAsrReferFallback: boolean;
voiceTranscriptSources: string[];
}
/** Classify image URLs into local vs remote and de-duplicate voice arrays. */
export function classifyMedia(processed: ProcessedAttachments): MediaClassification {
const localMediaPaths: string[] = [];
const localMediaTypes: string[] = [];
const remoteMediaUrls: string[] = [];
const remoteMediaTypes: string[] = [];
for (let i = 0; i < processed.imageUrls.length; i++) {
const u = processed.imageUrls[i];
const t = processed.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 uniqueVoicePaths = [...new Set(processed.voiceAttachmentPaths)];
const uniqueVoiceUrls = [...new Set(processed.voiceAttachmentUrls)];
const voiceMediaTypes = [...uniqueVoicePaths, ...uniqueVoiceUrls].map(() => "audio/wav");
return {
localMediaPaths,
localMediaTypes,
remoteMediaUrls,
remoteMediaTypes,
uniqueVoicePaths,
uniqueVoiceUrls,
uniqueVoiceAsrReferTexts: [...new Set(processed.voiceAsrReferTexts)].filter(Boolean),
voiceMediaTypes,
hasAsrReferFallback: processed.voiceTranscriptSources.includes("asr"),
voiceTranscriptSources: processed.voiceTranscriptSources,
};
}

View File

@@ -0,0 +1,292 @@
/**
* Group-gate stage — for `type === "group"` inbound events, decide
* whether the message should pass to AI dispatch or be intercepted.
*
* Three possible outcomes:
* - `{ kind: "pass", groupInfo }` — continue the pipeline
* - `{ kind: "skip", groupInfo, skipReason }` — buffered to history
* (if applicable) and short-circuit
* - No group info at all — returned when the event isn't a group event
* (caller should treat as a straight pass-through)
*
* Consolidates the control-command auth check, session-store
* activation override, mention detection, and the unified
* {@link resolveGroupMessageGate} call. Delegates all pure logic to
* existing `engine/group/*` modules so this stage remains a thin
* orchestrator.
*/
import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "../../access/index.js";
import { DEFAULT_GROUP_PROMPT, resolveGroupSettings } from "../../config/group.js";
import { resolveGroupActivation } from "../../group/activation.js";
import { toAttachmentSummaries, type HistoryEntry } from "../../group/history.js";
import { detectWasMentioned, hasAnyMention, resolveImplicitMention } from "../../group/mention.js";
import { getRefIndex } from "../../ref/store.js";
import type { InboundGroupInfo, InboundPipelineDeps } from "../inbound-context.js";
import { isMergedTurn, type QueuedMessage } from "../message-queue.js";
// ─────────────────────────── Types ───────────────────────────
export interface GroupGatePass {
kind: "pass";
groupInfo: InboundGroupInfo;
}
export interface GroupGateSkip {
kind: "skip";
groupInfo: InboundGroupInfo;
skipReason: NonNullable<import("../inbound-context.js").InboundContext["skipReason"]>;
}
export type GroupGateStageResult = GroupGatePass | GroupGateSkip;
export interface GroupGateStageInput {
event: QueuedMessage;
deps: InboundPipelineDeps;
accountId: string;
agentId?: string;
sessionKey: string;
/** User-visible content (post-emoji-parse, post-mention-strip). */
userContent: string;
/** Already-processed attachments (downloaded). Available for history recording. */
processedAttachments?: import("../inbound-attachments.js").ProcessedAttachments;
}
// ─────────────────────────── Stage ───────────────────────────
/**
* Run the group-gate stage.
*
* Precondition: `event.type === "group"` && `event.groupOpenid` is set.
* The caller (pipeline) enforces this; the stage doesn't re-check.
*
* On `skip` outcomes the stage records the message into the group's
* history buffer when the skip reason is one that should preserve
* context (drop / skip_no_mention), then returns. `block` skip
* reasons do NOT write history — they are silent rejects.
*/
export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageResult {
const { event, deps, accountId, agentId, sessionKey, userContent, processedAttachments } = input;
const groupOpenid = event.groupOpenid!;
const cfg = (deps.cfg ?? {}) as Record<string, unknown>;
// ---- 1. One-pass config resolution ----
const settings = resolveGroupSettings({ cfg, groupOpenid, accountId, agentId });
const { historyLimit, requireMention, ignoreOtherMentions } = settings.config;
const behaviorPrompt = settings.config.prompt ?? DEFAULT_GROUP_PROMPT;
const groupName = settings.name;
// ---- 2. Mention detection (QQ-specific) ----
const explicitWasMentioned = detectWasMentioned({
eventType: event.eventType,
mentions: event.mentions as never,
content: event.content,
mentionPatterns: settings.mentionPatterns,
});
const anyMention = hasAnyMention({
mentions: event.mentions as never,
content: event.content,
});
const implicitMention = resolveImplicitMention({
refMsgIdx: event.refMsgIdx,
getRefEntry: (idx) => getRefIndex(idx) ?? null,
});
// ---- 3. Activation mode (session store > cfg) ----
const activation = resolveGroupActivation({
cfg,
agentId: agentId ?? "default",
sessionKey,
configRequireMention: requireMention,
sessionStoreReader: deps.sessionStoreReader,
});
// ---- 4. Command authorization (for bypass) ----
const content = (event.content ?? "").trim();
const isControlCommand = Boolean(deps.isControlCommand?.(content));
const commandAuthorized =
deps.allowTextCommands !== false && isSenderAllowedForCommands(event.senderId, deps);
// ---- 5. Gate evaluation ----
// Layer 1 (ignoreOtherMentions) is QQ-specific and handled by
// resolveGateWithPort. Layers 2+3 delegate to the SDK adapter.
const gate = resolveGateWithPort({
mentionGatePort: deps.adapters.mentionGate,
ignoreOtherMentions,
hasAnyMention: anyMention,
wasMentioned: explicitWasMentioned,
implicitMention,
allowTextCommands: deps.allowTextCommands !== false,
isControlCommand,
commandAuthorized,
requireMention: activation === "mention",
});
// ---- 6. Build InboundGroupInfo (shared by pass / skip paths) ----
const introHint = deps.resolveGroupIntroHint?.({
cfg,
accountId,
groupId: groupOpenid,
});
const senderLabel = event.senderName ? `${event.senderName} (${event.senderId})` : event.senderId;
const groupInfo: InboundGroupInfo = {
gate,
activation,
historyLimit,
isMerged: isMergedTurn(event),
mergedMessages: event.merge?.messages,
display: {
groupName,
senderLabel,
introHint,
behaviorPrompt,
},
};
// ---- 7. Decide pass vs skip ----
if (gate.action === "pass") {
return { kind: "pass", groupInfo };
}
// Skip path: record history for drop / skip_no_mention, silent for block.
if (gate.action === "drop_other_mention" || gate.action === "skip_no_mention") {
recordGroupHistory({
historyMap: deps.groupHistories,
groupOpenid,
historyLimit,
event,
userContent,
historyPort: deps.adapters.history,
localPaths: processedAttachments?.attachmentLocalPaths,
});
}
return { kind: "skip", groupInfo, skipReason: gate.action };
}
// ─────────────────────────── Internal helpers ───────────────────────────
import type { HistoryPort } from "../../adapter/history.port.js";
import type { MentionGatePort } from "../../adapter/mention-gate.port.js";
import type { GroupMessageGateResult } from "../../group/message-gating.js";
/**
* Resolve the gate using the SDK MentionGatePort adapter.
*
* Layer 1 (ignoreOtherMentions) is QQ-specific and handled here.
* Layers 2+3 delegate to the SDK's `resolveInboundMentionDecision`.
*/
function resolveGateWithPort(params: {
mentionGatePort: MentionGatePort;
ignoreOtherMentions: boolean;
hasAnyMention: boolean;
wasMentioned: boolean;
implicitMention: boolean;
allowTextCommands: boolean;
isControlCommand: boolean;
commandAuthorized: boolean;
requireMention: boolean;
}): GroupMessageGateResult {
// Layer 1: QQ-specific ignoreOtherMentions
if (
params.ignoreOtherMentions &&
params.hasAnyMention &&
!params.wasMentioned &&
!params.implicitMention
) {
return {
action: "drop_other_mention",
effectiveWasMentioned: false,
shouldBypassMention: false,
};
}
// Layer 2+3: delegate to SDK mention gate (includes command bypass)
const decision = params.mentionGatePort.resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: params.wasMentioned,
hasAnyMention: params.hasAnyMention,
implicitMentionKinds: params.implicitMention ? ["reply_to_bot"] : [],
},
policy: {
isGroup: true,
requireMention: params.requireMention,
allowTextCommands: params.allowTextCommands,
hasControlCommand: params.isControlCommand,
commandAuthorized: params.commandAuthorized,
},
});
// Map SDK's shouldBlock (unauthorized command) to our action
if (params.allowTextCommands && params.isControlCommand && !params.commandAuthorized) {
return {
action: "block_unauthorized_command",
effectiveWasMentioned: false,
shouldBypassMention: false,
};
}
if (decision.shouldSkip) {
return {
action: "skip_no_mention",
effectiveWasMentioned: decision.effectiveWasMentioned,
shouldBypassMention: decision.shouldBypassMention,
};
}
return {
action: "pass",
effectiveWasMentioned: decision.effectiveWasMentioned,
shouldBypassMention: decision.shouldBypassMention,
};
}
/**
* Test whether the sender is on the DM `allowFrom` list.
*/
function isSenderAllowedForCommands(senderId: string, deps: InboundPipelineDeps): boolean {
const raw = deps.account.config?.allowFrom;
if (!Array.isArray(raw) || raw.length === 0) {
return true;
}
const normalized = normalizeQQBotAllowFrom(raw);
return createQQBotSenderMatcher(senderId)(normalized);
}
function recordGroupHistory(params: {
historyMap: Map<string, HistoryEntry[]> | undefined;
groupOpenid: string;
historyLimit: number;
event: QueuedMessage;
userContent: string;
historyPort: HistoryPort;
/** Local paths from processAttachments — enriches history with downloaded file paths. */
localPaths?: Array<string | null>;
}): void {
const { historyMap, groupOpenid, historyLimit, event, userContent, historyPort, localPaths } =
params;
if (!historyMap || historyLimit <= 0) {
return;
}
const senderForHistory = event.senderName
? `${event.senderName} (${event.senderId})`
: event.senderId;
const entry: HistoryEntry = {
sender: senderForHistory,
body: userContent,
timestamp: new Date(event.timestamp).getTime(),
messageId: event.messageId,
attachments: toAttachmentSummaries(event.attachments, localPaths),
};
historyPort.recordPendingHistoryEntry({
historyMap,
historyKey: groupOpenid,
limit: historyLimit,
entry,
});
}

View File

@@ -0,0 +1,18 @@
/**
* Inbound pipeline stages — each stage is a pure(-ish) function that
* transforms a subset of the pipeline's state. The main `inbound-pipeline`
* module composes them in order.
*
* Keeping every stage in its own file makes the pipeline's control flow
* obvious and lets each piece be unit-tested against tiny input fixtures
* without spinning up the full gateway.
*/
export * from "./access-stage.js";
export * from "./assembly-stage.js";
export * from "./content-stage.js";
export * from "./envelope-stage.js";
export * from "./group-gate-stage.js";
export * from "./quote-stage.js";
export * from "./refidx-stage.js";
export * from "./stub-contexts.js";

View File

@@ -0,0 +1,113 @@
/**
* Quote stage — resolve the quoted-reply (`refMsgIdx`) if any.
*
* Three-level fallback mirrors the standalone build:
* 1. RefIndex cache hit → rich ReplyToInfo
* 2. `msg_elements[0]` present → re-process the quoted body
* 3. Otherwise → id-only placeholder so the pipeline still knows it's a reply
*/
import {
formatMessageReferenceForAgent,
type AttachmentProcessor,
} from "../../ref/format-message-ref.js";
import { formatRefEntryForAgent, getRefIndex } from "../../ref/store.js";
import { MSG_TYPE_QUOTE } from "../../utils/text-parsing.js";
import { formatVoiceText } from "../../utils/voice-text.js";
import { processAttachments } from "../inbound-attachments.js";
import type { InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js";
import type { QueuedMessage } from "../message-queue.js";
/**
* Resolve the quote metadata for an inbound event.
*
* Returns `undefined` when the event is not a reply at all.
*/
export async function resolveQuote(
event: QueuedMessage,
deps: InboundPipelineDeps,
): Promise<ReplyToInfo | undefined> {
if (!event.refMsgIdx) {
return undefined;
}
const { account, log } = deps;
// ---- Layer 1: cache hit ----
const refEntry = getRefIndex(event.refMsgIdx);
if (refEntry) {
log?.debug?.(
`Quote detected via refMsgIdx cache: refMsgIdx=${event.refMsgIdx}, sender=${refEntry.senderName ?? refEntry.senderId}`,
);
return {
id: event.refMsgIdx,
body: formatRefEntryForAgent(refEntry),
sender: refEntry.senderName ?? refEntry.senderId,
isQuote: true,
};
}
// ---- Layer 2: fall back to msg_elements[0] if this is a quote type ----
if (event.msgType === MSG_TYPE_QUOTE && event.msgElements?.[0]) {
try {
const refElement = event.msgElements[0];
const refData = {
content: refElement.content ?? "",
attachments: refElement.attachments,
};
const attachmentProcessor: AttachmentProcessor = {
processAttachments: async (atts, refCtx) => {
const result = await processAttachments(
atts as Array<{
content_type: string;
url: string;
filename?: string;
voice_wav_url?: string;
asr_refer_text?: string;
}>,
{
accountId: account.accountId,
cfg: refCtx.cfg,
audioConvert: deps.adapters.audioConvert,
log: refCtx.log,
},
);
return {
attachmentInfo: result.attachmentInfo,
voiceTranscripts: result.voiceTranscripts,
voiceTranscriptSources: result.voiceTranscriptSources,
attachmentLocalPaths: result.attachmentLocalPaths,
};
},
formatVoiceText: (transcripts) => formatVoiceText(transcripts),
};
const refPeerId =
event.type === "group" && event.groupOpenid ? event.groupOpenid : event.senderId;
const refBody = await formatMessageReferenceForAgent(
refData,
{ appId: account.appId, peerId: refPeerId, cfg: account.config, log },
attachmentProcessor,
);
log?.debug?.(
`Quote detected via msg_elements[0] (cache miss): id=${event.refMsgIdx}, content="${(refBody ?? "").slice(0, 80)}..."`,
);
return {
id: event.refMsgIdx,
body: refBody || undefined,
isQuote: true,
};
} catch (refErr) {
log?.error(`Failed to format quoted message from msg_elements: ${String(refErr)}`);
}
} else {
log?.debug?.(
`Quote detected but no cache and msgType=${event.msgType}: refMsgIdx=${event.refMsgIdx}`,
);
}
// ---- Layer 3: id-only placeholder ----
return {
id: event.refMsgIdx,
isQuote: true,
};
}

View File

@@ -0,0 +1,62 @@
/**
* RefIdx persistence stage — writes the current message into the shared
* `refIndex` cache so future quote resolutions can find it.
*
* The stage also attaches voice transcripts (and their source) onto the
* cached attachment summaries so replies-to-this-message can render the
* original audio content inline instead of just a file handle.
*
* Pure data pipeline (no network I/O). Sync return value.
*/
import { setRefIndex } from "../../ref/store.js";
import { buildAttachmentSummaries } from "../../utils/text-parsing.js";
import type { ProcessedAttachments } from "../inbound-attachments.js";
import type { QueuedMessage } from "../message-queue.js";
/**
* Cache the current message under `msgIdx` (or the fallback `refIdx`
* returned by the typing-indicator call) so later quotes resolve.
*
* No-op when neither id is available.
*/
export function writeRefIndex(params: {
event: QueuedMessage;
parsedContent: string;
processed: ProcessedAttachments;
/** Optional refIdx returned by `InputNotify` — used when `msgIdx` is missing. */
inputNotifyRefIdx?: string;
}): void {
const { event, parsedContent, processed, inputNotifyRefIdx } = params;
const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx;
if (!currentMsgIdx) {
return;
}
const attSummaries = buildAttachmentSummaries(event.attachments, processed.attachmentLocalPaths);
if (attSummaries && processed.voiceTranscripts.length > 0) {
let voiceIdx = 0;
for (const att of attSummaries) {
if (att.type === "voice" && voiceIdx < processed.voiceTranscripts.length) {
att.transcript = processed.voiceTranscripts[voiceIdx];
if (voiceIdx < processed.voiceTranscriptSources.length) {
att.transcriptSource = processed.voiceTranscriptSources[voiceIdx] as
| "stt"
| "asr"
| "tts"
| "fallback";
}
voiceIdx++;
}
}
}
setRefIndex(currentMsgIdx, {
content: parsedContent,
senderId: event.senderId,
senderName: event.senderName,
timestamp: new Date(event.timestamp).getTime(),
attachments: attSummaries,
});
}

View File

@@ -0,0 +1,116 @@
/**
* Shared `InboundContext` stub builders for early-return paths.
*
* Both the access-control "blocked" path and the group-gate "skipped"
* path need to return a fully populated {@link InboundContext} that the
* upstream handler can inspect without crashing on undefined fields.
* Centralising the stubs here prevents the two paths from drifting.
*/
import type { QQBotAccessResult } from "../../access/index.js";
import type { InboundContext, InboundGroupInfo } from "../inbound-context.js";
import type { QueuedMessage } from "../message-queue.js";
import type { TypingKeepAlive } from "../typing-keepalive.js";
/** Shared fields every stub context needs. */
interface BaseStubFields {
event: QueuedMessage;
route: { sessionKey: string; accountId: string; agentId?: string };
isGroupChat: boolean;
peerId: string;
qualifiedTarget: string;
fromAddress: string;
}
/** Build an {@link InboundContext} with all non-routing fields cleared. */
export function emptyInboundContext(fields: BaseStubFields): InboundContext {
return {
event: fields.event,
route: fields.route,
isGroupChat: fields.isGroupChat,
peerId: fields.peerId,
qualifiedTarget: fields.qualifiedTarget,
fromAddress: fields.fromAddress,
parsedContent: "",
userContent: "",
quotePart: "",
dynamicCtx: "",
userMessage: "",
agentBody: "",
body: "",
systemPrompts: [],
groupSystemPrompt: undefined,
attachments: {
attachmentInfo: "",
imageUrls: [],
imageMediaTypes: [],
voiceAttachmentPaths: [],
voiceAttachmentUrls: [],
voiceAsrReferTexts: [],
voiceTranscripts: [],
voiceTranscriptSources: [],
attachmentLocalPaths: [],
},
localMediaPaths: [],
localMediaTypes: [],
remoteMediaUrls: [],
remoteMediaTypes: [],
uniqueVoicePaths: [],
uniqueVoiceUrls: [],
uniqueVoiceAsrReferTexts: [],
voiceMediaTypes: [],
hasAsrReferFallback: false,
voiceTranscriptSources: [],
replyTo: undefined,
commandAuthorized: false,
group: undefined,
blocked: false,
skipped: false,
typing: { keepAlive: null },
inputNotifyRefIdx: undefined,
};
}
/**
* Build an {@link InboundContext} that represents a message blocked by
* access control (policy denial, allowlist mismatch, etc.).
*/
export function buildBlockedInboundContext(
params: BaseStubFields & {
access: QQBotAccessResult;
},
): InboundContext {
return {
...emptyInboundContext(params),
blocked: true,
blockReason: params.access.reason,
blockReasonCode: params.access.reasonCode,
accessDecision: params.access.decision,
};
}
/**
* Build an {@link InboundContext} that represents a message stopped by
* the group gate (drop_other_mention, block_unauthorized_command,
* skip_no_mention). Any history side-effects have already been applied
* by the gate stage.
*/
export function buildSkippedInboundContext(
params: BaseStubFields & {
group: InboundGroupInfo;
skipReason: NonNullable<InboundContext["skipReason"]>;
access: QQBotAccessResult;
typing: { keepAlive: TypingKeepAlive | null };
inputNotifyRefIdx?: string;
},
): InboundContext {
return {
...emptyInboundContext(params),
group: params.group,
skipped: true,
skipReason: params.skipReason,
accessDecision: params.access.decision,
typing: params.typing,
inputNotifyRefIdx: params.inputNotifyRefIdx,
};
}

View File

@@ -70,6 +70,20 @@ export interface GatewayPluginRuntime {
error?: string;
}>;
};
/**
* Config API for reading/writing the framework configuration.
*
* Used by the interaction handler (config query/update) directly
* within the engine layer. Optional because not all runtime
* environments provide config write capability.
*/
config?: {
current: () => Record<string, unknown>;
replaceConfigFile: (params: {
nextConfig: unknown;
afterWrite: { mode: "auto" };
}) => Promise<unknown>;
};
}
// ============ Shared result types ============
@@ -146,17 +160,81 @@ export interface GroupMessageEvent {
id: string;
content: string;
timestamp: string;
author: { member_openid: string };
author: {
member_openid: string;
username?: string;
/** True when the sender is itself a bot. */
bot?: boolean;
};
group_openid: string;
attachments?: RawMessageAttachment[];
message_scene?: { ext?: string[] };
/** Optional @mentions list with per-entry is_you / member_openid / nickname. */
mentions?: Array<{
scope?: "all" | "single";
id?: string;
user_openid?: string;
member_openid?: string;
nickname?: string;
username?: string;
bot?: boolean;
/** `true` when this mention targets the bot itself. */
is_you?: boolean;
}>;
message_scene?: { source?: string; ext?: string[] };
message_type?: number;
msg_elements?: RawMsgElement[];
}
// ============ Gateway Context ============
/** Full gateway startup context. Only `runtime` is injected; everything else is imported directly. */
import type { EngineAdapters } from "../adapter/index.js";
/**
* Group-chat behaviour options.
*
* Grouped under a dedicated sub-object on {@link CoreGatewayContext} so
* future additions (admin lookup, proactive push, per-group toggles)
* don't keep polluting the top-level context type.
*/
export interface GatewayGroupOptions {
/**
* Whether group-chat gating is enabled. Defaults to `true`; set to
* `false` to disable all group processing (e.g. for a DM-only smoke
* test). When disabled, the engine does not allocate a history
* buffer and does not instantiate the session-store reader.
*/
enabled?: boolean;
/**
* Whether the framework has text-based control commands enabled. When
* `false`, the group gate skips the "unauthorized command" check and
* the command-bypass path.
*/
allowTextCommands?: boolean;
/**
* Optional probe that returns true when `content` is a recognised
* control command. Injected to avoid hard-coding a command list in
* the engine. When omitted, no message is treated as a control
* command and the bypass path never activates.
*/
isControlCommand?: (content: string) => boolean;
/**
* Platform hook that contributes a channel-level group intro hint
* (e.g. "当前群: 开发讨论组"). Invoked per-group when building the
* system prompt.
*/
resolveIntroHint?: (params: {
cfg: unknown;
accountId: string;
groupId: string;
}) => string | undefined;
/**
* Session-store reader for the `/activation` command override. When
* omitted, the engine loads a default node-based reader lazily.
*/
sessionStoreReader?: import("../group/activation.js").SessionStoreReader;
}
/** Full gateway startup context. */
export interface CoreGatewayContext {
account: GatewayAccount;
abortSignal: AbortSignal;
@@ -172,4 +250,8 @@ export interface CoreGatewayContext {
log?: EngineLogger;
/** PluginRuntime injected by the framework — same object in both versions. */
runtime: GatewayPluginRuntime;
/** Group-chat tuning options. */
group?: GatewayGroupOptions;
/** Adapter ports — delegates audio, history, mention gating, commands to bridge implementations. */
adapters: EngineAdapters;
}

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { resolveGroupActivation, type SessionStoreReader } from "./activation.js";
describe("engine/group/activation", () => {
describe("resolveGroupActivation — no reader", () => {
it("maps configRequireMention=true → mention", () => {
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "s",
configRequireMention: true,
}),
).toBe("mention");
});
it("maps configRequireMention=false → always", () => {
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "s",
configRequireMention: false,
}),
).toBe("always");
});
});
describe("resolveGroupActivation — with reader", () => {
const makeReader = (
store: Record<string, { groupActivation?: string }> | null,
): SessionStoreReader => ({
read: () => store,
});
it("honours explicit session-store override (mention)", () => {
const reader = makeReader({ k1: { groupActivation: "mention" } });
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: false,
sessionStoreReader: reader,
}),
).toBe("mention");
});
it("honours explicit session-store override (always)", () => {
const reader = makeReader({ k1: { groupActivation: "always" } });
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: true,
sessionStoreReader: reader,
}),
).toBe("always");
});
it("ignores override when the key is absent", () => {
const reader = makeReader({});
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "MISSING",
configRequireMention: true,
sessionStoreReader: reader,
}),
).toBe("mention");
});
it("ignores reader errors (null) and falls back", () => {
const reader = makeReader(null);
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: false,
sessionStoreReader: reader,
}),
).toBe("always");
});
it("ignores invalid activation values", () => {
const reader = makeReader({ k1: { groupActivation: "weird-mode" } });
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: true,
sessionStoreReader: reader,
}),
).toBe("mention");
});
it("normalizes whitespace / case", () => {
const reader = makeReader({ k1: { groupActivation: " Always " } });
expect(
resolveGroupActivation({
cfg: {},
agentId: "main",
sessionKey: "k1",
configRequireMention: true,
sessionStoreReader: reader,
}),
).toBe("always");
});
});
});

View File

@@ -0,0 +1,147 @@
/**
* Group activation mode — how the bot decides whether to respond in a group.
*
* Resolution chain:
* 1. session store override (`/activation` command writes per-session
* `groupActivation` value) — highest priority
* 2. per-group `requireMention` config
* 3. `"mention"` default (require @-bot to respond)
*
* File I/O is isolated in the default node-based reader so the gating
* logic itself stays a pure function, testable without touching disk.
*
* Note: the implicit-mention predicate (quoting a bot message counts as
* @-ing the bot) lives in `./mention.ts` alongside the other mention
* helpers — see `resolveImplicitMention` there.
*/
import fs from "node:fs";
import path from "node:path";
// ────────────────────────── Types ──────────────────────────
/** High-level activation outcome. */
export type GroupActivationMode = "mention" | "always";
/**
* Pluggable reader that returns parsed session-store contents.
*
* A return value of `null` means "no override available" (file missing,
* parse error, or reader disabled). Implementations must **not** throw —
* the gating pipeline treats any failure as "fall back to the config
* default".
*/
export interface SessionStoreReader {
read(params: {
cfg: Record<string, unknown>;
agentId: string;
}): Record<string, { groupActivation?: string }> | null;
}
// ────────────────────────── groupActivation ──────────────────────────
/**
* Resolve the effective activation mode for one inbound message.
*
* Order of precedence:
* 1. `store[sessionKey].groupActivation` (read via the injected reader)
* 2. config-level `requireMention` (maps to `"mention"` / `"always"`)
* 3. `"mention"` (safe default)
*/
export function resolveGroupActivation(params: {
cfg: Record<string, unknown>;
agentId: string;
sessionKey: string;
configRequireMention: boolean;
/** Pluggable reader; omit to disable the session-store override. */
sessionStoreReader?: SessionStoreReader;
}): GroupActivationMode {
const fallback: GroupActivationMode = params.configRequireMention ? "mention" : "always";
const store = params.sessionStoreReader?.read({
cfg: params.cfg,
agentId: params.agentId,
});
if (!store) {
return fallback;
}
const entry = store[params.sessionKey];
if (!entry?.groupActivation) {
return fallback;
}
const normalized = entry.groupActivation.trim().toLowerCase();
if (normalized === "mention" || normalized === "always") {
return normalized;
}
return fallback;
}
// ────────────────────────── Default node reader ──────────────────────────
/**
* Resolve the on-disk path to the agent-sessions file.
*
* Priority:
* 1. `cfg.session.store` (supports `{agentId}` placeholder and `~` expansion)
* 2. `$OPENCLAW_STATE_DIR` / `$CLAWDBOT_STATE_DIR`
* 3. `~/.openclaw/agents/{agentId}/sessions/sessions.json`
*/
export function resolveSessionStorePath(
cfg: Record<string, unknown>,
agentId: string | undefined,
): string {
const resolvedAgentId = agentId || "default";
const session =
typeof cfg.session === "object" && cfg.session !== null
? (cfg.session as { store?: unknown })
: undefined;
const rawStore = typeof session?.store === "string" ? session.store : undefined;
if (rawStore) {
let expanded = rawStore;
if (expanded.includes("{agentId}")) {
expanded = expanded.replaceAll("{agentId}", resolvedAgentId);
}
if (expanded.startsWith("~")) {
const home = process.env.HOME || process.env.USERPROFILE || "";
expanded = expanded.replace(/^~/, home);
}
return path.resolve(expanded);
}
const stateDir =
process.env.OPENCLAW_STATE_DIR?.trim() ||
process.env.CLAWDBOT_STATE_DIR?.trim() ||
path.join(process.env.HOME || process.env.USERPROFILE || "", ".openclaw");
return path.join(stateDir, "agents", resolvedAgentId, "sessions", "sessions.json");
}
/**
* Create the default, production-ready session-store reader.
*
* Reads the file synchronously on every call. The overhead is acceptable
* because activation mode is only resolved once per group message and
* the sessions file is typically a handful of kilobytes.
*
* Any I/O or JSON error is swallowed and returned as `null` so the
* gating pipeline falls back to the config default.
*/
export function createNodeSessionStoreReader(): SessionStoreReader {
return {
read: ({ cfg, agentId }) => {
try {
const storePath = resolveSessionStorePath(cfg, agentId);
if (!fs.existsSync(storePath)) {
return null;
}
const raw = fs.readFileSync(storePath, "utf-8");
return JSON.parse(raw) as Record<string, { groupActivation?: string }>;
} catch {
return null;
}
},
};
}

View File

@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeliverDebouncer, DeliverDebouncer } from "./deliver-debounce.js";
import type { DeliverPayload, DeliverInfo } from "./deliver-debounce.js";
function createMockExecutor() {
return vi.fn<(payload: DeliverPayload, info: DeliverInfo) => Promise<void>>(async () => {});
}
describe("engine/group/deliver-debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("buffers multiple text deliveries and merges them", async () => {
const executor = createMockExecutor();
const d = new DeliverDebouncer({ windowMs: 100, maxWaitMs: 10_000 }, executor);
await d.deliver({ text: "a" }, { kind: "block" });
await d.deliver({ text: "b" }, { kind: "block" });
await d.deliver({ text: "c" }, { kind: "block" });
expect(executor).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
expect(executor).toHaveBeenCalledTimes(1);
const call = executor.mock.calls[0];
expect(call[0].text).toContain("a");
expect(call[0].text).toContain("b");
expect(call[0].text).toContain("c");
});
it("flushes immediately when payload carries media", async () => {
const executor = createMockExecutor();
const d = new DeliverDebouncer({ windowMs: 200 }, executor);
await d.deliver({ text: "text" }, { kind: "block" });
await d.deliver({ mediaUrl: "http://x/a.png" }, { kind: "block" });
// One for the flushed text, one for the media.
expect(executor).toHaveBeenCalledTimes(2);
expect(executor.mock.calls[0][0].text).toBe("text");
expect(executor.mock.calls[1][0].mediaUrl).toBe("http://x/a.png");
});
it("passes through empty-text payloads directly", async () => {
const executor = createMockExecutor();
const d = new DeliverDebouncer({ windowMs: 200 }, executor);
await d.deliver({ text: " " }, { kind: "tool" });
expect(executor).toHaveBeenCalledTimes(1);
expect(executor.mock.calls[0][0].text).toBe(" ");
});
it("forces flush after maxWaitMs even if text keeps arriving", async () => {
const executor = createMockExecutor();
const d = new DeliverDebouncer({ windowMs: 1_000, maxWaitMs: 3_000 }, executor);
await d.deliver({ text: "a" }, { kind: "block" });
await vi.advanceTimersByTimeAsync(900); // still below window
await d.deliver({ text: "b" }, { kind: "block" });
await vi.advanceTimersByTimeAsync(900);
await d.deliver({ text: "c" }, { kind: "block" });
// maxWait timer was armed at t=0, so it should fire at t=3000.
await vi.advanceTimersByTimeAsync(1_300);
expect(executor).toHaveBeenCalledTimes(1);
expect(executor.mock.calls[0][0].text).toMatch(/a.+b.+c/s);
});
it("dispose flushes any remaining buffer", async () => {
const executor = createMockExecutor();
const d = new DeliverDebouncer({ windowMs: 10_000 }, executor);
await d.deliver({ text: "x" }, { kind: "block" });
expect(executor).not.toHaveBeenCalled();
await d.dispose();
expect(executor).toHaveBeenCalledTimes(1);
expect(executor.mock.calls[0][0].text).toBe("x");
});
it("ignores deliver calls after dispose", async () => {
const executor = createMockExecutor();
const d = new DeliverDebouncer({ windowMs: 1_000 }, executor);
await d.dispose();
await d.deliver({ text: "x" }, { kind: "block" });
expect(executor).not.toHaveBeenCalled();
});
it("createDeliverDebouncer returns null when disabled", () => {
const executor = createMockExecutor();
expect(createDeliverDebouncer({ enabled: false }, executor)).toBeNull();
});
it("createDeliverDebouncer returns instance by default", () => {
const executor = createMockExecutor();
const d = createDeliverDebouncer(undefined, executor);
expect(d).toBeInstanceOf(DeliverDebouncer);
});
it("preserves non-text fields from the latest buffered payload", async () => {
const executor = createMockExecutor();
const d = new DeliverDebouncer({ windowMs: 100 }, executor);
await d.deliver({ text: "a" }, { kind: "block" });
// Simulate a second deliver that also has a custom field — mediaUrls
// empty (not media-bearing) but merged later.
await d.deliver({ text: "b", mediaUrls: [] }, { kind: "block" });
await vi.advanceTimersByTimeAsync(100);
expect(executor).toHaveBeenCalledTimes(1);
// mediaUrls empty array should still be forwarded.
expect(executor.mock.calls[0][0].mediaUrls).toEqual([]);
});
it("hasPending / pendingCount reflect buffered state", async () => {
const executor = createMockExecutor();
const d = new DeliverDebouncer({ windowMs: 10_000 }, executor);
expect(d.hasPending).toBe(false);
expect(d.pendingCount).toBe(0);
await d.deliver({ text: "a" }, { kind: "block" });
expect(d.hasPending).toBe(true);
expect(d.pendingCount).toBe(1);
await d.dispose();
expect(d.hasPending).toBe(false);
});
});

View File

@@ -1,155 +1,282 @@
/**
* Message deliver debounce — merge multiple rapid deliver calls into one.
* Deliver debounce — merge short bursts of outbound text into one message.
*
* When QQ Bot sends multiple messages in quick succession (e.g. streaming
* partial responses), this module buffers them within a configurable time
* window and merges them into a single outbound message.
* Scenario: when the framework's dispatcher emits several `deliver()` calls
* in rapid succession (e.g. a tool returns partial text, then the agent
* streams more text a few hundred milliseconds later), naive delivery
* would spam the group with message fragments. The debouncer buffers
* text for a short window and flushes it as a single message.
*
* This prevents "message bombing" in group chats where rapid-fire messages
* flood the chat and annoy users.
* Design:
* - One debouncer instance per inbound turn. The gateway creates it
* lazily on the first `deliver` and disposes it in the `finally`
* block — per-peer bookkeeping is therefore unnecessary because the
* instance lifecycle already matches the peer's reply window.
* - Any payload carrying media (`mediaUrl` / `mediaUrls`) is NOT
* buffered: we flush the buffered text first, then forward the
* media-bearing payload immediately so media stays in-order.
* - Two timers: a sliding `windowMs` timer (reset on every new text)
* and a hard `maxWaitMs` cap (started on the first buffered text)
* prevent starvation when text keeps arriving faster than the window.
*
* The module is a pure function / class with zero I/O dependencies.
* The class is pure in-process logic; no I/O and no platform bindings.
* Safe to share between the built-in and standalone plugin builds.
*/
/** Configuration for the deliver debouncer. */
// ============ Defaults ============
const DEFAULT_WINDOW_MS = 1500;
const DEFAULT_MAX_WAIT_MS = 8000;
const DEFAULT_SEPARATOR = "\n\n---\n\n";
// ============ Types ============
/** Configuration for {@link DeliverDebouncer}. */
export interface DeliverDebounceConfig {
/** Whether debouncing is enabled. Defaults to true. */
enabled: boolean;
/** Time window in milliseconds. Defaults to 1500ms. */
/** Master switch. Default: true (enabled). Set to `false` to disable. */
enabled?: boolean;
/** Sliding-window duration in milliseconds. Default: 1500. */
windowMs?: number;
/**
* Maximum time to hold buffered text measured from the first buffered
* entry. Prevents starvation when text keeps arriving. Default: 8000.
*/
maxWaitMs?: number;
/** Separator inserted between merged text fragments. Default: `"\n\n---\n\n"`. */
separator?: string;
}
/** Payload passed to deliver callbacks. */
/** Shape of a deliver payload (text + optional media URLs). */
export interface DeliverPayload {
text?: string;
mediaUrls?: string[];
mediaUrl?: string;
}
/** Deliver callback info. */
/** Metadata attached by the framework's dispatcher to each deliver call. */
export interface DeliverInfo {
kind: string;
}
/** The actual deliver function signature. */
export type DeliverFn = (payload: DeliverPayload, info: DeliverInfo) => Promise<void>;
/** The actual send function that the debouncer eventually invokes. */
export type DeliverExecutor = (payload: DeliverPayload, info: DeliverInfo) => Promise<void>;
interface PendingEntry {
texts: string[];
mediaUrls: string[];
timer: ReturnType<typeof setTimeout>;
resolve: () => void;
/** Minimal logger interface (matches `EngineLogger`). */
export interface DebouncerLogger {
info: (msg: string) => void;
error: (msg: string) => void;
}
// ============ Implementation ============
/**
* Debouncer that merges rapid-fire deliver calls within a time window.
* Debouncer for a single outbound turn.
*
* Usage:
* ```ts
* const debouncer = new DeliverDebouncer({ enabled: true, windowMs: 1500 });
*
* // In the deliver callback:
* await debouncer.deliver(payload, info, originalDeliverFn);
* const debouncer = createDeliverDebouncer(cfg, executeDeliver, log, prefix);
* try {
* await debouncer.deliver(payload, info); // called per deliver event
* } finally {
* await debouncer.dispose(); // flush any leftover buffer
* }
* ```
*/
export class DeliverDebouncer {
private readonly enabled: boolean;
private readonly windowMs: number;
private readonly pending = new Map<string, PendingEntry>();
private readonly maxWaitMs: number;
private readonly separator: string;
private readonly executor: DeliverExecutor;
private readonly log?: DebouncerLogger;
private readonly prefix: string;
constructor(config?: DeliverDebounceConfig) {
this.enabled = config?.enabled !== false;
this.windowMs = config?.windowMs ?? 1500;
/** Buffered text fragments waiting to be merged. */
private bufferedTexts: string[] = [];
/** Info from the most recent buffered call — used when we flush. */
private lastInfo: DeliverInfo | null = null;
/** Non-text fields from the most recent buffered call — preserved on flush. */
private lastPayload: DeliverPayload | null = null;
/** Sliding window timer (reset on each new buffered call). */
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
/** Hard upper bound timer (armed once per burst). */
private maxWaitTimer: ReturnType<typeof setTimeout> | null = null;
/** Guard against re-entrant flushes. */
private flushing = false;
/** Lifecycle flag — once disposed, further deliver calls are ignored. */
private disposed = false;
constructor(
config: DeliverDebounceConfig | undefined,
executor: DeliverExecutor,
log?: DebouncerLogger,
prefix = "[debounce]",
) {
this.windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
this.maxWaitMs = config?.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
this.separator = config?.separator ?? DEFAULT_SEPARATOR;
this.executor = executor;
this.log = log;
this.prefix = prefix;
}
/**
* Buffer a deliver call and flush after the window expires.
* Accept one deliver call.
*
* @param payload - The deliver payload.
* @param info - Deliver metadata (kind, etc.).
* @param actualDeliver - The real deliver function to call with merged content.
* @param peerId - Peer identifier for per-conversation debouncing.
* - Payloads with media → flush buffered text first, then execute.
* - Empty-text payloads → pass through directly (no buffering).
* - Non-empty text payloads → buffer and (re-)arm the timers.
*/
async deliver(
payload: DeliverPayload,
info: DeliverInfo,
actualDeliver: DeliverFn,
peerId = "default",
): Promise<void> {
// Pass through immediately when debouncing is disabled.
if (!this.enabled) {
return actualDeliver(payload, info);
async deliver(payload: DeliverPayload, info: DeliverInfo): Promise<void> {
if (this.disposed) {
return;
}
// Media payloads flush any buffered text first, then send immediately.
const hasMedia = (payload.mediaUrls && payload.mediaUrls.length > 0) || !!payload.mediaUrl;
const hasMedia = Boolean(
(payload.mediaUrls && payload.mediaUrls.length > 0) || payload.mediaUrl,
);
if (hasMedia) {
await this.flush(peerId, actualDeliver, info);
return actualDeliver(payload, info);
this.log?.info(
`${this.prefix} Media deliver detected, flushing ${this.bufferedTexts.length} buffered text(s) first`,
);
await this.flush();
await this.executor(payload, info);
return;
}
const text = (payload.text ?? "").trim();
if (!text) {
await this.executor(payload, info);
return;
}
const existing = this.pending.get(peerId);
if (existing) {
// Extend the buffer with the new text.
existing.texts.push(text);
// Reset the timer.
clearTimeout(existing.timer);
existing.timer = setTimeout(() => {
this.flush(peerId, actualDeliver, info).catch(() => {});
}, this.windowMs);
// The caller awaits the same promise as the first buffered call.
return new Promise<void>((resolve) => {
const origResolve = existing.resolve;
existing.resolve = () => {
origResolve();
resolve();
};
// Buffer the text and track the latest payload/info so `flush()` can
// forward non-text fields to the executor.
this.bufferedTexts.push(text);
this.lastInfo = info;
this.lastPayload = payload;
this.log?.info(
`${this.prefix} Buffered text #${this.bufferedTexts.length} (${text.length} chars), window=${this.windowMs}ms`,
);
// Reset the sliding-window timer so bursty input keeps extending the wait.
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.flush().catch((err) => {
this.log?.error(`${this.prefix} Flush error (debounce timer): ${String(err)}`);
});
}
}, this.windowMs);
// First message in a new window — start buffering.
return new Promise<void>((resolve) => {
const entry: PendingEntry = {
texts: [text],
mediaUrls: [],
timer: setTimeout(() => {
this.flush(peerId, actualDeliver, info).catch(() => {});
}, this.windowMs),
resolve,
};
this.pending.set(peerId, entry);
});
// Arm the hard-cap timer only on the first buffered text of a burst.
if (this.bufferedTexts.length === 1) {
if (this.maxWaitTimer) {
clearTimeout(this.maxWaitTimer);
}
this.maxWaitTimer = setTimeout(() => {
this.log?.info(`${this.prefix} Max wait (${this.maxWaitMs}ms) reached, force flushing`);
this.flush().catch((err) => {
this.log?.error(`${this.prefix} Flush error (max wait timer): ${String(err)}`);
});
}, this.maxWaitMs);
}
}
/** Flush buffered content for a peer and invoke the actual deliver. */
private async flush(peerId: string, actualDeliver: DeliverFn, info: DeliverInfo): Promise<void> {
const entry = this.pending.get(peerId);
if (!entry) {
/** Merge buffered text into a single executor call. */
async flush(): Promise<void> {
if (this.flushing || this.bufferedTexts.length === 0) {
return;
}
this.flushing = true;
this.pending.delete(peerId);
clearTimeout(entry.timer);
const mergedText = entry.texts.join("\n").trim();
if (mergedText) {
await actualDeliver({ text: mergedText }, info);
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.maxWaitTimer) {
clearTimeout(this.maxWaitTimer);
this.maxWaitTimer = null;
}
entry.resolve();
// Snapshot and reset state BEFORE awaiting so that a concurrent
// `deliver()` (arriving while we're awaiting the executor) sees an
// empty buffer and starts a fresh burst.
const texts = this.bufferedTexts;
const info = this.lastInfo!;
const lastPayload = this.lastPayload!;
this.bufferedTexts = [];
this.lastInfo = null;
this.lastPayload = null;
try {
if (texts.length === 1) {
this.log?.info(`${this.prefix} Flushing single buffered text (${texts[0].length} chars)`);
await this.executor({ ...lastPayload, text: texts[0] }, info);
} else {
const merged = texts.join(this.separator);
this.log?.info(
`${this.prefix} Merged ${texts.length} buffered texts into one (${merged.length} chars)`,
);
await this.executor({ ...lastPayload, text: merged }, info);
}
} finally {
this.flushing = false;
}
}
/** Force-flush all pending entries (e.g. during shutdown). */
async flushAll(actualDeliver: DeliverFn, info: DeliverInfo): Promise<void> {
const peerIds = [...this.pending.keys()];
for (const peerId of peerIds) {
await this.flush(peerId, actualDeliver, info);
/**
* Flush any pending buffer and mark the debouncer as disposed.
* Subsequent `deliver()` calls become no-ops.
*/
async dispose(): Promise<void> {
this.disposed = true;
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.maxWaitTimer) {
clearTimeout(this.maxWaitTimer);
this.maxWaitTimer = null;
}
if (this.bufferedTexts.length > 0) {
// Allow `flush` to run even after `disposed` is set: the state
// reset inside flush makes this safe.
this.flushing = false;
await this.flush();
}
}
/** Whether any text is currently buffered. */
get hasPending(): boolean {
return this.bufferedTexts.length > 0;
}
/** Number of buffered fragments. */
get pendingCount(): number {
return this.bufferedTexts.length;
}
}
// ============ Factory ============
/**
* Create a debouncer instance or `null` when debouncing is disabled.
*
* Convention: when `config.enabled === false` the caller should call the
* executor directly without buffering. Returning `null` (rather than a
* pass-through debouncer) makes the disabled path visible at the call
* site.
*/
export function createDeliverDebouncer(
config: DeliverDebounceConfig | undefined,
executor: DeliverExecutor,
log?: DebouncerLogger,
prefix?: string,
): DeliverDebouncer | null {
if (config?.enabled === false) {
return null;
}
return new DeliverDebouncer(config, executor, log, prefix);
}

View File

@@ -0,0 +1,314 @@
import { describe, expect, it } from "vitest";
import {
buildMergedMessageContext,
buildPendingHistoryContext,
clearPendingHistory,
formatAttachmentTags,
formatMessageContent,
inferAttachmentType,
recordPendingHistoryEntry,
toAttachmentSummaries,
type HistoryEntry,
} from "./history.js";
function makeMap(): Map<string, HistoryEntry[]> {
return new Map();
}
function entry(sender: string, body: string, extras: Partial<HistoryEntry> = {}): HistoryEntry {
return { sender, body, ...extras };
}
describe("engine/group/history", () => {
describe("inferAttachmentType", () => {
it("maps image/* → image", () => {
expect(inferAttachmentType("image/png")).toBe("image");
});
it("maps voice / audio / silk / amr → voice", () => {
expect(inferAttachmentType("voice")).toBe("voice");
expect(inferAttachmentType("audio/mpeg")).toBe("voice");
expect(inferAttachmentType("application/silk")).toBe("voice");
expect(inferAttachmentType("audio/amr")).toBe("voice");
});
it("maps video / application / text → their category", () => {
expect(inferAttachmentType("video/mp4")).toBe("video");
expect(inferAttachmentType("application/pdf")).toBe("file");
expect(inferAttachmentType("text/plain")).toBe("file");
});
it("unknown content types fall back to unknown", () => {
expect(inferAttachmentType()).toBe("unknown");
expect(inferAttachmentType("weird/thing")).toBe("unknown");
});
});
describe("toAttachmentSummaries", () => {
it("returns undefined for empty input", () => {
expect(toAttachmentSummaries()).toBeUndefined();
expect(toAttachmentSummaries([])).toBeUndefined();
});
it("normalizes raw fields", () => {
const result = toAttachmentSummaries([
{
content_type: "image/png",
filename: "a.png",
url: "https://x/a.png",
},
{
content_type: "voice",
asr_refer_text: "hello",
},
]);
expect(result).toEqual([
{ type: "image", filename: "a.png", transcript: undefined, url: "https://x/a.png" },
{ type: "voice", filename: undefined, transcript: "hello", url: undefined },
]);
});
});
describe("formatAttachmentTags", () => {
it("returns empty string for empty input", () => {
expect(formatAttachmentTags()).toBe("");
expect(formatAttachmentTags([])).toBe("");
});
it("renders MEDIA:path for entries with a source", () => {
expect(formatAttachmentTags([{ type: "image", localPath: "/tmp/a.png" }])).toBe(
"MEDIA:/tmp/a.png",
);
expect(formatAttachmentTags([{ type: "image", url: "https://x/b.png" }])).toBe(
"MEDIA:https://x/b.png",
);
});
it("inlines transcript for voice w/ source", () => {
expect(
formatAttachmentTags([{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi" }]),
).toBe('MEDIA:/tmp/v.wav (transcript: "hi")');
});
it("uses descriptive tags when no source is available", () => {
expect(formatAttachmentTags([{ type: "image" }])).toBe("[image]");
expect(formatAttachmentTags([{ type: "image", filename: "a.png" }])).toBe("[image: a.png]");
expect(formatAttachmentTags([{ type: "voice" }])).toBe("[voice]");
expect(formatAttachmentTags([{ type: "voice", transcript: "t" }])).toBe(
'[voice (transcript: "t")]',
);
expect(formatAttachmentTags([{ type: "video" }])).toBe("[video]");
expect(formatAttachmentTags([{ type: "file", filename: "b.pdf" }])).toBe("[file: b.pdf]");
expect(formatAttachmentTags([{ type: "unknown" }])).toBe("[attachment]");
});
it("joins multiple entries with newline", () => {
expect(
formatAttachmentTags([
{ type: "image", localPath: "/tmp/a.png" },
{ type: "voice", transcript: "hi" },
]),
).toBe('MEDIA:/tmp/a.png\n[voice (transcript: "hi")]');
});
});
describe("formatMessageContent", () => {
it("passes content through parseFaceTags (no-op for plain text)", () => {
// parseFaceTags only rewrites the `<faceType=...>` tag form; plain
// text must round-trip unchanged so regressions in the pipeline
// don't silently mangle user input.
expect(formatMessageContent({ content: "hello world" })).toBe("hello world");
});
it("strips mentions only for group chat", () => {
expect(
formatMessageContent({
content: "<@X>hi",
chatType: "group",
mentions: [{ member_openid: "X", is_you: true }],
}),
).toBe("hi");
// Non-group: strip is NOT applied.
expect(
formatMessageContent({
content: "<@X>hi",
chatType: "c2c",
mentions: [{ member_openid: "X", is_you: true }],
}),
).toBe("<@X>hi");
});
it("appends attachment tags", () => {
expect(
formatMessageContent({
content: "see",
attachments: [{ content_type: "image/png", url: "https://x/a.png" }],
}),
).toBe("see MEDIA:https://x/a.png");
});
});
describe("recordPendingHistoryEntry / buildPendingHistoryContext", () => {
it("no-ops when limit is 0", () => {
const map = makeMap();
const entries = recordPendingHistoryEntry({
historyMap: map,
historyKey: "G",
entry: entry("A", "hi"),
limit: 0,
});
expect(entries).toEqual([]);
expect(map.size).toBe(0);
});
it("no-ops when entry is null", () => {
const map = makeMap();
recordPendingHistoryEntry({
historyMap: map,
historyKey: "G",
entry: null,
limit: 10,
});
expect(map.size).toBe(0);
});
it("appends and caps at the limit", () => {
const map = makeMap();
for (let i = 0; i < 5; i++) {
recordPendingHistoryEntry({
historyMap: map,
historyKey: "G",
entry: entry("A", `m${i}`),
limit: 3,
});
}
const entries = map.get("G")!;
expect(entries).toHaveLength(3);
expect(entries.map((e) => e.body)).toEqual(["m2", "m3", "m4"]);
});
it("builds a history-wrapped message when entries exist", () => {
const map = makeMap();
recordPendingHistoryEntry({
historyMap: map,
historyKey: "G",
entry: entry("Alice", "hello"),
limit: 10,
});
const out = buildPendingHistoryContext({
historyMap: map,
historyKey: "G",
limit: 10,
currentMessage: "[Bob] @bot",
formatEntry: (e) => `${e.sender}: ${e.body}`,
});
expect(out).toContain("[Chat messages since your last reply — CONTEXT ONLY]");
expect(out).toContain("Alice: hello");
expect(out).toContain("[CURRENT MESSAGE — reply to this]");
expect(out.endsWith("[Bob] @bot")).toBe(true);
});
it("returns current message unchanged when buffer is empty or disabled", () => {
const map = makeMap();
expect(
buildPendingHistoryContext({
historyMap: map,
historyKey: "G",
limit: 10,
currentMessage: "hi",
formatEntry: () => "x",
}),
).toBe("hi");
expect(
buildPendingHistoryContext({
historyMap: map,
historyKey: "G",
limit: 0,
currentMessage: "hi",
formatEntry: () => "x",
}),
).toBe("hi");
});
});
describe("LRU eviction across groups", () => {
it("evicts oldest keys past the implicit cap (smoke check)", () => {
const map = makeMap();
// Just ensure the cache doesn't explode. The hard cap (1000) is an
// implementation detail; here we confirm the data structure keeps
// re-inserting without error at modest volume.
for (let i = 0; i < 100; i++) {
recordPendingHistoryEntry({
historyMap: map,
historyKey: `G${i}`,
entry: entry("A", `m${i}`),
limit: 1,
});
}
expect(map.size).toBe(100);
});
it("refreshes LRU ordering on subsequent writes to the same key", () => {
const map = makeMap();
recordPendingHistoryEntry({
historyMap: map,
historyKey: "OLD",
entry: entry("A", "1"),
limit: 5,
});
recordPendingHistoryEntry({
historyMap: map,
historyKey: "NEW",
entry: entry("A", "2"),
limit: 5,
});
recordPendingHistoryEntry({
historyMap: map,
historyKey: "OLD",
entry: entry("A", "3"),
limit: 5,
});
// After re-writing OLD, its iteration order should come last.
const keys = [...map.keys()];
expect(keys).toEqual(["NEW", "OLD"]);
});
});
describe("buildMergedMessageContext", () => {
it("returns current message unchanged when no preceding parts", () => {
expect(buildMergedMessageContext({ precedingParts: [], currentMessage: "hi" })).toBe("hi");
});
it("wraps preceding parts with tags", () => {
const out = buildMergedMessageContext({
precedingParts: ["a", "b"],
currentMessage: "c",
});
expect(out).toContain("[Merged earlier messages — CONTEXT ONLY]");
expect(out).toContain("a\nb");
expect(out).toContain("[CURRENT MESSAGE — reply using the context above]");
expect(out.endsWith("c")).toBe(true);
});
});
describe("clearPendingHistory", () => {
it("resets the buffer to empty", () => {
const map = makeMap();
recordPendingHistoryEntry({
historyMap: map,
historyKey: "G",
entry: entry("A", "m"),
limit: 5,
});
clearPendingHistory({ historyMap: map, historyKey: "G", limit: 5 });
expect(map.get("G")).toEqual([]);
});
it("no-ops when disabled", () => {
const map = makeMap();
map.set("G", [entry("A", "m")]);
clearPendingHistory({ historyMap: map, historyKey: "G", limit: 0 });
expect(map.get("G")).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,321 @@
/**
* Group history cache — buffer non-@ messages and inject them as context
* the next time the bot is @-ed in the same group.
*
* Lifecycle (per group):
* 1. `recordPendingHistoryEntry` — called for every non-@ message that
* should be remembered (the gate returns `skip_no_mention` /
* `drop_other_mention`).
* 2. `buildPendingHistoryContext` — called when the bot IS @-ed; wraps
* the cached entries in context tags and prepends them to the
* current user message.
* 3. `clearPendingHistory` — called after the reply has been attempted
* (success, timeout, or error) so the next @ starts fresh.
*
* The cache itself is a simple `Map<groupOpenid, HistoryEntry[]>` with an
* LRU eviction policy both on the number of keys and on the per-key
* length. No I/O, no external dependencies — the module is pure and
* portable between the built-in and standalone plugin builds.
*/
import type { RefAttachmentSummary } from "../ref/types.js";
import { formatAttachmentTags } from "../utils/attachment-tags.js";
import { parseFaceTags } from "../utils/text-parsing.js";
import { stripMentionText, type RawMention } from "./mention.js";
// Re-export so existing `from "group/history.js"` imports keep working.
export { formatAttachmentTags } from "../utils/attachment-tags.js";
// ───────────────────────────── Constants ─────────────────────────────
/**
* Tags wrapping history injected on the bot's current turn.
*
* Kept in English so downstream LLMs (which are multilingual but follow
* instructions more reliably in English) parse the block structure
* unambiguously, regardless of the user/bot conversation language.
*/
const HISTORY_CTX_START = "[Chat messages since your last reply — CONTEXT ONLY]";
const HISTORY_CTX_END = "[CURRENT MESSAGE — reply to this]";
/** Tags wrapping merged sub-messages from the queue. */
const MERGED_CTX_START = "[Merged earlier messages — CONTEXT ONLY]";
const MERGED_CTX_END = "[CURRENT MESSAGE — reply using the context above]";
/**
* Upper bound on the number of concurrent group histories the cache will
* retain. Prevents the Map from growing without bound in long-running
* multi-group deployments. LRU-evict the least-recently-touched key once
* this limit is exceeded.
*/
const MAX_HISTORY_KEYS = 1000;
// ───────────────────────────── Types ─────────────────────────────
/**
* Attachment descriptor used inside history entries.
*
* Aligned with `RefAttachmentSummary` so the three places that describe
* attachments (group history cache, ref-index store, and the dynamic
* context block on the current message) all share a single shape.
*/
export type AttachmentSummary = RefAttachmentSummary;
/** Raw attachment fields carried in a QQ event (the union we actually read). */
export interface RawAttachment {
content_type: string;
filename?: string;
/** Pre-computed ASR transcription text provided by QQ's gateway. */
asr_refer_text?: string;
url?: string;
}
/** One cached history entry. */
export interface HistoryEntry {
/** Display label for the sender (e.g. "Nick (OPENID)"). */
sender: string;
/** Message body already stripped / formatted for the AI. */
body: string;
timestamp?: number;
messageId?: string;
/** Rich-media attachments to render inline on @-activation. */
attachments?: AttachmentSummary[];
}
/** Parameters for {@link formatMessageContent}. */
export interface FormatMessageContentParams {
content: string;
/** Message channel — `stripMentionText` only fires for `"group"`. */
chatType?: string;
mentions?: RawMention[];
attachments?: RawAttachment[];
}
// ───────────────────────────── Content formatting ─────────────────────────────
/** Map a raw QQ content-type string onto the normalized attachment type. */
export function inferAttachmentType(contentType?: string): AttachmentSummary["type"] {
const ct = (contentType ?? "").toLowerCase();
if (ct.startsWith("image/")) {
return "image";
}
if (ct === "voice" || ct.startsWith("audio/") || ct.includes("silk") || ct.includes("amr")) {
return "voice";
}
if (ct.startsWith("video/")) {
return "video";
}
if (ct.startsWith("application/") || ct.startsWith("text/")) {
return "file";
}
return "unknown";
}
/**
* Convert raw QQ-event attachments into `AttachmentSummary` entries.
*
* When `localPaths` is provided (from `ProcessedAttachments.attachmentLocalPaths`),
* each summary is enriched with the local file path so that history context
* renders the downloaded path instead of the ephemeral QQ CDN URL.
*
* Returns `undefined` (rather than `[]`) when no attachments are provided
* so that callers can omit the field from their result objects.
*/
export function toAttachmentSummaries(
attachments?: RawAttachment[],
localPaths?: Array<string | null>,
): AttachmentSummary[] | undefined {
if (!attachments?.length) {
return undefined;
}
return attachments.map(
(att, i): AttachmentSummary => ({
type: inferAttachmentType(att.content_type),
filename: att.filename,
transcript: att.asr_refer_text || undefined,
localPath: localPaths?.[i] || undefined,
url: att.url || undefined,
}),
);
}
/**
* Format one sub-message: emoji parsing → mention cleanup → attachment tags.
*
* Used for the merged-message path where several queued messages are
* rendered together. `parseFaceTags` and `stripMentionText` are imported
* directly — both are pure utilities inside the same engine and do not
* warrant DI overhead.
*/
export function formatMessageContent(params: FormatMessageContentParams): string {
let msgContent = parseFaceTags(params.content);
if (params.chatType === "group" && params.mentions?.length) {
msgContent = stripMentionText(msgContent, params.mentions);
}
if (params.attachments?.length) {
const attachmentDesc = formatAttachmentTags(toAttachmentSummaries(params.attachments));
if (attachmentDesc) {
msgContent = `${msgContent} ${attachmentDesc}`;
}
}
return msgContent;
}
// ───────────────────────────── Attachment tags ─────────────────────────────
//
// `formatAttachmentTags` lives in `utils/attachment-tags.ts` (the single
// source of truth shared with the ref-index renderer). It is re-exported
// from the top of this file so existing `from "group/history.js"` imports
// continue to work.
// ───────────────────────────── Internal LRU helpers ─────────────────────────────
/**
* LRU-evict the least-recently-inserted keys so the map never exceeds
* `maxKeys`. Since `Map` iteration order is insertion order, removing
* from the front gives us an LRU by insertion point.
*/
function evictOldHistoryKeys<T>(
historyMap: Map<string, T[]>,
maxKeys: number = MAX_HISTORY_KEYS,
): void {
if (historyMap.size <= maxKeys) {
return;
}
const keysToDelete = historyMap.size - maxKeys;
const iterator = historyMap.keys();
for (let i = 0; i < keysToDelete; i++) {
const key = iterator.next().value;
if (key !== undefined) {
historyMap.delete(key);
}
}
}
/**
* Append one entry to a group's history. When the group's buffer exceeds
* `limit`, the oldest entry is shifted off the front. The group's key is
* re-inserted into the map so its LRU position is refreshed.
*/
function appendHistoryEntry(params: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
entry: HistoryEntry;
limit: number;
}): HistoryEntry[] {
const { historyMap, historyKey, entry, limit } = params;
if (limit <= 0) {
return [];
}
const history = historyMap.get(historyKey) ?? [];
history.push(entry);
while (history.length > limit) {
history.shift();
}
// Refresh insertion order so this key becomes the most recent.
if (historyMap.has(historyKey)) {
historyMap.delete(historyKey);
}
historyMap.set(historyKey, history);
evictOldHistoryKeys(historyMap);
return history;
}
// ───────────────────────────── Public API ─────────────────────────────
/**
* Record a non-@ message so it can be replayed on the next @-activation.
*
* No-op when `limit <= 0` (history disabled) or when `entry` is missing.
* Returns the updated history list for the group.
*/
export function recordPendingHistoryEntry(params: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
entry?: HistoryEntry | null;
limit: number;
}): HistoryEntry[] {
if (!params.entry || params.limit <= 0) {
return [];
}
return appendHistoryEntry({
historyMap: params.historyMap,
historyKey: params.historyKey,
entry: params.entry,
limit: params.limit,
});
}
/**
* Build the full user-message string when the bot is @-ed, prefixing the
* buffered non-@ chatter for context.
*
* Returns `currentMessage` unchanged when no history exists, when the
* limit is zero, or when the buffer is empty.
*/
export function buildPendingHistoryContext(params: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
limit: number;
currentMessage: string;
formatEntry: (entry: HistoryEntry) => string;
lineBreak?: string;
}): string {
if (params.limit <= 0) {
return params.currentMessage;
}
const entries = params.historyMap.get(params.historyKey) ?? [];
if (entries.length === 0) {
return params.currentMessage;
}
const lineBreak = params.lineBreak ?? "\n";
const historyText = entries.map(params.formatEntry).join(lineBreak);
return [HISTORY_CTX_START, historyText, "", HISTORY_CTX_END, params.currentMessage].join(
lineBreak,
);
}
/**
* Wrap a batch of merged messages with begin/end tags and append the
* current user turn at the bottom.
*
* When `precedingParts` is empty, `currentMessage` is returned unchanged.
*/
export function buildMergedMessageContext(params: {
precedingParts: string[];
currentMessage: string;
lineBreak?: string;
}): string {
const { precedingParts, currentMessage } = params;
if (precedingParts.length === 0) {
return currentMessage;
}
const lineBreak = params.lineBreak ?? "\n";
return [MERGED_CTX_START, precedingParts.join(lineBreak), MERGED_CTX_END, currentMessage].join(
lineBreak,
);
}
/**
* Clear a group's pending history after a reply has been attempted.
*
* No-op when the feature is disabled (`limit <= 0`).
*/
export function clearPendingHistory(params: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
limit: number;
}): void {
if (params.limit <= 0) {
return;
}
params.historyMap.set(params.historyKey, []);
}

View File

@@ -0,0 +1,62 @@
/**
* Public surface of the group sub-package.
*
* Grouped here so consumers (bridge-layer wiring, tests, future
* standalone bootstrap) can reach every group primitive through a
* single import path without caring about the internal layout.
*/
// Gating — three-layer decision
export {
resolveGroupMessageGate,
type GroupMessageGateAction,
type GroupMessageGateInput,
type GroupMessageGateResult,
} from "./message-gating.js";
// History buffer — non-@ chatter cache + context formatting
export {
buildMergedMessageContext,
buildPendingHistoryContext,
clearPendingHistory,
formatAttachmentTags,
formatMessageContent,
inferAttachmentType,
recordPendingHistoryEntry,
toAttachmentSummaries,
type AttachmentSummary,
type FormatMessageContentParams,
type HistoryEntry,
type RawAttachment,
} from "./history.js";
// Mention detection / normalization + implicit-mention predicate
export {
detectWasMentioned,
hasAnyMention,
resolveImplicitMention,
stripMentionText,
type DetectWasMentionedInput,
type HasAnyMentionInput,
type RawMention,
} from "./mention.js";
// Activation mode (session-store override + cfg fallback)
export {
createNodeSessionStoreReader,
resolveGroupActivation,
resolveSessionStorePath,
type GroupActivationMode,
type SessionStoreReader,
} from "./activation.js";
// Deliver debouncer — buffers rapid outbound text fragments
export {
createDeliverDebouncer,
DeliverDebouncer,
type DebouncerLogger,
type DeliverDebounceConfig,
type DeliverExecutor,
type DeliverInfo,
type DeliverPayload,
} from "./deliver-debounce.js";

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import {
detectWasMentioned,
hasAnyMention,
resolveImplicitMention,
stripMentionText,
} from "./mention.js";
describe("engine/group/mention", () => {
describe("detectWasMentioned", () => {
it("returns true when mentions contains is_you", () => {
expect(detectWasMentioned({ mentions: [{ is_you: true }] })).toBe(true);
});
it("returns true for GROUP_AT_MESSAGE_CREATE even without mentions", () => {
expect(detectWasMentioned({ eventType: "GROUP_AT_MESSAGE_CREATE" })).toBe(true);
});
it("matches by mentionPatterns regex", () => {
expect(
detectWasMentioned({ content: "@xiaoke help me", mentionPatterns: ["^@xiaoke"] }),
).toBe(true);
});
it("returns false when no signal matches", () => {
expect(
detectWasMentioned({
eventType: "GROUP_MESSAGE_CREATE",
mentions: [{ member_openid: "USER1" }],
content: "hello",
mentionPatterns: ["^@bot"],
}),
).toBe(false);
});
it("ignores invalid regex patterns gracefully", () => {
// "[" is an invalid regex; should not throw.
expect(detectWasMentioned({ content: "hi", mentionPatterns: ["[", "@bot"] })).toBe(false);
});
it("matches case-insensitively", () => {
expect(detectWasMentioned({ content: "Hello @Bot", mentionPatterns: ["@bot"] })).toBe(true);
});
it("skips empty patterns", () => {
expect(detectWasMentioned({ content: "hi", mentionPatterns: ["", " "] })).toBe(false);
});
it("returns false when everything is empty", () => {
expect(detectWasMentioned({})).toBe(false);
});
});
describe("hasAnyMention", () => {
it("detects mentions array", () => {
expect(hasAnyMention({ mentions: [{ member_openid: "X" }] })).toBe(true);
});
it("detects mention tags in text", () => {
expect(hasAnyMention({ content: "hi <@ABC123>" })).toBe(true);
expect(hasAnyMention({ content: "hi <@!ABC123>" })).toBe(true);
});
it("returns false when nothing mentioned", () => {
expect(hasAnyMention({ content: "just a normal message" })).toBe(false);
expect(hasAnyMention({})).toBe(false);
});
});
describe("stripMentionText", () => {
it("removes self-mention tag", () => {
expect(stripMentionText("<@BOTID> hello", [{ member_openid: "BOTID", is_you: true }])).toBe(
"hello",
);
});
it("replaces other-user tag with @nickname", () => {
expect(stripMentionText("hi <@USER1>", [{ member_openid: "USER1", nickname: "Alice" }])).toBe(
"hi @Alice",
);
});
it("falls back to username when nickname missing", () => {
expect(stripMentionText("hi <@USER1>", [{ member_openid: "USER1", username: "alice" }])).toBe(
"hi @alice",
);
});
it("leaves unknown mentions untouched", () => {
// No display name, so the tag cannot be prettified — keep raw.
expect(stripMentionText("hi <@USER1>", [{ member_openid: "USER1" }])).toBe("hi <@USER1>");
});
it("handles <@!openid> variant", () => {
expect(stripMentionText("hi <@!USER1>", [{ member_openid: "USER1", nickname: "A" }])).toBe(
"hi @A",
);
});
it("returns the original text when no mentions array is provided", () => {
expect(stripMentionText("hi <@X>", [])).toBe("hi <@X>");
expect(stripMentionText("hi <@X>")).toBe("hi <@X>");
});
it("escapes regex meta-characters in openid", () => {
// Defensive: even if QQ ever sends openids with unusual characters,
// the function should not explode nor produce a bogus regex.
expect(stripMentionText("see <@A.B+C>", [{ member_openid: "A.B+C", nickname: "X" }])).toBe(
"see @X",
);
});
});
describe("resolveImplicitMention", () => {
it("returns false when refMsgIdx is missing", () => {
expect(resolveImplicitMention({ getRefEntry: () => null })).toBe(false);
});
it("returns true when the referenced entry is a bot message", () => {
expect(
resolveImplicitMention({
refMsgIdx: "R1",
getRefEntry: (id) => (id === "R1" ? { isBot: true } : null),
}),
).toBe(true);
});
it("returns false when ref entry exists but is not a bot", () => {
expect(
resolveImplicitMention({
refMsgIdx: "R1",
getRefEntry: () => ({ isBot: false }),
}),
).toBe(false);
});
it("returns false when ref entry is missing", () => {
expect(resolveImplicitMention({ refMsgIdx: "R1", getRefEntry: () => null })).toBe(false);
});
});
});

View File

@@ -0,0 +1,197 @@
/**
* QQBot group @mention detection and text normalization.
*
* Pure functions extracted from the standalone build (`openclaw-qqbot/src/
* channel.ts::detectWasMentioned` / `stripMentionText`) plus the helper
* `hasAnyMention` that previously lived inline in `gateway.ts` and the
* `resolveImplicitMention` predicate that decides whether a quoted-reply
* should count as an implicit @bot.
*
* Keeping these helpers together makes it easier to test the group gating
* pipeline and lets both the built-in and standalone builds share a
* single mention-detection implementation.
*/
// ============ Types ============
/**
* Raw mention entry shape used across QQ Bot group events.
*
* QQ's `mentions` array uses slightly different field names on different
* event types (the bot's self-mention comes as `is_you: true`; user IDs
* can appear in any of `member_openid` / `id` / `user_openid`). This type
* captures the union so callers don't have to worry about which variant.
*/
export interface RawMention {
/** Whether this mention targets the bot itself. */
is_you?: boolean;
/** Whether the mention target is another bot. */
bot?: boolean;
/** Member openid in group chats. */
member_openid?: string;
/** Event-level id (guild context). */
id?: string;
/** User openid (C2C context). */
user_openid?: string;
/** Display name. */
nickname?: string;
/** Alternative display name. */
username?: string;
/** @all / @single scope (QQ guild events). */
scope?: "all" | "single";
}
/** Input for {@link detectWasMentioned}. */
export interface DetectWasMentionedInput {
/**
* Raw event type. `"GROUP_AT_MESSAGE_CREATE"` unambiguously identifies
* that the bot was @-ed, even when the mentions array is empty.
*/
eventType?: string;
mentions?: RawMention[];
/** Raw message content — used as a regex fallback via `mentionPatterns`. */
content?: string;
/**
* Regex patterns matched against `content` when neither `mentions.is_you`
* nor `eventType` prove a bot mention. Invalid patterns are ignored.
*/
mentionPatterns?: string[];
}
/** Input for {@link hasAnyMention}. */
export interface HasAnyMentionInput {
mentions?: RawMention[];
content?: string;
}
// ============ Constants ============
/** Regex detecting `<@openid>` / `<@!openid>` mention tags in raw content. */
const MENTION_TAG_RE = /<@!?\w+>/;
// ============ Public API ============
/**
* Detect whether the inbound message explicitly targets the bot.
*
* Priority order:
* 1. `mentions[].is_you === true` (most reliable)
* 2. `eventType === "GROUP_AT_MESSAGE_CREATE"` (QQ-level @bot event)
* 3. regex match on any of `mentionPatterns` (fallback, e.g. "@bot-name")
*
* Returns `false` for direct messages or when no signal is found.
*/
export function detectWasMentioned(input: DetectWasMentionedInput): boolean {
const { eventType, mentions, content, mentionPatterns } = input;
if (mentions?.some((m) => m.is_you)) {
return true;
}
if (eventType === "GROUP_AT_MESSAGE_CREATE") {
return true;
}
if (mentionPatterns?.length && content) {
for (const pattern of mentionPatterns) {
if (!pattern) {
continue;
}
try {
if (new RegExp(pattern, "i").test(content)) {
return true;
}
} catch {
// Invalid regex — skip silently; bad patterns must not crash the pipeline.
}
}
}
return false;
}
/**
* Report whether the message contains **any** @mention (not necessarily @bot).
*
* Used by the gating layer to decide whether to bypass mention requirements
* for control commands. A control command like `/stop` that also @-s another
* user should NOT bypass the mention gate — the `@other-user` prefix is a
* strong signal that the command wasn't addressed to the bot.
*/
export function hasAnyMention(input: HasAnyMentionInput): boolean {
if (input.mentions && input.mentions.length > 0) {
return true;
}
if (input.content && MENTION_TAG_RE.test(input.content)) {
return true;
}
return false;
}
/**
* Clean up `<@openid>` mention tags in raw QQ group content.
*
* - For the bot's own mention (`is_you === true`): the tag is removed
* outright so prompts don't contain visible `<@BOTID>` garbage.
* - For other mentioned users: the tag is replaced with `@nickname` (or
* `@username`) for readability. Entries without a display name are left
* as-is (rare in practice).
*
* Returns the original text unchanged when `text` or `mentions` is empty.
*/
export function stripMentionText(text: string, mentions?: RawMention[]): string {
if (!text || !mentions?.length) {
return text;
}
let cleaned = text;
for (const m of mentions) {
const openid = m.member_openid ?? m.id ?? m.user_openid;
if (!openid) {
continue;
}
// RegExp: match both `<@openid>` and `<@!openid>` variants.
const tagRe = new RegExp(`<@!?${escapeRegex(openid)}>`, "g");
if (m.is_you) {
cleaned = cleaned.replace(tagRe, "").trim();
} else {
const displayName = m.nickname ?? m.username;
if (displayName) {
cleaned = cleaned.replace(tagRe, `@${displayName}`);
}
}
}
return cleaned;
}
// ============ Internal helpers ============
/** Escape characters that carry regex meaning. */
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// ============ Implicit mention (quoted bot message) ============
/**
* Decide whether a quoted-reply should count as an implicit @bot.
*
* When the user quotes an earlier bot message, we treat the new message
* as if it @-ed the bot, even without a literal mention. This lives in
* the mention module (rather than with activation) because semantically
* it answers the same question as `detectWasMentioned`:
* "was the bot addressed by this message?".
*
* The `getRefEntry` callback is injected so this function does not
* depend on the ref-index store implementation — any lookup that
* returns `{ isBot?: boolean }` works.
*/
export function resolveImplicitMention(params: {
refMsgIdx?: string;
getRefEntry: (idx: string) => { isBot?: boolean } | null;
}): boolean {
if (!params.refMsgIdx) {
return false;
}
const refEntry = params.getRefEntry(params.refMsgIdx);
return refEntry?.isBot === true;
}

View File

@@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import {
resolveGroupMessageGate,
type GroupMessageGateInput,
type GroupMessageGateResult,
} from "./message-gating.js";
// Compose a full input so each test can override just the interesting axis.
function input(overrides: Partial<GroupMessageGateInput>): GroupMessageGateInput {
return {
ignoreOtherMentions: false,
hasAnyMention: false,
wasMentioned: false,
implicitMention: false,
allowTextCommands: true,
isControlCommand: false,
commandAuthorized: false,
requireMention: true,
canDetectMention: true,
...overrides,
};
}
function expectAction(
result: GroupMessageGateResult,
action: GroupMessageGateResult["action"],
): void {
expect(result.action).toBe(action);
}
describe("engine/group/message-gating", () => {
describe("Layer 1: ignoreOtherMentions", () => {
it("drops messages that @other users when enabled", () => {
const result = resolveGroupMessageGate(
input({ ignoreOtherMentions: true, hasAnyMention: true }),
);
expectAction(result, "drop_other_mention");
});
it("does NOT drop when the bot itself was @-ed", () => {
const result = resolveGroupMessageGate(
input({ ignoreOtherMentions: true, hasAnyMention: true, wasMentioned: true }),
);
expectAction(result, "pass");
});
it("does NOT drop when implicitly mentioned via quote", () => {
const result = resolveGroupMessageGate(
input({ ignoreOtherMentions: true, hasAnyMention: true, implicitMention: true }),
);
expectAction(result, "pass");
});
it("is inactive when ignoreOtherMentions is off", () => {
const result = resolveGroupMessageGate(
input({ ignoreOtherMentions: false, hasAnyMention: true }),
);
// Falls through to mention gate — requireMention on, so skipped.
expectAction(result, "skip_no_mention");
});
});
describe("Layer 2: unauthorized control command", () => {
it("silently blocks an unauthorized /stop", () => {
const result = resolveGroupMessageGate(
input({ isControlCommand: true, commandAuthorized: false }),
);
expectAction(result, "block_unauthorized_command");
});
it("passes through when sender is authorized", () => {
const result = resolveGroupMessageGate(
input({ isControlCommand: true, commandAuthorized: true, wasMentioned: true }),
);
expectAction(result, "pass");
});
it("does not trigger when text commands are disabled", () => {
const result = resolveGroupMessageGate(
input({
allowTextCommands: false,
isControlCommand: true,
commandAuthorized: false,
wasMentioned: true,
}),
);
// allowTextCommands=false skips the block, so the mention gate decides.
expectAction(result, "pass");
});
});
describe("Layer 3: mention gating", () => {
it("requires @bot when requireMention is on", () => {
const result = resolveGroupMessageGate(input({ requireMention: true }));
expectAction(result, "skip_no_mention");
expect(result.effectiveWasMentioned).toBe(false);
});
it("passes through when explicitly mentioned", () => {
const result = resolveGroupMessageGate(input({ requireMention: true, wasMentioned: true }));
expectAction(result, "pass");
expect(result.effectiveWasMentioned).toBe(true);
});
it("passes through on implicit mention", () => {
const result = resolveGroupMessageGate(
input({ requireMention: true, implicitMention: true }),
);
expectAction(result, "pass");
expect(result.effectiveWasMentioned).toBe(true);
});
it("passes through when requireMention is off", () => {
const result = resolveGroupMessageGate(input({ requireMention: false }));
expectAction(result, "pass");
});
it("passes through when mention cannot be detected (DMs)", () => {
const result = resolveGroupMessageGate(
input({ requireMention: true, canDetectMention: false }),
);
expectAction(result, "pass");
});
});
describe("command bypass", () => {
it("bypasses mention gate for an authorized control command", () => {
const result = resolveGroupMessageGate(
input({
requireMention: true,
isControlCommand: true,
commandAuthorized: true,
allowTextCommands: true,
}),
);
expectAction(result, "pass");
expect(result.shouldBypassMention).toBe(true);
expect(result.effectiveWasMentioned).toBe(true);
});
it("does NOT bypass when the command @-s another user", () => {
const result = resolveGroupMessageGate(
input({
requireMention: true,
isControlCommand: true,
commandAuthorized: true,
hasAnyMention: true,
}),
);
expectAction(result, "skip_no_mention");
expect(result.shouldBypassMention).toBe(false);
});
it("is a no-op when requireMention is off", () => {
const result = resolveGroupMessageGate(
input({
requireMention: false,
isControlCommand: true,
commandAuthorized: true,
}),
);
expectAction(result, "pass");
// requireMention=false means bypass is unnecessary (condition 1 fails).
expect(result.shouldBypassMention).toBe(false);
});
});
describe("priority ordering", () => {
it("layer 1 wins over layer 2 (ignoreOtherMentions before block)", () => {
const result = resolveGroupMessageGate(
input({
ignoreOtherMentions: true,
hasAnyMention: true,
isControlCommand: true,
commandAuthorized: false,
}),
);
expectAction(result, "drop_other_mention");
});
it("layer 2 wins over layer 3 (unauthorized command before skip)", () => {
const result = resolveGroupMessageGate(
input({ requireMention: true, isControlCommand: true, commandAuthorized: false }),
);
expectAction(result, "block_unauthorized_command");
});
});
});

View File

@@ -1,72 +1,216 @@
/**
* Group message gating — three-layer access control for group messages.
* Group message gate — unified entry point for group inbound gating.
*
* 1. `ignoreOtherMentions` — skip messages that @other bots, not this one.
* 2. `shouldBlock` — enforce allowFrom whitelist at the group level.
* 3. `mentionGating` — require explicit @bot mention in group chats.
* Collapses three orthogonal rules that previously lived in ad-hoc spots
* of the standalone gateway into a single pure function. Callers pass in
* the message's mention state plus the resolved configuration, and get
* back a structured action telling them how to handle the message.
*
* All functions are **pure** (no side effects, no I/O), making them easy to
* test and safe to share between the built-in and standalone versions.
* Evaluation order (short-circuit at the first match):
* 1. `ignoreOtherMentions` — message @-s someone else but not the bot
* → `drop_other_mention` (record to history,
* then drop). Implicit mentions (e.g. quoting
* a bot reply) still count as @bot.
* 2. `block_unauthorized_command` — sender is not allowed to run control
* commands (text starts with `/xxx`)
* → silently drop.
* 3. `mention gating` — when `requireMention` is on, non-@bot messages
* are `skip_no_mention`'d (still buffered to
* history). Authorized control commands can
* **bypass** the gate as long as the message does
* not @anyone else at the same time.
* 4. Otherwise → `pass` (the message will reach the AI pipeline).
*
* All inputs are plain data; there is no I/O and no mutation, so the
* function is safe to share between the built-in and standalone builds.
*/
/** Result of the group message gate evaluation. */
export interface GateResult {
/** Whether the message should be blocked (i.e. not processed). */
blocked: boolean;
/** Reason for blocking (for logging). */
reason?: string;
/** Whether the sender is authorized for slash commands. */
commandAuthorized: boolean;
}
// ────────────────────── Types ──────────────────────
/** Configuration relevant to group message gating. */
export interface GroupGateConfig {
/** Normalized allowFrom list (uppercase, `qqbot:` prefix stripped). */
normalizedAllowFrom: string[];
/**
* Structured action returned by {@link resolveGroupMessageGate}.
*
* - `drop_other_mention` — message @-s another user but not the bot;
* record to the group history cache and
* drop without hitting the AI.
* - `block_unauthorized_command` — silently refuse a control command from
* an unauthorized sender (no history
* write, no AI call).
* - `skip_no_mention` — `requireMention` is on and the message
* does not @bot; record to history but
* skip AI dispatch.
* - `pass` — forward the message to the AI pipeline.
*/
export type GroupMessageGateAction =
| "drop_other_mention"
| "block_unauthorized_command"
| "skip_no_mention"
| "pass";
/** Gate evaluation result. */
export interface GroupMessageGateResult {
/** The action the caller should take. */
action: GroupMessageGateAction;
/**
* Whether to ignore messages that mention other bots.
* When true, messages containing @mentions for other bot IDs are silently dropped.
* Effective mention state after combining raw mention detection with
* implicit / bypass signals. Only meaningful when `action === "pass"`.
*/
ignoreOtherMentions?: boolean;
effectiveWasMentioned: boolean;
/**
* Whether the control-command bypass was applied to flip a missing
* mention into `pass`. Only meaningful when `action === "pass"`.
*/
shouldBypassMention: boolean;
}
/** Input for {@link resolveGroupMessageGate}. */
export interface GroupMessageGateInput {
// ---- ignoreOtherMentions layer ----
/** Per-group config: drop messages that @someone other than the bot. */
ignoreOtherMentions: boolean;
/** Whether the message contains *any* @mention (including @other-user). */
hasAnyMention: boolean;
/**
* Whether the QQ event explicitly @-s the bot (via `mentions[].is_you`
* or `GROUP_AT_MESSAGE_CREATE`).
*/
wasMentioned: boolean;
/**
* Implicit mention — e.g. the message quotes an earlier bot reply.
* Treated as equivalent to an explicit @bot for gating purposes.
*/
implicitMention: boolean;
// ---- Control-command layer ----
/** Whether text-based control commands are enabled globally. */
allowTextCommands: boolean;
/** Whether the current message is recognised as a control command. */
isControlCommand: boolean;
/** Whether the sender is authorised to run control commands. */
commandAuthorized: boolean;
// ---- Mention gating layer ----
/** Per-group config: `requireMention` — bot only replies when @-ed. */
requireMention: boolean;
/**
* Whether the channel can reliably detect @-mentions at all. In C2C chat
* this should be `false` (DMs don't have mentions); in group chat it
* should be `true`.
*/
canDetectMention: boolean;
}
// ────────────────────── Core logic ──────────────────────
/**
* Base mention-gate evaluation.
*
* `effectiveWasMentioned = wasMentioned || implicitMention || bypass`.
* `shouldSkip = requireMention && canDetectMention && !effectiveWasMentioned`.
*/
function resolveMentionGating(input: {
requireMention: boolean;
canDetectMention: boolean;
wasMentioned: boolean;
implicitMention: boolean;
shouldBypassMention: boolean;
}): { effectiveWasMentioned: boolean; shouldSkip: boolean } {
const effectiveWasMentioned =
input.wasMentioned || input.implicitMention || input.shouldBypassMention;
const shouldSkip = input.requireMention && input.canDetectMention && !effectiveWasMentioned;
return { effectiveWasMentioned, shouldSkip };
}
/**
* Evaluate the group message gate for one inbound message.
* Decide whether an authorized control command may bypass the mention gate.
*
* @param senderId - The sender's openid (raw, not normalized).
* @param config - Group gating configuration.
* @returns The gate evaluation result.
* All of the following must hold:
* 1. `requireMention` is on (gate is active)
* 2. The bot was NOT directly @-ed (otherwise no bypass is needed)
* 3. The message does NOT @anyone (a `@other-user /stop` should NOT pass
* — the command wasn't aimed at us)
* 4. Text commands are enabled
* 5. Sender is authorised
* 6. The content is a valid control command
*/
export function resolveGroupMessageGate(senderId: string, config: GroupGateConfig): GateResult {
const { normalizedAllowFrom } = config;
function resolveCommandBypass(input: {
requireMention: boolean;
wasMentioned: boolean;
hasAnyMention: boolean;
allowTextCommands: boolean;
commandAuthorized: boolean;
isControlCommand: boolean;
}): boolean {
return (
input.requireMention &&
!input.wasMentioned &&
!input.hasAnyMention &&
input.allowTextCommands &&
input.commandAuthorized &&
input.isControlCommand
);
}
// Normalize the sender ID for comparison.
const normalizedSenderId = senderId.replace(/^qqbot:/i, "").toUpperCase();
// ────────────────────── Unified gate ──────────────────────
// Open gate: empty allowFrom or wildcard means everyone is allowed.
const allowAll = normalizedAllowFrom.length === 0 || normalizedAllowFrom.some((e) => e === "*");
/**
* Evaluate the group-message gate.
*
* See the module-level docs for the ordering and semantics.
*/
export function resolveGroupMessageGate(input: GroupMessageGateInput): GroupMessageGateResult {
// ---- Layer 1: ignoreOtherMentions ----
if (
input.ignoreOtherMentions &&
input.hasAnyMention &&
!input.wasMentioned &&
!input.implicitMention
) {
return {
action: "drop_other_mention",
effectiveWasMentioned: false,
shouldBypassMention: false,
};
}
const commandAuthorized = allowAll || normalizedAllowFrom.includes(normalizedSenderId);
// ---- Layer 2: unauthorized control command ----
if (input.allowTextCommands && input.isControlCommand && !input.commandAuthorized) {
return {
action: "block_unauthorized_command",
effectiveWasMentioned: false,
shouldBypassMention: false,
};
}
// ---- Layer 3: mention gate + command bypass ----
const shouldBypassMention = resolveCommandBypass({
requireMention: input.requireMention,
wasMentioned: input.wasMentioned,
hasAnyMention: input.hasAnyMention,
allowTextCommands: input.allowTextCommands,
commandAuthorized: input.commandAuthorized,
isControlCommand: input.isControlCommand,
});
const mentionGate = resolveMentionGating({
requireMention: input.requireMention,
canDetectMention: input.canDetectMention,
wasMentioned: input.wasMentioned,
implicitMention: input.implicitMention,
shouldBypassMention,
});
if (mentionGate.shouldSkip) {
return {
action: "skip_no_mention",
effectiveWasMentioned: mentionGate.effectiveWasMentioned,
shouldBypassMention,
};
}
return {
blocked: false,
commandAuthorized,
action: "pass",
effectiveWasMentioned: mentionGate.effectiveWasMentioned,
shouldBypassMention,
};
}
/**
* Normalize an allowFrom list by stripping `qqbot:` prefixes and uppercasing.
*
* @param allowFrom - Raw allowFrom config entries.
* @returns Normalized entries for comparison.
*/
export function normalizeAllowFrom(allowFrom: Array<string | number> | undefined | null): string[] {
if (!allowFrom) {
return [];
}
return allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^qqbot:/i, ""))
.map((entry) => entry.toUpperCase());
}

View File

@@ -0,0 +1,255 @@
/**
* Unified media-source abstraction for the QQ Bot upload pipeline.
*
* All rich-media entry points (sender.ts#sendMedia, outbound.ts#send*,
* reply-dispatcher.ts#handle*Payload) funnel through {@link normalizeSource}
* before reaching the low-level {@link MediaApi}.
*
* ## Why four branches?
*
* - `url` — remote http(s) URL that the QQ server can fetch directly.
* - `base64` — in-memory base64 string (typically from a `data:` URL).
* - `localPath` — on-disk file; kept as a path so a future chunked-upload
* implementation can stream it via `fs.createReadStream` without the 4/3×
* base64 memory overhead.
* - `buffer` — in-memory raw bytes (e.g. TTS output, downloaded url-fallback).
*
* ## Security baseline (localPath branch)
*
* `openLocalFile` is the single canonical implementation of "safely open a
* local file for upload" across the plugin. It merges the previously
* inconsistent strategies from `reply-dispatcher.ts` (O_NOFOLLOW + size check)
* and `outbound.ts` (realpath + root containment). Callers are still
* responsible for *root-whitelist* validation (via
* `resolveQQBotPayloadLocalFilePath` / `resolveOutboundMediaPath`) before
* passing the path in; this function enforces *file-level* safety only.
*
* Chunked upload is not implemented in this PR, but the contract here already
* returns `size` metadata so `sendMediaInternal` can route by size without
* reading the whole file first.
*/
import * as fs from "node:fs";
import { MAX_UPLOAD_SIZE, formatFileSize, getMimeType } from "../utils/file-utils.js";
// ============ Types ============
/**
* Fully normalized media source. Downstream uploaders switch on `kind`.
*
* - `url`: remote URL — upload via `file_data=null; url=...`.
* - `base64`: already-encoded base64 — upload via `file_data=...`.
* - `localPath`: on-disk file — one-shot path reads it into a buffer;
* chunked path (future) streams it via `fs.createReadStream`.
* - `buffer`: raw bytes in memory — same as above minus disk I/O.
*/
export type MediaSource =
| { kind: "url"; url: string }
| { kind: "base64"; data: string; mime?: string }
| { kind: "localPath"; path: string; size: number; mime?: string }
| { kind: "buffer"; buffer: Buffer; fileName?: string; mime?: string };
/**
* Untyped media source accepted from callers.
*
* `url` may be either a remote `http(s)://...` URL or a `data:<mime>;base64,...`
* data URL — {@link normalizeSource} transparently resolves the latter to a
* `base64` branch.
*/
export type RawMediaSource =
| { url: string }
| { base64: string; mime?: string }
| { localPath: string }
| { buffer: Buffer; fileName?: string; mime?: string };
// ============ data: URL ============
const DATA_URL_RE = /^data:([^;,]+);base64,(.+)$/i;
/**
* Parse a `data:<mime>;base64,<payload>` URL.
*
* Returns `null` when the string is not a data URL or does not declare
* base64 encoding. Non-base64 data URLs are intentionally rejected because
* the QQ upload API ingests raw base64, not arbitrary URL-encoded payloads.
*/
export function tryParseDataUrl(value: string): { mime: string; data: string } | null {
if (!value.startsWith("data:")) {
return null;
}
const m = value.match(DATA_URL_RE);
if (!m) {
return null;
}
return { mime: m[1], data: m[2] };
}
// ============ Local file safe open ============
/**
* Opened handle to a local file, with metadata already validated against
* QQ upload limits.
*
* Callers MUST call {@link OpenedLocalFile.close} (typically in a `finally`).
*/
export interface OpenedLocalFile {
handle: fs.promises.FileHandle;
size: number;
close(): Promise<void>;
}
/**
* Open a local file for upload with defense-in-depth:
*
* 1. `O_NOFOLLOW` refuses to traverse symlinks (prevents post-whitelist
* symlink swaps / TOCTOU attacks).
* 2. `fstat` on the opened descriptor — NOT `fs.stat` on the path —
* so the size check applies to the exact byte stream we will read.
* 3. Rejects non-regular files (sockets / devices / directories).
* 4. Enforces a caller-specified `maxSize` (default {@link MAX_UPLOAD_SIZE})
* at open time, so oversized files fail fast without allocating a
* full buffer. Chunked upload callers should pass a larger ceiling
* (e.g. `CHUNKED_UPLOAD_MAX_SIZE` from `utils/file-utils.js`).
*
* The caller receives the open handle plus validated size and is expected
* to either {@link OpenedLocalFile.handle.readFile} (one-shot path) or
* stream via `fs.createReadStream` (chunked path).
*/
export async function openLocalFile(
filePath: string,
opts: { maxSize?: number } = {},
): Promise<OpenedLocalFile> {
const maxSize = opts.maxSize ?? MAX_UPLOAD_SIZE;
const openFlags =
fs.constants.O_RDONLY | ("O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0);
const handle = await fs.promises.open(filePath, openFlags);
try {
const stat = await handle.stat();
if (!stat.isFile()) {
throw new Error("Path is not a regular file");
}
if (stat.size > maxSize) {
throw new Error(
`File is too large (${formatFileSize(stat.size)}); QQ Bot API limit is ${formatFileSize(maxSize)}`,
);
}
return {
handle,
size: stat.size,
close: () => handle.close(),
};
} catch (err) {
// Close the handle on any validation failure to avoid fd leaks.
await handle.close().catch(() => undefined);
throw err;
}
}
// ============ Normalization ============
/**
* Normalize a {@link RawMediaSource} into a {@link MediaSource}.
*
* - Strings passed via `{ url }` that start with `data:` are auto-resolved
* to a `base64` branch (this is the unified `data:` URL support that was
* previously only implemented in `sendImage`).
* - `localPath` branches open the file with {@link openLocalFile} solely to
* validate size / regular-file / O_NOFOLLOW invariants. The handle is
* closed immediately — actual reading is deferred to the uploader so
* the chunked path can stream without double-reading.
* - `buffer` branches enforce the same ceiling inline.
*
* `maxSize` defaults to {@link MAX_UPLOAD_SIZE} (20MB, one-shot upload limit).
* Callers that dispatch to the chunked uploader should pass a larger ceiling
* (e.g. `CHUNKED_UPLOAD_MAX_SIZE`, or a value derived from
* `getMaxUploadSize(fileType)`).
*
* NOTE: Root-whitelist validation (i.e. "this path must live under the
* allowed QQ Bot media directory") is a caller concern. This function
* assumes the path has already passed such checks.
*/
export async function normalizeSource(
raw: RawMediaSource,
opts: { maxSize?: number } = {},
): Promise<MediaSource> {
const maxSize = opts.maxSize ?? MAX_UPLOAD_SIZE;
if ("url" in raw) {
const parsed = tryParseDataUrl(raw.url);
if (parsed) {
return { kind: "base64", data: parsed.data, mime: parsed.mime };
}
return { kind: "url", url: raw.url };
}
if ("base64" in raw) {
return { kind: "base64", data: raw.base64, mime: raw.mime };
}
if ("localPath" in raw) {
const opened = await openLocalFile(raw.localPath, { maxSize });
try {
return {
kind: "localPath",
path: raw.localPath,
size: opened.size,
mime: getMimeType(raw.localPath),
};
} finally {
await opened.close();
}
}
// buffer branch
if (raw.buffer.length > maxSize) {
throw new Error(
`Buffer is too large (${formatFileSize(raw.buffer.length)}); QQ Bot API limit is ${formatFileSize(maxSize)}`,
);
}
return {
kind: "buffer",
buffer: raw.buffer,
fileName: raw.fileName,
mime: raw.mime,
};
}
// ============ Materialization helpers ============
/**
* Read a {@link MediaSource} into the `{ url?, fileData?, fileName? }` shape
* expected by {@link MediaApi.uploadMedia} today (one-shot upload path).
*
* Chunked upload (future) should bypass this helper and feed the uploader
* directly from the `localPath` / `buffer` branch.
*/
export async function materializeForOneShotUpload(
source: MediaSource,
): Promise<{ url?: string; fileData?: string; fileName?: string }> {
switch (source.kind) {
case "url":
return { url: source.url };
case "base64":
return { fileData: source.data };
case "localPath": {
const opened = await openLocalFile(source.path);
try {
const buf = await opened.handle.readFile();
return { fileData: buf.toString("base64") };
} finally {
await opened.close();
}
}
case "buffer":
return {
fileData: source.buffer.toString("base64"),
fileName: source.fileName,
};
default: {
const _exhaustive: never = source;
throw new Error(
`materializeForOneShotUpload: unsupported MediaSource kind: ${JSON.stringify(_exhaustive)}`,
);
}
}
}

View File

@@ -0,0 +1,38 @@
import type { OutboundAudioPort } from "../adapter/audio.port.js";
let _audioPort: OutboundAudioPort | null = null;
/**
* Initialize the outbound audio adapter. Called once by gateway startup
* via `adapters.outboundAudio`.
*/
export function setOutboundAudioPort(port: OutboundAudioPort): void {
_audioPort = port;
}
function getAudio(): OutboundAudioPort {
if (!_audioPort) {
throw new Error("OutboundAudioPort not initialized — call setOutboundAudioPort first");
}
return _audioPort;
}
export function audioFileToSilkBase64(p: string, f?: string[]): Promise<string | undefined> {
return getAudio().audioFileToSilkBase64(p, f);
}
export function isAudioFile(p: string, m?: string): boolean {
try {
return getAudio().isAudioFile(p, m);
} catch {
return false;
}
}
export function shouldTranscodeVoice(p: string): boolean {
return getAudio().shouldTranscodeVoice(p);
}
export function waitForFile(p: string, ms?: number): Promise<number> {
return getAudio().waitForFile(p, ms);
}

View File

@@ -19,7 +19,7 @@ import { filterInternalMarkers } from "../utils/text-parsing.js";
import { decodeMediaPath } from "./decode-media-path.js";
import {
sendText as senderSendText,
sendImage as senderSendImage,
sendMedia as senderSendMedia,
withTokenRetry,
buildDeliveryTarget,
accountToCreds,
@@ -659,7 +659,13 @@ async function sendMarkdownReply(
const creds = accountToCreds(account);
if (target.type === "c2c" || target.type === "group") {
await withTokenRetry(creds, async () => {
await senderSendImage(target, imageUrl, creds, { msgId: event.messageId });
await senderSendMedia({
target,
creds,
kind: "image",
source: { url: imageUrl },
msgId: event.messageId,
});
});
} else {
log?.debug?.(`${target.type} does not support rich media, skipping Base64 image`);

View File

@@ -0,0 +1,702 @@
/**
* Low-level outbound media sends (photo, voice, video, document) and path resolution.
*/
import fs from "node:fs";
import path from "node:path";
import type { GatewayAccount } from "../types.js";
import { MediaFileType } from "../types.js";
import {
checkFileSize,
downloadFile,
fileExistsAsync,
formatFileSize,
getImageMimeType,
getMaxUploadSize,
readFileAsync,
} from "../utils/file-utils.js";
import { formatErrorMessage } from "../utils/format.js";
import { debugError, debugLog, debugWarn } from "../utils/log.js";
import {
getQQBotDataDir,
getQQBotMediaDir,
isLocalPath as isLocalFilePath,
normalizePath,
resolveQQBotPayloadLocalFilePath,
} from "../utils/platform.js";
import { normalizeLowercaseStringOrEmpty, sanitizeFileName } from "../utils/string-normalize.js";
import { audioFileToSilkBase64, shouldTranscodeVoice, waitForFile } from "./outbound-audio-port.js";
import {
buildDailyLimitExceededResult,
buildFileTooLargeResult,
} from "./outbound-result-helpers.js";
import type { MediaTargetContext, OutboundResult } from "./outbound-types.js";
import {
accountToCreds,
sendMedia as senderSendMedia,
sendText as senderSendText,
UploadDailyLimitExceededError,
type DeliveryTarget,
} from "./sender.js";
import { parseTarget as coreParseTarget } from "./target-parser.js";
/** Parse a qqbot target into a structured delivery target. */
export function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
const timestamp = new Date().toISOString();
debugLog(`[${timestamp}] [qqbot] parseTarget: input=${to}`);
const parsed = coreParseTarget(to);
debugLog(`[${timestamp}] [qqbot] parseTarget: ${parsed.type} target, ID=${parsed.id}`);
return parsed;
}
// Structured media send helpers shared by gateway delivery and sendText.
/** Build a media target from a normal outbound context. */
export function buildMediaTarget(ctx: {
to: string;
account: GatewayAccount;
replyToId?: string | null;
}): MediaTargetContext {
const target = parseTarget(ctx.to);
return {
targetType: target.type,
targetId: target.id,
account: ctx.account,
replyToId: ctx.replyToId ?? undefined,
};
}
/** Return true when public URLs should be passed through directly. */
function shouldDirectUploadUrl(account: GatewayAccount): boolean {
return account.config?.urlDirectUpload !== false;
}
type QQBotMediaKind = "image" | "voice" | "video" | "file" | "media";
const qqBotMediaKindLabel: Record<QQBotMediaKind, string> = {
image: "Image",
voice: "Voice",
video: "Video",
file: "File",
media: "Media",
};
type ResolvedOutboundMediaPath = { ok: true; mediaPath: string } | { ok: false; error: string };
type ResolveOutboundMediaPathOptions = {
allowMissingLocalPath?: boolean;
extraLocalRoots?: string[];
};
type SendDocumentOptions = {
allowQQBotDataDownloads?: boolean;
};
function isHttpOrDataSource(pathValue: string): boolean {
return (
pathValue.startsWith("http://") ||
pathValue.startsWith("https://") ||
pathValue.startsWith("data:")
);
}
function isPathWithinRoot(candidate: string, root: string): boolean {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function resolveMissingPathWithinMediaRoot(normalizedPath: string): string | null {
const resolvedCandidate = path.resolve(normalizedPath);
if (fs.existsSync(resolvedCandidate)) {
return null;
}
const allowedRoot = path.resolve(getQQBotMediaDir());
let canonicalAllowedRoot: string;
try {
canonicalAllowedRoot = fs.realpathSync(allowedRoot);
} catch {
return null;
}
const missingSegments: string[] = [];
let cursor = resolvedCandidate;
while (!fs.existsSync(cursor)) {
const parent = path.dirname(cursor);
if (parent === cursor) {
break;
}
missingSegments.unshift(path.basename(cursor));
cursor = parent;
}
if (!fs.existsSync(cursor)) {
return null;
}
let canonicalCursor: string;
try {
canonicalCursor = fs.realpathSync(cursor);
} catch {
return null;
}
const canonicalCandidate =
missingSegments.length > 0 ? path.join(canonicalCursor, ...missingSegments) : canonicalCursor;
return isPathWithinRoot(canonicalCandidate, canonicalAllowedRoot) ? canonicalCandidate : null;
}
function resolveExistingPathWithinRoots(
normalizedPath: string,
allowedRoots: readonly string[],
): string | null {
const resolvedCandidate = path.resolve(normalizedPath);
if (!fs.existsSync(resolvedCandidate)) {
return null;
}
let canonicalCandidate: string;
try {
canonicalCandidate = fs.realpathSync(resolvedCandidate);
} catch {
return null;
}
for (const root of allowedRoots) {
const resolvedRoot = path.resolve(root);
const canonicalRoot = fs.existsSync(resolvedRoot)
? fs.realpathSync(resolvedRoot)
: resolvedRoot;
if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) {
return canonicalCandidate;
}
}
return null;
}
export function resolveOutboundMediaPath(
rawPath: string,
mediaKind: QQBotMediaKind,
options: ResolveOutboundMediaPathOptions = {},
): ResolvedOutboundMediaPath {
const normalizedPath = normalizePath(rawPath);
if (isHttpOrDataSource(normalizedPath)) {
return { ok: true, mediaPath: normalizedPath };
}
const allowedPath = resolveQQBotPayloadLocalFilePath(normalizedPath);
if (allowedPath) {
return { ok: true, mediaPath: allowedPath };
}
if (options.extraLocalRoots && options.extraLocalRoots.length > 0) {
const extraAllowedPath = resolveExistingPathWithinRoots(
normalizedPath,
options.extraLocalRoots,
);
if (extraAllowedPath) {
return { ok: true, mediaPath: extraAllowedPath };
}
}
if (options.allowMissingLocalPath) {
const allowedMissingPath = resolveMissingPathWithinMediaRoot(normalizedPath);
if (allowedMissingPath) {
return { ok: true, mediaPath: allowedMissingPath };
}
}
debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`);
return {
ok: false,
error: `${qqBotMediaKindLabel[mediaKind]} path must be inside QQ Bot media storage`,
};
}
/**
* Send a photo from a local file, public URL, or Base64 data URL.
*/
export async function sendPhoto(
ctx: MediaTargetContext,
imagePath: string,
): Promise<OutboundResult> {
const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image");
if (!resolvedMediaPath.ok) {
return { channel: "qqbot", error: resolvedMediaPath.error };
}
const mediaPath = resolvedMediaPath.mediaPath;
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(`sendPhoto: urlDirectUpload=false, downloading URL first...`);
const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
if (localFile) {
return await sendPhotoFromLocal(ctx, localFile);
}
return { channel: "qqbot", error: `Failed to download image: ${mediaPath.slice(0, 80)}` };
}
if (isLocal) {
return await sendPhotoFromLocal(ctx, mediaPath);
}
if (!isHttp && !isData) {
return { channel: "qqbot", error: `Unsupported image source: ${mediaPath.slice(0, 50)}` };
}
// Remote URL or data: URL — try direct upload first, fall back to
// download-then-local on failure.
try {
const creds = accountToCreds(ctx.account);
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
if (target.type === "c2c" || target.type === "group") {
const r = await senderSendMedia({
target,
creds,
kind: "image",
source: { url: mediaPath },
msgId: ctx.replyToId,
});
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
}
if (isHttp) {
const r = await senderSendText(target, `![](${mediaPath})`, creds, {
msgId: ctx.replyToId,
});
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
}
debugLog(`sendPhoto: channel does not support local/Base64 images`);
return { channel: "qqbot", error: "Channel does not support local/Base64 images" };
} catch (err) {
const msg = formatErrorMessage(err);
// Fall back to plugin-managed download + local upload when QQ fails to
// fetch the URL directly. One-shot, non-recursive.
if (isHttp && !isData) {
debugWarn(
`sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
);
const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
if (localFile) {
return await sendPhotoFromLocal(ctx, localFile);
}
}
debugError(`sendPhoto failed: ${msg}`);
return { channel: "qqbot", error: msg };
}
}
/** Send a photo from a validated local file path. */
async function sendPhotoFromLocal(
ctx: MediaTargetContext,
mediaPath: string,
): Promise<OutboundResult> {
if (!(await fileExistsAsync(mediaPath))) {
return { channel: "qqbot", error: "Image not found" };
}
const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.IMAGE));
if (!sizeCheck.ok) {
return buildFileTooLargeResult(MediaFileType.IMAGE, sizeCheck.size);
}
const mimeType = getImageMimeType(mediaPath);
if (!mimeType) {
const ext = normalizeLowercaseStringOrEmpty(path.extname(mediaPath));
return { channel: "qqbot", error: `Unsupported image format: ${ext}` };
}
debugLog(`sendPhoto: local (${formatFileSize(sizeCheck.size)})`);
try {
const creds = accountToCreds(ctx.account);
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
if (target.type === "c2c" || target.type === "group") {
const r = await senderSendMedia({
target,
creds,
kind: "image",
source: { localPath: mediaPath },
msgId: ctx.replyToId,
localPathForMeta: mediaPath,
});
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
}
debugLog(`sendPhoto: channel does not support local images`);
return { channel: "qqbot", error: "Channel does not support local/Base64 images" };
} catch (err) {
if (err instanceof UploadDailyLimitExceededError) {
debugError(`sendPhoto (local): daily upload quota exceeded`);
return buildDailyLimitExceededResult(err);
}
const msg = formatErrorMessage(err);
debugError(`sendPhoto (local) failed: ${msg}`);
return { channel: "qqbot", error: msg };
}
}
/**
* 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<OutboundResult> {
const resolvedMediaPath = resolveOutboundMediaPath(voicePath, "voice", {
allowMissingLocalPath: true,
});
if (!resolvedMediaPath.ok) {
return { channel: "qqbot", error: resolvedMediaPath.error };
}
const mediaPath = resolvedMediaPath.mediaPath;
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
if (isHttp) {
if (shouldDirectUploadUrl(ctx.account)) {
try {
const creds = accountToCreds(ctx.account);
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
if (target.type === "c2c" || target.type === "group") {
const r = await senderSendMedia({
target,
creds,
kind: "voice",
source: { url: mediaPath },
msgId: ctx.replyToId,
});
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
}
debugLog(`sendVoice: voice not supported in channel`);
return { channel: "qqbot", error: "Voice not supported in channel" };
} catch (err) {
const msg = formatErrorMessage(err);
debugWarn(
`sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`,
);
}
} else {
debugLog(`sendVoice: urlDirectUpload=false, downloading URL first...`);
}
const localFile = await downloadToFallbackDir(mediaPath, "sendVoice");
if (localFile) {
return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled);
}
return { channel: "qqbot", error: `Failed to download audio: ${mediaPath.slice(0, 80)}` };
}
return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled);
}
/** Send voice from a local file. */
async function sendVoiceFromLocal(
ctx: MediaTargetContext,
mediaPath: string,
directUploadFormats: string[] | undefined,
transcodeEnabled: boolean,
): Promise<OutboundResult> {
// 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" };
}
if (fileSize > getMaxUploadSize(MediaFileType.VOICE)) {
return buildFileTooLargeResult(MediaFileType.VOICE, fileSize);
}
// Re-check containment after the file appears to prevent symlink-race escapes.
const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath);
if (!safeMediaPath) {
debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`);
return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" };
}
const needsTranscode = shouldTranscodeVoice(safeMediaPath);
if (needsTranscode && !transcodeEnabled) {
const ext = normalizeLowercaseStringOrEmpty(path.extname(safeMediaPath));
debugLog(
`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(safeMediaPath, directUploadFormats);
let uploadBase64 = silkBase64;
if (!uploadBase64) {
const buf = await readFileAsync(safeMediaPath);
uploadBase64 = buf.toString("base64");
debugLog(`sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`);
} else {
debugLog(`sendVoice: SILK ready (${fileSize} bytes)`);
}
const creds = accountToCreds(ctx.account);
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
if (target.type === "c2c" || target.type === "group") {
const r = await senderSendMedia({
target,
creds,
kind: "voice",
source: { base64: uploadBase64 },
msgId: ctx.replyToId,
localPathForMeta: safeMediaPath,
});
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
}
debugLog(`sendVoice: voice not supported in channel`);
return { channel: "qqbot", error: "Voice not supported in channel" };
} catch (err) {
if (err instanceof UploadDailyLimitExceededError) {
debugError(`sendVoice (local): daily upload quota exceeded`);
return buildDailyLimitExceededResult(err);
}
const msg = formatErrorMessage(err);
debugError(`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<OutboundResult> {
const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video");
if (!resolvedMediaPath.ok) {
return { channel: "qqbot", error: resolvedMediaPath.error };
}
const mediaPath = resolvedMediaPath.mediaPath;
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
debugLog(`sendVideoMsg: urlDirectUpload=false, downloading URL first...`);
const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
if (localFile) {
return await sendVideoFromLocal(ctx, localFile);
}
return { channel: "qqbot", error: `Failed to download video: ${mediaPath.slice(0, 80)}` };
}
try {
if (isHttp) {
const creds = accountToCreds(ctx.account);
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
if (target.type === "c2c" || target.type === "group") {
const r = await senderSendMedia({
target,
creds,
kind: "video",
source: { url: mediaPath },
msgId: ctx.replyToId,
});
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
}
debugLog(`sendVideoMsg: video not supported in channel`);
return { channel: "qqbot", error: "Video not supported in channel" };
}
return await sendVideoFromLocal(ctx, mediaPath);
} catch (err) {
const msg = formatErrorMessage(err);
// If direct URL upload fails, retry through a local download path.
if (isHttp) {
debugWarn(
`sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
);
const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
if (localFile) {
return await sendVideoFromLocal(ctx, localFile);
}
}
debugError(`sendVideoMsg failed: ${msg}`);
return { channel: "qqbot", error: msg };
}
}
/** Send video from a local file. */
async function sendVideoFromLocal(
ctx: MediaTargetContext,
mediaPath: string,
): Promise<OutboundResult> {
if (!(await fileExistsAsync(mediaPath))) {
return { channel: "qqbot", error: "Video not found" };
}
const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.VIDEO));
if (!sizeCheck.ok) {
return buildFileTooLargeResult(MediaFileType.VIDEO, sizeCheck.size);
}
debugLog(`sendVideoMsg: local video (${formatFileSize(sizeCheck.size)})`);
try {
const creds = accountToCreds(ctx.account);
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
if (target.type === "c2c" || target.type === "group") {
const r = await senderSendMedia({
target,
creds,
kind: "video",
source: { localPath: mediaPath },
msgId: ctx.replyToId,
localPathForMeta: mediaPath,
});
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
}
debugLog(`sendVideoMsg: video not supported in channel`);
return { channel: "qqbot", error: "Video not supported in channel" };
} catch (err) {
if (err instanceof UploadDailyLimitExceededError) {
debugError(`sendVideoMsg (local): daily upload quota exceeded`);
return buildDailyLimitExceededResult(err);
}
const msg = formatErrorMessage(err);
debugError(`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,
options: SendDocumentOptions = {},
): Promise<OutboundResult> {
const extraLocalRoots = options.allowQQBotDataDownloads
? [getQQBotDataDir("downloads")]
: undefined;
const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", {
extraLocalRoots,
});
if (!resolvedMediaPath.ok) {
return { channel: "qqbot", error: resolvedMediaPath.error };
}
const mediaPath = resolvedMediaPath.mediaPath;
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
const fileName = sanitizeFileName(path.basename(mediaPath));
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
debugLog(`sendDocument: urlDirectUpload=false, downloading URL first...`);
const localFile = await downloadToFallbackDir(mediaPath, "sendDocument");
if (localFile) {
return await sendDocumentFromLocal(ctx, localFile);
}
return { channel: "qqbot", error: `Failed to download file: ${mediaPath.slice(0, 80)}` };
}
try {
if (isHttp) {
const creds = accountToCreds(ctx.account);
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
if (target.type === "c2c" || target.type === "group") {
const r = await senderSendMedia({
target,
creds,
kind: "file",
source: { url: mediaPath },
msgId: ctx.replyToId,
fileName,
});
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
}
debugLog(`sendDocument: file not supported in channel`);
return { channel: "qqbot", error: "File not supported in channel" };
}
return await sendDocumentFromLocal(ctx, mediaPath);
} catch (err) {
const msg = formatErrorMessage(err);
// If direct URL upload fails, retry through a local download path.
if (isHttp) {
debugWarn(
`sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
);
const localFile = await downloadToFallbackDir(mediaPath, "sendDocument");
if (localFile) {
return await sendDocumentFromLocal(ctx, localFile);
}
}
debugError(`sendDocument failed: ${msg}`);
return { channel: "qqbot", error: msg };
}
}
/** Send a file from local storage. */
async function sendDocumentFromLocal(
ctx: MediaTargetContext,
mediaPath: string,
): Promise<OutboundResult> {
const fileName = sanitizeFileName(path.basename(mediaPath));
if (!(await fileExistsAsync(mediaPath))) {
return { channel: "qqbot", error: "File not found" };
}
const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.FILE));
if (!sizeCheck.ok) {
return buildFileTooLargeResult(MediaFileType.FILE, sizeCheck.size);
}
if (sizeCheck.size === 0) {
return { channel: "qqbot", error: `File is empty: ${mediaPath}` };
}
debugLog(`sendDocument: local file (${formatFileSize(sizeCheck.size)})`);
try {
const creds = accountToCreds(ctx.account);
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
if (target.type === "c2c" || target.type === "group") {
const r = await senderSendMedia({
target,
creds,
kind: "file",
source: { localPath: mediaPath },
msgId: ctx.replyToId,
fileName,
localPathForMeta: mediaPath,
});
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
}
debugLog(`sendDocument: file not supported in channel`);
return { channel: "qqbot", error: "File not supported in channel" };
} catch (err) {
if (err instanceof UploadDailyLimitExceededError) {
debugError(`sendDocument (local): daily upload quota exceeded`);
return buildDailyLimitExceededResult(err);
}
const msg = formatErrorMessage(err);
debugError(`sendDocument (local) failed: ${msg}`);
return { channel: "qqbot", error: msg };
}
}
/** Download a remote file into the fallback media directory. */
async function downloadToFallbackDir(httpUrl: string, caller: string): Promise<string | null> {
try {
const downloadDir = getQQBotMediaDir("downloads", "url-fallback");
const localFile = await downloadFile(httpUrl, downloadDir);
if (!localFile) {
debugError(`${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`);
return null;
}
debugLog(`${caller} fallback: downloaded → ${localFile}`);
return localFile;
} catch (err) {
debugError(`${caller} fallback download error:`, err);
return null;
}
}

View File

@@ -0,0 +1,27 @@
import { debugLog } from "../utils/log.js";
import { ReplyLimiter, type ReplyLimitResult } from "./reply-limiter.js";
const replyLimiter = new ReplyLimiter();
export type { ReplyLimitResult };
export const MESSAGE_REPLY_LIMIT = 4;
export function checkMessageReplyLimit(messageId: string): ReplyLimitResult {
return replyLimiter.checkLimit(messageId);
}
export function recordMessageReply(messageId: string): void {
replyLimiter.record(messageId);
debugLog(
`[qqbot] recordMessageReply: ${messageId}, count=${replyLimiter.getStats().totalReplies}`,
);
}
export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } {
return replyLimiter.getStats();
}
export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } {
return replyLimiter.getConfig();
}

View File

@@ -0,0 +1,54 @@
import path from "node:path";
import { UPLOAD_PREPARE_FALLBACK_CODE } from "../api/retry.js";
import { MediaFileType } from "../types.js";
import { formatFileSize, getFileTypeName, getMaxUploadSize } from "../utils/file-utils.js";
import {
DEFAULT_MEDIA_SEND_ERROR,
OUTBOUND_ERROR_CODES,
type OutboundResult,
} from "./outbound-types.js";
import { UploadDailyLimitExceededError } from "./sender.js";
/**
* Convert a media send result into a user-facing message.
*/
export function resolveUserFacingMediaError(
result: Pick<OutboundResult, "error" | "errorCode" | "qqBizCode">,
): string {
if (!result.error) {
return DEFAULT_MEDIA_SEND_ERROR;
}
if (result.qqBizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
return result.error;
}
switch (result.errorCode) {
case OUTBOUND_ERROR_CODES.FILE_TOO_LARGE:
case OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED:
return result.error;
default:
return DEFAULT_MEDIA_SEND_ERROR;
}
}
export function buildDailyLimitExceededResult(err: UploadDailyLimitExceededError): OutboundResult {
const dir = path.dirname(err.filePath);
const name = path.basename(err.filePath);
const size = formatFileSize(err.fileSize);
return {
channel: "qqbot",
error: `QQBot每天发送文件有累计2G的限制如果着急的话可以直接来我的主机copy下载文件目录\`${dir}/${name}\`${size}`,
errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED,
qqBizCode: UPLOAD_PREPARE_FALLBACK_CODE,
};
}
export function buildFileTooLargeResult(fileType: MediaFileType, fileSize: number): OutboundResult {
const typeName = getFileTypeName(fileType);
const limit = getMaxUploadSize(fileType);
const limitMB = Math.round(limit / (1024 * 1024));
return {
channel: "qqbot",
error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M暂时不能通过QQ直接发给你。`,
errorCode: OUTBOUND_ERROR_CODES.FILE_TOO_LARGE,
};
}

View File

@@ -0,0 +1,45 @@
import type { GatewayAccount } from "../types.js";
export interface OutboundContext {
to: string;
text: string;
accountId?: string | null;
replyToId?: string | null;
account: GatewayAccount;
}
export interface MediaOutboundContext extends OutboundContext {
mediaUrl: string;
mimeType?: string;
}
/**
* Stable error codes for outbound media send results.
*/
export const OUTBOUND_ERROR_CODES = {
FILE_TOO_LARGE: "file_too_large",
UPLOAD_DAILY_LIMIT_EXCEEDED: "upload_daily_limit_exceeded",
} as const;
export type OutboundErrorCode = (typeof OUTBOUND_ERROR_CODES)[keyof typeof OUTBOUND_ERROR_CODES];
export const DEFAULT_MEDIA_SEND_ERROR = "发送失败,请稍后重试。";
export interface OutboundResult {
channel: string;
messageId?: string;
timestamp?: string | number;
error?: string;
errorCode?: OutboundErrorCode;
qqBizCode?: number;
refIdx?: string;
}
/** Normalized target information for media sends. */
export interface MediaTargetContext {
targetType: "c2c" | "group" | "channel" | "dm";
targetId: string;
account: GatewayAccount;
replyToId?: string;
logPrefix?: string;
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,9 @@
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { GatewayAccount } from "../types.js";
import { MAX_UPLOAD_SIZE, formatFileSize } from "../utils/file-utils.js";
import { MediaFileType, type GatewayAccount } from "../types.js";
import { formatFileSize, getImageMimeType, getMaxUploadSize } from "../utils/file-utils.js";
import { formatErrorMessage } from "../utils/format.js";
import {
parseQQBotPayload,
@@ -21,12 +20,10 @@ import {
import { normalizePath, resolveQQBotPayloadLocalFilePath } from "../utils/platform.js";
import { normalizeLowercaseStringOrEmpty } from "../utils/string-normalize.js";
import { sanitizeFileName } from "../utils/string-normalize.js";
import { openLocalFile } from "./media-source.js";
import {
sendText as senderSendText,
sendImage as senderSendImage,
sendVoiceMessage as senderSendVoice,
sendVideoMessage as senderSendVideo,
sendFileMessage as senderSendFile,
sendMedia as senderSendMedia,
withTokenRetry,
buildDeliveryTarget,
accountToCreds,
@@ -276,23 +273,43 @@ function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): strin
}
}
async function readStructuredPayloadLocalFile(filePath: string): Promise<Buffer> {
const openFlags =
fs.constants.O_RDONLY | ("O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0);
const handle = await fs.promises.open(filePath, openFlags);
/**
* Read a local file into memory for image base64 inlining.
*
* Non-image media (video / file) should pass `source: { localPath }` to
* `sender.sendMedia` directly — the sender pipeline handles chunked
* routing once this function validates the per-type ceiling.
*/
async function readLocalFileForInlineBase64(
filePath: string,
fileType: MediaFileType,
): Promise<Buffer> {
const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) });
try {
const stat = await handle.stat();
if (!stat.isFile()) {
throw new Error("Path is not a regular file");
}
if (stat.size > MAX_UPLOAD_SIZE) {
throw new Error(
`File is too large (${formatFileSize(stat.size)}); QQ Bot API limit is ${formatFileSize(MAX_UPLOAD_SIZE)}`,
);
}
return handle.readFile();
return await opened.handle.readFile();
} finally {
await handle.close();
await opened.close();
}
}
/**
* Enforce the per-{@link MediaFileType} upload ceiling before handing a
* local path to `sender.sendMedia`. The sender's internal `normalizeSource`
* uses an unlimited cap so it can accept whatever size the policy layer
* (outbound / reply-dispatcher) approves; the policy gate lives here.
*
* Returns the validated byte size. Throws via {@link openLocalFile} with a
* human-readable "File is too large" message when exceeding the ceiling.
*/
async function assertLocalFileWithinTypeLimit(
filePath: string,
fileType: MediaFileType,
): Promise<number> {
const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) });
try {
return opened.size;
} finally {
await opened.close();
}
}
@@ -317,19 +334,11 @@ async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Pro
if (payload.source === "file") {
try {
const fileBuffer = await readStructuredPayloadLocalFile(imageUrl);
const fileBuffer = await readLocalFileForInlineBase64(imageUrl, MediaFileType.IMAGE);
const base64Data = fileBuffer.toString("base64");
const ext = normalizeLowercaseStringOrEmpty(path.extname(imageUrl));
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
};
const mimeType = mimeTypes[ext];
const mimeType = getImageMimeType(imageUrl);
if (!mimeType) {
const ext = normalizeLowercaseStringOrEmpty(path.extname(imageUrl));
log?.error(`Unsupported image format: ${ext}`);
return;
}
@@ -353,9 +362,13 @@ async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Pro
creds,
async () => {
if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") {
await senderSendImage(deliveryTarget, imageUrl, creds, {
await senderSendMedia({
target: deliveryTarget,
creds,
kind: "image",
source: { url: imageUrl },
msgId: target.messageId,
localPath: originalImagePath,
localPathForMeta: originalImagePath,
});
} else if (deliveryTarget.type === "dm") {
await senderSendText(deliveryTarget, `![](${payload.path})`, creds, {
@@ -439,11 +452,14 @@ export async function sendTextAsVoiceReply(
creds,
async () => {
if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") {
await senderSendVoice(deliveryTarget, creds, {
voiceBase64: silkBase64,
await senderSendMedia({
target: deliveryTarget,
creds,
kind: "voice",
source: { base64: silkBase64 },
msgId: target.messageId,
ttsText,
filePath: silkPath,
localPathForMeta: silkPath,
});
} else {
log?.error(`Voice not supported in ${deliveryTarget.type}, sending text fallback`);
@@ -485,20 +501,27 @@ async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Pro
creds,
async () => {
if (isHttpUrl) {
await senderSendVideo(deliveryTarget, creds, {
videoUrl: videoPath,
await senderSendMedia({
target: deliveryTarget,
creds,
kind: "video",
source: { url: videoPath },
msgId: target.messageId,
});
} else {
const fileBuffer = await readStructuredPayloadLocalFile(videoPath);
const videoBase64 = fileBuffer.toString("base64");
const size = await assertLocalFileWithinTypeLimit(videoPath, MediaFileType.VIDEO);
log?.debug?.(
`Read local video (${formatFileSize(fileBuffer.length)}): ${describeMediaTargetForLog(videoPath, false)}`,
`Video local (${formatFileSize(size)}): ${describeMediaTargetForLog(videoPath, false)}`,
);
await senderSendVideo(deliveryTarget, creds, {
videoBase64,
// Hand the local path straight to the sender — `dispatchUpload`
// routes one-shot vs chunked based on size.
await senderSendMedia({
target: deliveryTarget,
creds,
kind: "video",
source: { localPath: videoPath },
msgId: target.messageId,
localPath: videoPath,
localPathForMeta: videoPath,
});
}
},
@@ -542,19 +565,29 @@ async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Prom
creds,
async () => {
if (isHttpUrl) {
await senderSendFile(deliveryTarget, creds, {
fileUrl: filePath,
await senderSendMedia({
target: deliveryTarget,
creds,
kind: "file",
source: { url: filePath },
msgId: target.messageId,
fileName,
});
} else {
const fileBuffer = await readStructuredPayloadLocalFile(filePath);
const fileBase64 = fileBuffer.toString("base64");
await senderSendFile(deliveryTarget, creds, {
fileBase64,
const size = await assertLocalFileWithinTypeLimit(filePath, MediaFileType.FILE);
log?.debug?.(
`File local (${formatFileSize(size)}): ${describeMediaTargetForLog(filePath, false)}`,
);
// Hand the local path straight to the sender — `dispatchUpload`
// routes one-shot vs chunked based on size.
await senderSendMedia({
target: deliveryTarget,
creds,
kind: "file",
source: { localPath: filePath },
msgId: target.messageId,
fileName,
localFilePath: filePath,
localPathForMeta: filePath,
});
}
},

View File

@@ -26,28 +26,34 @@
import os from "node:os";
import { ApiClient } from "../api/api-client.js";
import { ChunkedMediaApi as ChunkedMediaApiClass } from "../api/media-chunked.js";
import { MediaApi as MediaApiClass } from "../api/media.js";
import type { Credentials } from "../api/messages.js";
import { MessageApi as MessageApiClass } from "../api/messages.js";
import { getNextMsgSeq } from "../api/routes.js";
import { TokenManager } from "../api/token.js";
import {
ApiError,
MediaFileType,
type ChatScope,
type EngineLogger,
type MessageResponse,
type OutboundMeta,
type UploadMediaResponse,
} from "../types.js";
import { LARGE_FILE_THRESHOLD } from "../utils/file-utils.js";
import { formatErrorMessage } from "../utils/format.js";
import { debugLog, debugError, debugWarn } from "../utils/log.js";
import { sanitizeFileName } from "../utils/string-normalize.js";
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "../utils/upload-cache.js";
import { normalizeSource, type MediaSource, type RawMediaSource } from "./media-source.js";
// ============ Re-exported types ============
export { ApiError } from "../types.js";
export type { OutboundMeta, MessageResponse, UploadMediaResponse } from "../types.js";
export { MediaFileType } from "../types.js";
export { UploadDailyLimitExceededError } from "../api/media-chunked.js";
// ============ Plugin User-Agent ============
@@ -92,6 +98,7 @@ interface AccountContext {
client: ApiClient;
tokenMgr: TokenManager;
mediaApi: MediaApiClass;
chunkedMediaApi: ChunkedMediaApiClass;
messageApi: MessageApiClass;
markdownSupport: boolean;
}
@@ -116,22 +123,31 @@ const _fallbackLogger: EngineLogger = {
function buildAccountContext(logger: EngineLogger, markdownSupport: boolean): AccountContext {
const client = new ApiClient({ logger, userAgent: buildUserAgent });
const tokenMgr = new TokenManager({ logger, userAgent: buildUserAgent });
// The one-shot and chunked uploaders share the same cache adapter so repeat
// sends of identical bytes hit the same `file_info` regardless of which
// path the first send used.
const sharedUploadCache = {
computeHash: computeFileHash,
get: (hash: string, scope: string, targetId: string, fileType: number) =>
getCachedFileInfo(hash, scope as ChatScope, targetId, fileType),
set: (
hash: string,
scope: string,
targetId: string,
fileType: number,
fileInfo: string,
fileUuid: string,
ttl: number,
) => setCachedFileInfo(hash, scope as ChatScope, targetId, fileType, fileInfo, fileUuid, ttl),
};
const mediaApi = new MediaApiClass(client, tokenMgr, {
logger,
uploadCache: {
computeHash: computeFileHash,
get: (hash: string, scope: string, targetId: string, fileType: number) =>
getCachedFileInfo(hash, scope as ChatScope, targetId, fileType),
set: (
hash: string,
scope: string,
targetId: string,
fileType: number,
fileInfo: string,
fileUuid: string,
ttl: number,
) => setCachedFileInfo(hash, scope as ChatScope, targetId, fileType, fileInfo, fileUuid, ttl),
},
uploadCache: sharedUploadCache,
sanitizeFileName,
});
const chunkedMediaApi = new ChunkedMediaApiClass(client, tokenMgr, {
logger,
uploadCache: sharedUploadCache,
sanitizeFileName,
});
const messageApi = new MessageApiClass(client, tokenMgr, {
@@ -139,7 +155,7 @@ function buildAccountContext(logger: EngineLogger, markdownSupport: boolean): Ac
logger,
});
return { logger, client, tokenMgr, mediaApi, messageApi, markdownSupport };
return { logger, client, tokenMgr, mediaApi, chunkedMediaApi, messageApi, markdownSupport };
}
/**
@@ -212,6 +228,11 @@ export function getMediaApi(appId: string): MediaApiClass {
return resolveAccount(appId).mediaApi;
}
/** Get the ChunkedMediaApi instance for the given appId. */
export function getChunkedMediaApi(appId: string): ChunkedMediaApiClass {
return resolveAccount(appId).chunkedMediaApi;
}
/** Get the TokenManager instance for the given appId. */
export function getTokenManager(appId: string): TokenManager {
return resolveAccount(appId).tokenMgr;
@@ -317,10 +338,14 @@ export async function acknowledgeInteraction(
creds: AccountCreds,
interactionId: string,
code: 0 | 1 | 2 | 3 | 4 | 5 = 0,
data?: Record<string, unknown>,
): Promise<void> {
const ctx = resolveAccount(creds.appId);
const token = await ctx.tokenMgr.getAccessToken(creds.appId, creds.clientSecret);
await ctx.client.request(token, "PUT", `/interactions/${interactionId}`, { code });
await ctx.client.request(token, "PUT", `/interactions/${interactionId}`, {
code,
...(data ? { data } : {}),
});
}
// ============ Types ============
@@ -341,6 +366,10 @@ export interface AccountCreds {
/**
* Execute an API call with automatic token-retry on 401 errors.
*
* Primary signal is structured: `ApiError.httpStatus === 401`. A string
* fallback remains for non-`ApiError` paths (e.g. synthetic errors from
* custom adapters), but logs a warning so such cases can be surfaced.
*/
export async function withTokenRetry<T>(
creds: AccountCreds,
@@ -352,9 +381,23 @@ export async function withTokenRetry<T>(
const token = await getAccessToken(creds.appId, creds.clientSecret);
return await sendFn(token);
} catch (err) {
const isStructured401 = err instanceof ApiError && err.httpStatus === 401;
if (isStructured401) {
log?.debug?.(`Token expired (ApiError 401), refreshing...`);
clearTokenCache(creds.appId);
const newToken = await getAccessToken(creds.appId, creds.clientSecret);
return await sendFn(newToken);
}
// String fallback — retain for non-ApiError code paths but make it visible.
const errMsg = formatErrorMessage(err);
if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
log?.debug?.(`Token may be expired, refreshing...`);
const looksLike401 =
errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token");
if (looksLike401) {
log?.warn?.(
`Token retry triggered by string heuristic (err is not ApiError). ` +
`Consider propagating ApiError end-to-end. msg=${errMsg.slice(0, 120)}`,
);
clearTokenCache(creds.appId);
const newToken = await getAccessToken(creds.appId, creds.clientSecret);
return await sendFn(newToken);
@@ -482,189 +525,254 @@ export function createRawInputNotifyFn(
};
}
// ============ Image sending ============
// ============ Media sending (unified) ============
/** Rich-media kind accepted by {@link sendMedia}. */
export type MediaKind = "image" | "voice" | "video" | "file";
/** Map a {@link MediaKind} to the wire-level {@link MediaFileType} code. */
const KIND_TO_FILE_TYPE: Record<MediaKind, MediaFileType> = {
image: MediaFileType.IMAGE,
voice: MediaFileType.VOICE,
video: MediaFileType.VIDEO,
file: MediaFileType.FILE,
};
/** Re-export source types so callers can construct them without importing media-source. */
export type { MediaSource, RawMediaSource } from "./media-source.js";
/**
* Upload and send an image message to any C2C/Group target.
* Options for the unified {@link sendMedia} API.
*
* This replaces the legacy four-method surface
* (`sendImage / sendVoiceMessage / sendVideoMessage / sendFileMessage`).
*/
export async function sendImage(
target: DeliveryTarget,
imageUrl: string,
creds: AccountCreds,
opts?: { msgId?: string; content?: string; localPath?: string },
): Promise<MessageResponse> {
if (target.type !== "c2c" && target.type !== "group") {
throw new Error(`Image sending not supported for target type: ${target.type}`);
export interface SendMediaOptions {
/** Delivery target. Only `c2c` and `group` support rich media. */
target: DeliveryTarget;
/** Account credentials. */
creds: AccountCreds;
/** Media kind (drives `file_type`, meta, and content semantics). */
kind: MediaKind;
/** Media source — URL, base64, on-disk path, or in-memory buffer. */
source: RawMediaSource;
/** Passive reply message ID; omit for proactive sends. */
msgId?: string;
/**
* Accompanying text. Only honored for `image` / `video` kinds — the QQ
* API ignores it for voice/file.
*/
content?: string;
/** Override the server-visible file name (FILE kind only). */
fileName?: string;
/** Original TTS text — recorded in {@link OutboundMeta.ttsText} for voice. */
ttsText?: string;
/**
* Local path to record in {@link OutboundMeta.mediaLocalPath}. Usually set
* by adapters that already downloaded the source to disk; otherwise
* inferred automatically when `source` is `{ localPath }`.
*/
localPathForMeta?: string;
/**
* Original URL to record in {@link OutboundMeta.mediaUrl}. Usually set by
* adapters that downloaded a remote URL before uploading; otherwise
* inferred automatically when `source` is `{ url }` (non-data URL).
*/
origUrlForMeta?: string;
}
/**
* Upload and send a rich-media message to any C2C or Group target.
*
* This is the **single** rich-media entry point for the plugin. All adapter
* layers (outbound.ts, reply-dispatcher.ts, outbound-deliver.ts,
* bridge/commands, gateway/outbound-dispatch.ts) funnel through here.
*
* Dispatch structure:
*
* ```
* sendMedia(opts)
* └─ sendMediaInternal(ctx, opts)
* ├─ normalizeSource ← unified data:URL parsing + O_NOFOLLOW file safety
* ├─ uploadOnce ← one-shot upload via MediaApi (chunked hook TBD)
* ├─ sendMediaMessage
* └─ notifyMediaHook ← meta assembled per kind
* ```
*
* Future chunked upload will slot into the dispatch without touching callers.
*/
export async function sendMedia(opts: SendMediaOptions): Promise<MessageResponse> {
if (!supportsRichMedia(opts.target.type)) {
throw new Error(`Media sending not supported for target type: ${opts.target.type}`);
}
const ctx = resolveAccount(opts.creds.appId);
return sendMediaInternal(ctx, opts);
}
const ctx = resolveAccount(creds.appId);
const scope: ChatScope = target.type;
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
const isBase64 = imageUrl.startsWith("data:");
let uploadOpts: { url?: string; fileData?: string };
if (isBase64) {
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) {
throw new Error("Invalid Base64 Data URL format");
}
uploadOpts = { fileData: matches[2] };
} else {
uploadOpts = { url: imageUrl };
}
const uploadResult = await ctx.mediaApi.uploadMedia(
scope,
target.id,
MediaFileType.IMAGE,
c,
uploadOpts,
);
/**
* Assemble an {@link OutboundMeta} record from the normalized source and the
* caller-provided overrides.
*
* The meta layout is identical across kinds except:
* - `image` / `video` carry `text` (the accompanying content string).
* - `voice` carries `ttsText` (original TTS input, if any).
*/
function buildOutboundMeta(opts: SendMediaOptions, source: MediaSource): OutboundMeta {
const meta: OutboundMeta = {
text: opts?.content,
mediaType: "image",
...(!isBase64 ? { mediaUrl: imageUrl } : {}),
...(opts?.localPath ? { mediaLocalPath: opts.localPath } : {}),
mediaType: opts.kind,
};
const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
msgId: opts?.msgId,
content: opts?.content,
if (opts.kind === "image" || opts.kind === "video") {
if (opts.content) {
meta.text = opts.content;
}
}
if (opts.kind === "voice" && opts.ttsText) {
meta.ttsText = opts.ttsText;
}
// Prefer explicit caller overrides; otherwise derive from the source.
const inferredUrl = source.kind === "url" ? source.url : undefined;
const mediaUrl = opts.origUrlForMeta ?? inferredUrl;
if (mediaUrl) {
meta.mediaUrl = mediaUrl;
}
const inferredLocal = source.kind === "localPath" ? source.path : undefined;
const mediaLocalPath = opts.localPathForMeta ?? inferredLocal;
if (mediaLocalPath) {
meta.mediaLocalPath = mediaLocalPath;
}
return meta;
}
/**
* Core dispatch for rich media. Not exported — callers must go through
* {@link sendMedia}.
*
* Upload dispatch lives in {@link dispatchUpload}: sources smaller than
* {@link LARGE_FILE_THRESHOLD} (or not supporting chunked transport, i.e.
* url/base64) go to {@link MediaApi.uploadMedia}; larger `localPath` /
* `buffer` sources go to {@link ChunkedMediaApi.uploadChunked}.
*/
async function sendMediaInternal(
ctx: AccountContext,
opts: SendMediaOptions,
): Promise<MessageResponse> {
const scope: ChatScope = opts.target.type as ChatScope;
const c: Credentials = {
appId: opts.creds.appId,
clientSecret: opts.creds.clientSecret,
};
// The outbound layer enforces per-file-type ceilings; normalizeSource's
// default is the smaller one-shot limit. We pass the chunked limit here
// to let the dispatcher decide per source.size whether to route to the
// chunked uploader. Upstream (outbound/sendPhoto etc.) remains the
// authoritative size-by-file-type gate.
const source = await normalizeSource(opts.source, {
maxSize: Number.MAX_SAFE_INTEGER,
});
notifyMediaHook(creds.appId, result, meta);
const uploadResult = await dispatchUpload(
ctx,
scope,
opts.target.id,
KIND_TO_FILE_TYPE[opts.kind],
source,
c,
opts.fileName,
);
// Content is semantically meaningful only for image / video — the voice
// and file APIs ignore it.
const msgContent = opts.kind === "image" || opts.kind === "video" ? opts.content : undefined;
const result = await ctx.mediaApi.sendMediaMessage(
scope,
opts.target.id,
uploadResult.file_info,
c,
{
msgId: opts.msgId,
content: msgContent,
},
);
notifyMediaHook(opts.creds.appId, result, buildOutboundMeta(opts, source));
return result;
}
// ============ Voice sending ============
/**
* Upload and send a voice message.
* Upload a {@link MediaSource} via the one-shot or chunked path, chosen by
* size + kind.
*
* Routing rules (kept here as the single source of truth so callers need
* not know which endpoint was used):
*
* - `url` / `base64`: always one-shot — the server accepts these directly
* and the chunked endpoint has no representation for them.
* - `localPath` / `buffer` with `size >= LARGE_FILE_THRESHOLD`: chunked.
* - Everything else: one-shot.
*/
export async function sendVoiceMessage(
target: DeliveryTarget,
creds: AccountCreds,
opts: {
voiceBase64?: string;
voiceUrl?: string;
msgId?: string;
ttsText?: string;
filePath?: string;
},
): Promise<MessageResponse> {
if (target.type !== "c2c" && target.type !== "group") {
throw new Error(`Voice sending not supported for target type: ${target.type}`);
async function dispatchUpload(
ctx: AccountContext,
scope: ChatScope,
targetId: string,
fileType: MediaFileType,
source: MediaSource,
creds: Credentials,
fileName?: string,
): Promise<UploadMediaResponse> {
switch (source.kind) {
case "url":
return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
url: source.url,
fileName,
});
case "base64":
return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
fileData: source.data,
fileName,
});
case "localPath":
if (source.size >= LARGE_FILE_THRESHOLD) {
return ctx.chunkedMediaApi.uploadChunked({
scope,
targetId,
fileType,
source,
creds,
fileName,
});
}
return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
localPath: source.path,
fileName,
});
case "buffer":
if (source.buffer.length >= LARGE_FILE_THRESHOLD) {
return ctx.chunkedMediaApi.uploadChunked({
scope,
targetId,
fileType,
source,
creds,
fileName: fileName ?? source.fileName,
});
}
return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, {
buffer: source.buffer,
fileName: fileName ?? source.fileName,
});
default: {
const _exhaustive: never = source;
throw new Error(
`dispatchUpload: unsupported MediaSource kind: ${JSON.stringify(_exhaustive)}`,
);
}
}
const ctx = resolveAccount(creds.appId);
const scope: ChatScope = target.type;
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.VOICE, c, {
url: opts.voiceUrl,
fileData: opts.voiceBase64,
});
const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
msgId: opts.msgId,
});
notifyMediaHook(creds.appId, result, {
mediaType: "voice",
...(opts.ttsText ? { ttsText: opts.ttsText } : {}),
...(opts.filePath ? { mediaLocalPath: opts.filePath } : {}),
});
return result;
}
// ============ Video sending ============
/**
* Upload and send a video message.
*/
export async function sendVideoMessage(
target: DeliveryTarget,
creds: AccountCreds,
opts: {
videoUrl?: string;
videoBase64?: string;
msgId?: string;
content?: string;
localPath?: string;
},
): Promise<MessageResponse> {
if (target.type !== "c2c" && target.type !== "group") {
throw new Error(`Video sending not supported for target type: ${target.type}`);
}
const ctx = resolveAccount(creds.appId);
const scope: ChatScope = target.type;
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.VIDEO, c, {
url: opts.videoUrl,
fileData: opts.videoBase64,
});
const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
msgId: opts.msgId,
content: opts.content,
});
notifyMediaHook(creds.appId, result, {
text: opts.content,
mediaType: "video",
...(opts.videoUrl ? { mediaUrl: opts.videoUrl } : {}),
...(opts.localPath ? { mediaLocalPath: opts.localPath } : {}),
});
return result;
}
// ============ File sending ============
/**
* Upload and send a file message.
*/
export async function sendFileMessage(
target: DeliveryTarget,
creds: AccountCreds,
opts: {
fileBase64?: string;
fileUrl?: string;
msgId?: string;
fileName?: string;
localFilePath?: string;
},
): Promise<MessageResponse> {
if (target.type !== "c2c" && target.type !== "group") {
throw new Error(`File sending not supported for target type: ${target.type}`);
}
const ctx = resolveAccount(creds.appId);
const scope: ChatScope = target.type;
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.FILE, c, {
url: opts.fileUrl,
fileData: opts.fileBase64,
fileName: opts.fileName,
});
const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
msgId: opts.msgId,
});
notifyMediaHook(creds.appId, result, {
mediaType: "file",
mediaUrl: opts.fileUrl,
mediaLocalPath: opts.localFilePath ?? opts.fileName,
});
return result;
}
// ============ Helpers ============

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,725 @@
/**
* 富媒体标签解析与发送队列
*
* 提供媒体标签qqimg / qqvoice / qqvideo / qqfile / qqmedia的检测、
* 拆分、路径编码修复,以及统一的发送队列执行器。
*/
import type { GatewayAccount } from "../types.js";
import { normalizeMediaTags } from "../utils/media-tags.js";
import { normalizePath } from "../utils/platform.js";
import {
sendPhoto,
sendVoice,
sendVideoMsg,
sendDocument,
sendMedia as sendMediaAuto,
DEFAULT_MEDIA_SEND_ERROR,
resolveUserFacingMediaError,
type MediaTargetContext,
} from "./outbound.js";
function formatStreamSendErr(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
// ============ 类型定义 ============
/** 发送队列项 */
export interface SendQueueItem {
type: "text" | "image" | "voice" | "video" | "file" | "media";
content: string;
}
/** 统一的媒体标签正则 — 匹配标准化后的 6 种标签 */
export const MEDIA_TAG_REGEX =
/<(qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
/** 创建一个新的全局标签正则实例(每次调用 reset lastIndex */
export function createMediaTagRegex(): RegExp {
return new RegExp(MEDIA_TAG_REGEX.source, MEDIA_TAG_REGEX.flags);
}
/** 媒体发送上下文(统一的,供流式和普通模式共用) */
export interface MediaSendContext {
/** 媒体目标上下文(用于 sendPhoto/sendVoice 等) */
mediaTarget: MediaTargetContext;
/** qualifiedTarget格式 "qqbot:c2c:xxx" 或 "qqbot:group:xxx",用于 sendMediaAuto */
qualifiedTarget: string;
/** 账户配置 */
account: GatewayAccount;
/** 事件消息 ID用于被动回复 */
replyToId?: string;
/** 日志 */
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
}
// ============ 路径编码修复 ============
/**
* 修复路径编码问题双反斜杠、八进制转义、UTF-8 双重编码)
*
* 这是由于 LLM 输出路径时可能引入的编码问题:
* - Markdown 转义导致双反斜杠
* - 八进制转义序列(来自某些 shell 工具的输出)
* - UTF-8 双重编码(中文路径经过多层处理后的乱码)
*
* 此方法在 gateway.ts deliver 回调、outbound.ts sendText、
* streaming.ts sendMediaQueue 中共用。
*/
export function fixPathEncoding(
mediaPath: string,
log?: { debug?: (msg: string) => void; error?: (msg: string) => void },
): string {
// 1. 双反斜杠 -> 单反斜杠Markdown 转义)
let result = 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("\\\\");
// 2. 八进制转义序列 + UTF-8 双重编码修复
try {
const hasOctal = /\\[0-7]{1,3}/.test(result);
const hasNonASCII = /[\u0080-\u00FF]/.test(result);
if (!isWinLocal && (hasOctal || hasNonASCII)) {
log?.debug?.(`Decoding path with mixed encoding: ${result}`);
// Step 1: 将八进制转义转换为字节
let decoded = result.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
// Step 2: 提取所有字节(包括 Latin-1 字符)
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);
}
}
// Step 3: 尝试按 UTF-8 解码
const buffer = Buffer.from(bytes);
const utf8Decoded = buffer.toString("utf8");
if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
result = utf8Decoded;
log?.debug?.(`Successfully decoded path: ${result}`);
}
}
} catch (decodeErr) {
log?.error?.(`Path decode error: ${formatStreamSendErr(decodeErr)}`);
}
return result;
}
// ============ 代码块检测 ============
/**
* 判断文本中给定位置是否处于围栏代码块内(``` 块)。
*
* 围栏代码块:行首 ``` 开始,到下一个行首 ``` 结束(或文本末尾)
*
* @param text 完整文本
* @param position 要检测的位置(字符索引)
* @returns 如果 position 在围栏代码块内返回 true
*/
export function isInsideCodeBlock(text: string, position: number): boolean {
const fenceRegex = /^(`{3,})[^\n]*$/gm;
let fenceMatch: RegExpExecArray | null;
let openFence: { pos: number; ticks: number } | null = null;
while ((fenceMatch = fenceRegex.exec(text)) !== null) {
const ticks = fenceMatch[1].length;
if (!openFence) {
openFence = { pos: fenceMatch.index, ticks };
} else if (ticks >= openFence.ticks) {
// 闭合围栏
if (position >= openFence.pos && position < fenceMatch.index + fenceMatch[0].length) {
return true;
}
openFence = null;
}
}
// 未闭合的围栏一直延伸到文本末尾
if (openFence && position >= openFence.pos) {
return true;
}
return false;
}
// ============ 媒体标签解析 ============
/**
* 检测文本是否包含富媒体标签(忽略代码块内的标签)
*/
export function hasMediaTags(text: string): boolean {
const normalized = normalizeMediaTags(text);
const regex = createMediaTagRegex();
let match: RegExpExecArray | null;
while ((match = regex.exec(normalized)) !== null) {
if (!isInsideCodeBlock(normalized, match.index)) {
return true;
}
}
return false;
}
/** findFirstClosedMediaTag 的返回值 */
export interface FirstClosedMediaTag {
/** 标签前的纯文本 */
textBefore: string;
/** 标签类型(小写,如 "qqvoice" */
tagName: string;
/** 标签内的媒体路径(已 trim、去 MEDIA: 前缀、修复编码) */
mediaPath: string;
/** 标签在输入文本中的结束索引(紧接标签后的第一个字符位置) */
tagEndIndex: number;
/** 映射后的发送队列项类型 */
itemType: SendQueueItem["type"];
}
/**
* 在文本中查找**第一个**完整闭合的媒体标签
*
* 与 splitByMediaTags 不同,此函数只匹配一个标签就停止,
* 用于流式场景的"循环消费"模式:每次处理一个标签,更新偏移,再找下一个。
*
* @param text 待检查的文本(应已 normalize 过)
* @returns 第一个闭合标签的信息,没有则返回 null
*/
export function findFirstClosedMediaTag(
text: string,
log?: {
info?: (msg: string) => void;
debug?: (msg: string) => void;
error?: (msg: string) => void;
},
): FirstClosedMediaTag | null {
const regex = createMediaTagRegex();
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// 跳过代码块内的媒体标签
if (isInsideCodeBlock(text, match.index)) {
log?.debug?.(
`findFirstClosedMediaTag: skipping <${match[1]}> at index ${match.index} (inside code block)`,
);
continue;
}
const textBefore = text.slice(0, match.index);
const tagName = match[1].toLowerCase();
let mediaPath = match[2]?.trim() ?? "";
// 剥离 MEDIA: 前缀
if (mediaPath.startsWith("MEDIA:")) {
mediaPath = mediaPath.slice("MEDIA:".length);
}
mediaPath = normalizePath(mediaPath);
mediaPath = fixPathEncoding(mediaPath, log);
const typeMap: Record<string, SendQueueItem["type"]> = {
qqimg: "image",
qqvoice: "voice",
qqvideo: "video",
qqfile: "file",
qqmedia: "media",
};
return {
textBefore,
tagName,
mediaPath,
tagEndIndex: match.index + match[0].length,
itemType: typeMap[tagName] ?? "image",
};
}
return null;
}
/**
* 媒体标签拆分结果
*/
export interface MediaSplitResult {
/** 是否包含媒体标签 */
hasMediaTags: boolean;
/** 媒体标签前的纯文本 */
textBeforeFirstTag: string;
/** 媒体标签后的剩余文本 */
textAfterLastTag: string;
/** 完整的发送队列(标签间的文本 + 媒体项) */
mediaQueue: SendQueueItem[];
}
/**
* 将文本按富媒体标签拆分为三部分
*
* 用于两个场景:
* 1. 流式模式:中断-恢复流程(标签前文本 → 结束流式 → 发送媒体 → 新流式 → 标签后文本)
* 2. 普通模式:构建按顺序发送的队列
*/
export function splitByMediaTags(
text: string,
log?: {
info?: (msg: string) => void;
debug?: (msg: string) => void;
error?: (msg: string) => void;
},
): MediaSplitResult {
const normalized = normalizeMediaTags(text);
const regex = createMediaTagRegex();
// 过滤掉代码块内的匹配
const matches = [...normalized.matchAll(regex)].filter(
(m) => !isInsideCodeBlock(normalized, m.index),
);
if (matches.length === 0) {
return {
hasMediaTags: false,
textBeforeFirstTag: normalized,
textAfterLastTag: "",
mediaQueue: [],
};
}
// 第一个标签前的纯文本
const firstMatch = matches[0];
const textBeforeFirstTag = normalized
.slice(0, firstMatch.index)
.replace(/\n{3,}/g, "\n\n")
.trim();
// 最后一个标签后的纯文本
const lastMatch = matches[matches.length - 1];
const lastMatchEnd = lastMatch.index + lastMatch[0].length;
const textAfterLastTag = normalized
.slice(lastMatchEnd)
.replace(/\n{3,}/g, "\n\n")
.trim();
// 构建媒体发送队列
const mediaQueue: SendQueueItem[] = [];
let lastIndex = firstMatch.index;
for (const match of matches) {
// 标签前的文本(标签之间的间隔文本)
const textBetween = normalized
.slice(lastIndex, match.index)
.replace(/\n{3,}/g, "\n\n")
.trim();
if (textBetween && lastIndex !== firstMatch.index) {
// 只添加非首段的间隔文本(首段由 textBeforeFirstTag 覆盖)
mediaQueue.push({ type: "text", content: textBetween });
}
// 解析标签内容
const tagName = match[1].toLowerCase();
let mediaPath = match[2]?.trim() ?? "";
// 剥离 MEDIA: 前缀
if (mediaPath.startsWith("MEDIA:")) {
mediaPath = mediaPath.slice("MEDIA:".length);
}
mediaPath = normalizePath(mediaPath);
// 修复路径编码问题
mediaPath = fixPathEncoding(mediaPath, log);
// 根据标签类型加入队列
const typeMap: Record<string, SendQueueItem["type"]> = {
qqimg: "image",
qqvoice: "voice",
qqvideo: "video",
qqfile: "file",
qqmedia: "media",
};
const itemType = typeMap[tagName] ?? "image";
if (mediaPath) {
mediaQueue.push({ type: itemType, content: mediaPath });
log?.info?.(`Found ${itemType} in <${tagName}>: ${mediaPath.slice(0, 80)}`);
}
lastIndex = match.index + match[0].length;
}
return {
hasMediaTags: true,
textBeforeFirstTag,
textAfterLastTag,
mediaQueue,
};
}
/**
* 从文本中解析出完整的发送队列(含标签前后的纯文本)
*
* 与 splitByMediaTags 的区别:
* - splitByMediaTags 分为 before / queue / after 三段(供流式模式的中断-恢复)
* - parseMediaTagsToSendQueue 返回一个扁平的完整队列(供普通模式按顺序发送)
*
* 适用于 gateway.ts deliver 回调和 outbound.ts sendText。
*/
export function parseMediaTagsToSendQueue(
text: string,
log?: {
info?: (msg: string) => void;
debug?: (msg: string) => void;
error?: (msg: string) => void;
},
): { hasMediaTags: boolean; sendQueue: SendQueueItem[] } {
const split = splitByMediaTags(text, log);
if (!split.hasMediaTags) {
return { hasMediaTags: false, sendQueue: [] };
}
const sendQueue: SendQueueItem[] = [];
// 标签前的文本
if (split.textBeforeFirstTag) {
sendQueue.push({ type: "text", content: split.textBeforeFirstTag });
}
// 媒体队列(含标签间文本)
sendQueue.push(...split.mediaQueue);
// 标签后的文本
if (split.textAfterLastTag) {
sendQueue.push({ type: "text", content: split.textAfterLastTag });
}
return { hasMediaTags: true, sendQueue };
}
// ============ 发送队列执行 ============
/**
* 统一执行发送队列
*
* 遍历 sendQueue按类型调用对应的发送函数。
* 文本项通过 onSendText 回调处理(不同场景的文本发送方式不同)。
* 媒体发送失败时,通过 onSendText 发送兜底文本通知用户。
*/
export async function executeSendQueue(
queue: SendQueueItem[],
ctx: MediaSendContext,
options: {
/** 文本发送回调(每种场景的文本发送方式不同) */
onSendText?: (text: string) => Promise<void>;
/** 是否跳过 inter-tag 文本(流式模式下通常跳过,由新流式会话处理) */
skipInterTagText?: boolean;
} = {},
): Promise<void> {
const { mediaTarget, qualifiedTarget, account, replyToId, log } = ctx;
const prefix = mediaTarget.logPrefix ?? `[qqbot:${account.accountId}]`;
/** 媒体发送失败时的兜底:通过 onSendText 发送错误文本给用户 */
const sendFallbackText = async (errorMsg: string): Promise<void> => {
if (!options.onSendText) {
log?.info(`${prefix} executeSendQueue: no onSendText handler, cannot send fallback text`);
return;
}
try {
await options.onSendText(errorMsg);
} catch (fallbackErr) {
log?.error(
`${prefix} executeSendQueue: fallback text send failed: ${formatStreamSendErr(fallbackErr)}`,
);
}
};
for (const item of queue) {
try {
if (item.type === "text") {
if (options.skipInterTagText) {
log?.info(
`${prefix} executeSendQueue: skipping inter-tag text (${item.content.length} chars)`,
);
continue;
}
if (options.onSendText) {
await options.onSendText(item.content);
} else {
log?.info(`${prefix} executeSendQueue: no onSendText handler, skipping text`);
}
continue;
}
log?.info(
`${prefix} executeSendQueue: sending ${item.type}: ${item.content.slice(0, 80)}...`,
);
if (item.type === "image") {
const result = await sendPhoto(mediaTarget, item.content);
if (result.error) {
log?.error(`${prefix} sendPhoto error: ${result.error}`);
await sendFallbackText(resolveUserFacingMediaError(result));
}
} else if (item.type === "voice") {
const uploadFormats =
account.config?.audioFormatPolicy?.uploadDirectFormats ??
account.config?.voiceDirectUploadFormats;
const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
const voiceTimeout = 45000; // 45s
try {
const result = await Promise.race([
sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled),
new Promise<{ channel: string; error: string }>((resolve) =>
setTimeout(
() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }),
voiceTimeout,
),
),
]);
if (result.error) {
log?.error(`${prefix} sendVoice error: ${result.error}`);
await sendFallbackText(resolveUserFacingMediaError(result));
}
} catch (err) {
log?.error(`${prefix} sendVoice unexpected error: ${formatStreamSendErr(err)}`);
await sendFallbackText(DEFAULT_MEDIA_SEND_ERROR);
}
} else if (item.type === "video") {
const result = await sendVideoMsg(mediaTarget, item.content);
if (result.error) {
log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
await sendFallbackText(resolveUserFacingMediaError(result));
}
} else if (item.type === "file") {
const result = await sendDocument(mediaTarget, item.content);
if (result.error) {
log?.error(`${prefix} sendDocument error: ${result.error}`);
await sendFallbackText(resolveUserFacingMediaError(result));
}
} else if (item.type === "media") {
const result = await sendMediaAuto({
to: qualifiedTarget,
text: "",
mediaUrl: item.content,
accountId: account.accountId,
replyToId,
account,
});
if (result.error) {
log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
await sendFallbackText(resolveUserFacingMediaError(result));
}
}
} catch (err) {
log?.error(
`${prefix} executeSendQueue: failed to send ${item.type}: ${formatStreamSendErr(err)}`,
);
await sendFallbackText(DEFAULT_MEDIA_SEND_ERROR);
}
}
}
/**
* 从文本中剥离所有媒体标签(用于最终显示)
*/
export function stripMediaTags(text: string): string {
const regex = createMediaTagRegex();
return text
.replace(regex, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/**
* 检测文本中是否有未闭合的媒体标签,如果有则截断到安全位置。
*
* 流式输出中 LLM 逐 token 吐出媒体标签,中间态不应直接发给用户。
* 只检查最后一行,从右到左扫描 `<`,找到第一个有意义的媒体标签片段并判断是否完整。
*
* 核心原则:截断只能截到**开标签**前面;闭合标签前缀若找不到对应开标签则原样返回。
*/
export function stripIncompleteMediaTag(text: string): [safeText: string, hasIncomplete: boolean] {
if (!text) {
return [text, false];
}
const lastNL = text.lastIndexOf("\n");
const lastLine = lastNL === -1 ? text : text.slice(lastNL + 1);
if (!lastLine) {
return [text, false];
} // 以换行结尾,安全
const lineStart = lastNL === -1 ? 0 : lastNL + 1;
// ---- 媒体标签名判断 ----
const MEDIA_NAMES = [
"qq",
"img",
"image",
"pic",
"photo",
"voice",
"audio",
"video",
"file",
"doc",
"media",
"attach",
"send",
"document",
"picture",
"qqvoice",
"qqaudio",
"qqvideo",
"qqimg",
"qqimage",
"qqfile",
"qqpic",
"qqphoto",
"qqmedia",
"qqattach",
"qqsend",
"qqdocument",
"qqpicture",
];
const isMedia = (n: string) => MEDIA_NAMES.includes(n.toLowerCase());
const couldBeMedia = (n: string) => {
const l = n.toLowerCase();
return MEDIA_NAMES.some((m) => m.startsWith(l));
};
/** 截断到 lastLine 中位置 pos 之前,返回 [safe, true] */
const cutAt = (pos: number): [string, true] => [text.slice(0, lineStart + pos).trimEnd(), true];
/** 检查 lastLine 中位置 pos 处的媒体开标签后面是否有完整闭合标签 */
const hasClosingAfter = (pos: number, name: string): boolean => {
const rest = lastLine.slice(pos + 1); // < 之后
const gt = rest.search(/[>]/);
if (gt < 0) {
return false;
}
const after = rest.slice(gt + 1);
return new RegExp(`[<\uFF1C]/${name}\\s*[>\uFF1E]`, "i").test(after);
};
// ---- 回溯状态 ----
// 遇到不完整的闭合标签/孤立 < 时,记录并继续往左找对应的开标签
let searchTag: string | null = null; // 要找的开标签名,"*" = 来自孤立 <
let searchIsClosing = false; // 触发回溯的是闭合类(</、</tag还是开类<
let fallbackPos = -1; // 最右边触发回溯的 < 的位置
for (let i = lastLine.length - 1; i >= 0; i--) {
const ch = lastLine[i];
if (ch !== "<" && ch !== "\uFF1C") {
continue;
}
const after = lastLine.slice(i + 1);
const isClosing = after.startsWith("/");
const nameStr = isClosing ? after.slice(1) : after;
const nameMatch = nameStr.match(/^(\w+)/);
// ======== 回溯模式:正在找对应的开标签 ========
if (searchTag) {
if (!nameMatch || isClosing) {
continue;
}
const cand = nameMatch[1].toLowerCase();
if (!isMedia(cand)) {
continue;
}
// 跳过已有完整闭合对的开标签
if (hasClosingAfter(i, cand)) {
continue;
}
if (searchTag === "*") {
return cutAt(i); // 通配:任何未闭合的媒体开标签都匹配
}
// 精确/前缀匹配(闭合标签名可能不完整,如 </qq 对 <qqvoice
const t = searchTag.toLowerCase();
if (cand === t || cand.startsWith(t)) {
return cutAt(i);
}
continue;
}
// ======== 正常扫描 ========
// --- 无标签名:孤立 < 或 </ ---
if (!nameMatch) {
if (!after) {
// 孤立 <:可能是新开标签,往左找未闭合的媒体开标签
if (fallbackPos < 0) {
fallbackPos = i;
}
searchTag = "*";
searchIsClosing = false;
} else if (after === "/") {
// 孤立 </:闭合标签开始,找不到开标签时原样返回
if (fallbackPos < 0) {
fallbackPos = i;
}
searchTag = "*";
searchIsClosing = true;
}
// 其他(如 "< 3"):非标签,跳过
continue;
}
const tag = nameMatch[1];
const restAfterName = nameStr.slice(tag.length);
const hasGT = /[>]/.test(restAfterName);
// --- 不是媒体标签(也不是前缀) ---
if (!isMedia(tag) && !(couldBeMedia(tag) && !hasGT)) {
continue;
}
// --- 标签未闭合(无 >),还在输入中 ---
if (!hasGT) {
if (isClosing) {
// 不完整闭合标签(如 </voice、</i→ 回溯找开标签
if (fallbackPos < 0) {
fallbackPos = i;
}
searchTag = tag;
searchIsClosing = true;
continue;
}
// 不完整开标签(如 <img、<i→ 截断
return cutAt(i);
}
// --- 标签有 >,是完整的 ---
if (isClosing) {
return [text, false];
} // 完整闭合标签 </tag> → 安全
// 完整开标签 <tag...>,检查后面有无对应 </tag>
if (hasClosingAfter(i, tag)) {
return [text, false];
}
return cutAt(i); // 无闭合 → 截断
}
// ---- 循环结束,处理回溯未命中 ----
if (searchTag) {
if (!searchIsClosing) {
// 来自孤立 <,前面没有媒体开标签 → 截断到那个 < 前面
return cutAt(fallbackPos);
}
// 来自闭合类(</、</tag前面找不到对应开标签 → 不可能是有效媒体标签,原样返回
return [text, true];
}
return [text, false]; // 最后一行无任何 < → 安全
}

View File

@@ -37,7 +37,7 @@ describe("engine/ref/format-ref-entry", () => {
);
expect(formatted).toBe(
'see these [image: photo.png (/tmp/photo.png)] [voice message (content: "spoken words" - platform ASR) (https://example.test/voice.amr)] [file: notes.txt]',
'see these MEDIA:/tmp/photo.png MEDIA:https://example.test/voice.amr (transcript: "spoken words") [source: platform ASR] [file: notes.txt]',
);
});
@@ -49,7 +49,7 @@ describe("engine/ref/format-ref-entry", () => {
attachments: [{ type: "voice", localPath: "/tmp/voice.wav" }],
}),
),
).toBe("[voice message (/tmp/voice.wav)]");
).toBe("MEDIA:/tmp/voice.wav");
});
it("returns an explicit empty marker for blank entries", () => {

View File

@@ -1,9 +1,13 @@
/**
* Format a ref-index entry into text suitable for model context.
*
* Zero external dependencies — pure string formatting.
* Delegates all attachment rendering to the shared
* `utils/attachment-tags.ts::renderAttachmentTags` (with `mode: "ref"`)
* so the quoted-message preview and the current-message history use
* identical wording for identical attachment types.
*/
import { renderAttachmentTags } from "../utils/attachment-tags.js";
import type { RefIndexEntry } from "./types.js";
/** Format a ref-index entry into text suitable for model context. */
@@ -14,39 +18,9 @@ export function formatRefEntryForAgent(entry: RefIndexEntry): string {
parts.push(entry.content);
}
if (entry.attachments?.length) {
for (const att of entry.attachments) {
const sourceHint = att.localPath ? ` (${att.localPath})` : att.url ? ` (${att.url})` : "";
switch (att.type) {
case "image":
parts.push(`[image${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
break;
case "voice":
if (att.transcript) {
const sourceMap: Record<string, string> = {
stt: "local STT",
asr: "platform ASR",
tts: "TTS source",
fallback: "fallback text",
};
const sourceTag = att.transcriptSource
? ` - ${sourceMap[att.transcriptSource] || att.transcriptSource}`
: "";
parts.push(`[voice message (content: "${att.transcript}"${sourceTag})${sourceHint}]`);
} else {
parts.push(`[voice message${sourceHint}]`);
}
break;
case "video":
parts.push(`[video${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
break;
case "file":
parts.push(`[file${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
break;
default:
parts.push(`[attachment${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
}
}
const attachmentTags = renderAttachmentTags(entry.attachments, { mode: "ref" });
if (attachmentTags) {
parts.push(attachmentTags);
}
return parts.join(" ") || "[empty message]";

View File

@@ -44,10 +44,10 @@ export class ApiError extends Error {
* some callers (e.g. the framework-injected `ctx.log`) may not provide them.
*/
export interface EngineLogger {
info: (msg: string) => void;
error: (msg: string) => void;
warn?: (msg: string) => void;
debug?: (msg: string) => void;
info: (msg: string, meta?: Record<string, unknown>) => void;
error: (msg: string, meta?: Record<string, unknown>) => void;
warn?: (msg: string, meta?: Record<string, unknown>) => void;
debug?: (msg: string, meta?: Record<string, unknown>) => void;
}
// ============ Chat Scope ============
@@ -158,23 +158,37 @@ export interface UploadPrepareHashes {
// ============ Stream Message Types ============
/** Stream message input state. */
export enum StreamInputState {
GENERATING = "1",
DONE = "10",
}
/** Stream message input mode (C2C stream_messages API). */
export const StreamInputMode = {
/** Each chunk replaces full message content. */
REPLACE: "replace",
} as const;
export type StreamInputMode = (typeof StreamInputMode)[keyof typeof StreamInputMode];
/** Stream message request body. */
/** Stream message input state (numeric per QQ Open Platform). */
export const StreamInputState = {
GENERATING: 1,
DONE: 10,
} as const;
export type StreamInputState = (typeof StreamInputState)[keyof typeof StreamInputState];
/** Stream message content type. */
export const StreamContentType = {
MARKDOWN: "markdown",
} as const;
export type StreamContentType = (typeof StreamContentType)[keyof typeof StreamContentType];
/** Stream message request body for `/v2/users/{openid}/stream_messages`. */
export interface StreamMessageRequest {
input_mode: string;
input_state: string;
content_type: string;
input_mode: StreamInputMode;
input_state: StreamInputState;
content_type: StreamContentType;
content_raw: string;
event_id?: string;
msg_id?: string;
msg_seq?: number;
index?: number;
event_id: string;
msg_id: string;
stream_msg_id?: string;
msg_seq: number;
index: number;
}
// ============ Inline Keyboard Types ============
@@ -241,6 +255,36 @@ export interface InteractionEvent {
};
}
// ============ Account Config View ============
import type { QQBotDmPolicy, QQBotGroupPolicy } from "./access/types.js";
/**
* Typed view of known per-account configuration fields.
*
* Used for `as QQBotAccountConfigView` casts when reading fields from
* the raw `Record<string, unknown>` config. The actual config type
* stays `Record<string, unknown>` to avoid schema incompatibility.
*/
export interface QQBotAccountConfigView {
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
dmPolicy?: QQBotDmPolicy;
groupPolicy?: QQBotGroupPolicy;
groups?: Record<string, Record<string, unknown>>;
streaming?:
| boolean
| {
mode?: string;
c2cStreamApi?: boolean;
};
audioFormatPolicy?: {
uploadDirectFormats?: string[];
transcodeEnabled?: boolean;
};
voiceDirectUploadFormats?: string[];
}
// ============ Gateway Account ============
/**
@@ -260,7 +304,13 @@ export interface GatewayAccount {
groupAllowFrom?: Array<string | number>;
dmPolicy?: "open" | "allowlist" | "disabled";
groupPolicy?: "open" | "allowlist" | "disabled";
streaming?: { mode?: string };
streaming?:
| boolean
| {
mode?: string;
/** When true, use QQ C2C `stream_messages` for DMs. Boolean `true` is equivalent. */
c2cStreamApi?: boolean;
};
audioFormatPolicy?: {
uploadDirectFormats?: string[];
transcodeEnabled?: boolean;

View File

@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import {
formatAttachmentTags,
renderAttachmentTags,
TRANSCRIPT_SOURCE_LABELS,
type AttachmentSummary,
} from "./attachment-tags.js";
describe("engine/utils/attachment-tags", () => {
// ────────────────────────── shared body (mode-agnostic) ──────────────────────────
describe("shared tag body", () => {
it("returns empty string for missing/empty input", () => {
expect(formatAttachmentTags()).toBe("");
expect(formatAttachmentTags([])).toBe("");
});
it("collapses to MEDIA:{source} when a path/url is present", () => {
expect(formatAttachmentTags([{ type: "image", localPath: "/tmp/a.png" }])).toBe(
"MEDIA:/tmp/a.png",
);
expect(formatAttachmentTags([{ type: "file", url: "https://x/y.pdf" }])).toBe(
"MEDIA:https://x/y.pdf",
);
});
it("inlines voice transcript only for voice attachments", () => {
expect(
formatAttachmentTags([{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi" }]),
).toBe('MEDIA:/tmp/v.wav (transcript: "hi")');
// Non-voice attachments never get the transcript suffix even if one
// is present on the summary.
expect(
formatAttachmentTags([
{ type: "image", localPath: "/tmp/i.png", transcript: "unused" } as AttachmentSummary,
]),
).toBe("MEDIA:/tmp/i.png");
});
it("falls back to bracketed tags when no source is available", () => {
expect(formatAttachmentTags([{ type: "image" }])).toBe("[image]");
expect(formatAttachmentTags([{ type: "image", filename: "a.png" }])).toBe("[image: a.png]");
expect(formatAttachmentTags([{ type: "voice" }])).toBe("[voice]");
expect(formatAttachmentTags([{ type: "voice", transcript: "t" }])).toBe(
'[voice (transcript: "t")]',
);
expect(formatAttachmentTags([{ type: "video" }])).toBe("[video]");
expect(formatAttachmentTags([{ type: "file", filename: "b.pdf" }])).toBe("[file: b.pdf]");
expect(formatAttachmentTags([{ type: "unknown" }])).toBe("[attachment]");
});
it("joins multiple entries with newline in inline mode", () => {
expect(
formatAttachmentTags([
{ type: "image", localPath: "/tmp/a.png" },
{ type: "voice", transcript: "hi" },
]),
).toBe('MEDIA:/tmp/a.png\n[voice (transcript: "hi")]');
});
});
// ────────────────────────── ref mode = body + source suffix ──────────────────────────
describe("ref mode consistency with inline", () => {
it("produces the same body as inline for non-voice attachments", () => {
const att: AttachmentSummary[] = [
{ type: "image", localPath: "/tmp/a.png" },
{ type: "file", filename: "b.pdf" },
];
// Rendered one at a time so separator differences don't matter.
for (const a of att) {
expect(renderAttachmentTags([a], { mode: "inline" })).toBe(
renderAttachmentTags([a], { mode: "ref" }),
);
}
});
it("produces the same body as inline for voice without transcriptSource", () => {
const cases: AttachmentSummary[] = [
{ type: "voice" },
{ type: "voice", transcript: "hi" },
{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi" },
];
for (const a of cases) {
expect(renderAttachmentTags([a], { mode: "inline" })).toBe(
renderAttachmentTags([a], { mode: "ref" }),
);
}
});
it("appends ' [source: …]' ONLY for voice + transcript + transcriptSource in ref mode", () => {
// ref mode: suffix appears.
expect(
renderAttachmentTags(
[{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi", transcriptSource: "stt" }],
{ mode: "ref" },
),
).toBe('MEDIA:/tmp/v.wav (transcript: "hi") [source: local STT]');
// inline mode: suffix NEVER appears, even with transcriptSource set.
expect(
renderAttachmentTags(
[{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi", transcriptSource: "stt" }],
{ mode: "inline" },
),
).toBe('MEDIA:/tmp/v.wav (transcript: "hi")');
});
it("omits the source suffix when transcriptSource is missing (both modes identical)", () => {
const att: AttachmentSummary = { type: "voice", transcript: "hi" };
expect(renderAttachmentTags([att], { mode: "ref" })).toBe(
renderAttachmentTags([att], { mode: "inline" }),
);
});
it("joins with space in ref mode", () => {
expect(
renderAttachmentTags(
[
{ type: "image", filename: "a.png" },
{ type: "voice", transcript: "hi" },
],
{ mode: "ref" },
),
).toBe('[image: a.png] [voice (transcript: "hi")]');
});
});
// ────────────────────────── Prompt-contract regression guards ──────────────────────────
describe("prompt contract", () => {
it("exposes the transcript-source labels table", () => {
expect(TRANSCRIPT_SOURCE_LABELS.stt).toBe("local STT");
expect(TRANSCRIPT_SOURCE_LABELS.asr).toBe("platform ASR");
expect(TRANSCRIPT_SOURCE_LABELS.tts).toBe("TTS source");
expect(TRANSCRIPT_SOURCE_LABELS.fallback).toBe("fallback text");
});
it("uses the single canonical keyword 'transcript:' (never 'content:')", () => {
// If anyone reintroduces 'content:' the regex below will match and fail the test.
const samples = [
formatAttachmentTags([{ type: "voice", transcript: "t" }]),
renderAttachmentTags([{ type: "voice", transcript: "t", transcriptSource: "asr" }], {
mode: "ref",
}),
];
for (const s of samples) {
expect(s).toMatch(/transcript:/);
expect(s).not.toMatch(/content:/);
}
});
it("uses the single canonical type label 'voice' (never 'voice message')", () => {
const samples = [
renderAttachmentTags([{ type: "voice" }], { mode: "inline" }),
renderAttachmentTags([{ type: "voice", transcript: "hi" }], { mode: "ref" }),
];
for (const s of samples) {
expect(s).not.toMatch(/voice message/);
}
});
});
// ────────────────────────── Options ──────────────────────────
describe("options", () => {
it("respects a custom separator", () => {
expect(
renderAttachmentTags(
[
{ type: "image", filename: "a" },
{ type: "video", filename: "b" },
],
{ mode: "inline", separator: " | " },
),
).toBe("[image: a] | [video: b]");
});
it("returns the emptyFallback when input is empty", () => {
expect(renderAttachmentTags(undefined, { mode: "ref", emptyFallback: "(none)" })).toBe(
"(none)",
);
expect(renderAttachmentTags([], { mode: "inline", emptyFallback: "" })).toBe("");
});
});
});

View File

@@ -0,0 +1,174 @@
/**
* Single source of truth for rendering attachment summaries as
* human-readable tags that the LLM sees.
*
* There is exactly ONE vocabulary shared by every consumer:
*
* • Type labels: `image` / `voice` / `video` / `file` / `attachment`
* • Keyword for voice text: `transcript:` (never `content:`)
* • With source: `MEDIA:{source}` (no bracketed alias)
* • Without source: `[{type}]` or `[{type}: {filename}]`
*
* Both consumers (group history / current inbound turn, and the ref-index
* quoted-message block) call the same function with the same vocabulary.
* They differ only on two orthogonal dimensions:
*
* 1. `transcriptSource` — ref mode appends `[source: local STT]` (or
* similar) after a voice transcript so the model knows where the
* text came from. Inline mode omits this (the current turn knows
* its own STT provenance).
*
* 2. Separator — inline joins with `\n` (history replay is multi-line),
* ref joins with a space (quoted block is rendered inline).
*
* These are the ONLY permitted differences between modes. Any new
* decoration must be added in both modes or behind an explicit option
* documented here, otherwise the model ends up learning two dialects.
*
* Zero external dependencies — pure string formatting.
*/
import type { RefAttachmentSummary } from "../ref/types.js";
// ============ Types ============
/** Canonical attachment shape shared by history entries and ref entries. */
export type AttachmentSummary = RefAttachmentSummary;
/**
* Rendering mode.
*
* - `"inline"`: current turn + history replay. No transcript-source tag.
* Tags are separated by newlines.
* - `"ref"`: quoted-message block. Appends `[source: …]` to voice
* transcripts when `transcriptSource` is present. Tags are separated
* by spaces so the block fits on one line.
*/
export type RenderMode = "inline" | "ref";
/** Human-readable labels for transcript provenance (prompt contract). */
export const TRANSCRIPT_SOURCE_LABELS: Record<
NonNullable<RefAttachmentSummary["transcriptSource"]>,
string
> = {
stt: "local STT",
asr: "platform ASR",
tts: "TTS source",
fallback: "fallback text",
};
/** Options controlling how the tag list is rendered. */
export interface RenderOptions {
mode: RenderMode;
/** Separator between tags. Defaults per mode: inline=`\n`, ref=` `. */
separator?: string;
/** Returned when `attachments` is empty/undefined. Defaults to `""`. */
emptyFallback?: string;
}
// ============ Public API ============
/**
* Render a list of attachments into an LLM-facing tag string.
*
* Shared grammar (both modes):
*
* ```
* attachment_with_source := "MEDIA:" SOURCE [voice_suffix]
* voice_suffix := ' (transcript: "' TEXT '")' [source_suffix]
* attachment_no_source := "[" TYPE_LABEL [": " FILENAME] [voice_suffix_bare] "]" [source_suffix_bare]
* voice_suffix_bare := ' (transcript: "' TEXT '")'
* source_suffix := " [source: " LABEL "]" ← ref mode only
* source_suffix_bare := " [source: " LABEL "]" ← ref mode only
* TYPE_LABEL := "image" | "voice" | "video" | "file" | "attachment"
* ```
*
* The **only** mode-dependent decoration is the `source_suffix` (present
* in `ref`, absent in `inline`). Every other token is identical.
*/
export function renderAttachmentTags(
attachments: readonly AttachmentSummary[] | undefined,
options: RenderOptions,
): string {
if (!attachments?.length) {
return options.emptyFallback ?? "";
}
const parts: string[] = [];
for (const att of attachments) {
parts.push(renderOne(att, options.mode));
}
const separator = options.separator ?? (options.mode === "ref" ? " " : "\n");
return parts.join(separator);
}
/**
* Shorthand for `renderAttachmentTags(attachments, { mode: "inline" })`.
*
* Kept as the primary entry point for group history / current-turn
* rendering where the terse inline form is always wanted.
*/
export function formatAttachmentTags(attachments?: readonly AttachmentSummary[]): string {
return renderAttachmentTags(attachments, { mode: "inline" });
}
// ============ Internal ============
/**
* Render a single attachment.
*
* The function is split into two orthogonal concerns:
* - `renderBody`: the shared "MEDIA:{source}…" or "[type…]" string.
* - `renderSourceSuffix`: ref-mode-only `" [source: …]"` tail.
*
* Both consumers produce the same body; only the suffix differs.
*/
function renderOne(att: AttachmentSummary, mode: RenderMode): string {
const body = renderBody(att);
const suffix = mode === "ref" ? renderSourceSuffix(att) : "";
return body + suffix;
}
/** Shared, mode-agnostic body of the tag. */
function renderBody(att: AttachmentSummary): string {
const source = att.localPath || att.url;
const voiceSuffix =
att.type === "voice" && att.transcript ? ` (transcript: "${att.transcript}")` : "";
if (source) {
return `MEDIA:${source}${voiceSuffix}`;
}
const label = labelForType(att.type);
const namePart = att.filename ? `: ${att.filename}` : "";
return `[${label}${namePart}${voiceSuffix}]`;
}
/**
* Ref-mode-only tail that records where a voice transcript came from.
* Empty string when the attachment isn't a transcribed voice message.
*/
function renderSourceSuffix(att: AttachmentSummary): string {
if (att.type !== "voice" || !att.transcript || !att.transcriptSource) {
return "";
}
const label = TRANSCRIPT_SOURCE_LABELS[att.transcriptSource] ?? att.transcriptSource;
return ` [source: ${label}]`;
}
/** Canonical single-word label for each attachment type. */
function labelForType(type: AttachmentSummary["type"]): string {
switch (type) {
case "image":
return "image";
case "voice":
return "voice";
case "video":
return "video";
case "file":
return "file";
default:
return "attachment";
}
}

View File

@@ -3,15 +3,44 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { getPlatformAdapter } from "../adapter/index.js";
import type { SsrfPolicyConfig } from "../adapter/types.js";
import { MediaFileType } from "../types.js";
import { formatErrorMessage } from "./format.js";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-normalize.js";
/** Maximum file size accepted by the QQ Bot API. */
/** Maximum file size accepted by the QQ Bot one-shot upload API (base64 direct). */
export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
/** Threshold used to treat an upload as a large file. */
/** Absolute upper bound enforced on the chunked upload path (matches server policy). */
export const CHUNKED_UPLOAD_MAX_SIZE = 100 * 1024 * 1024;
/** Threshold used to treat an upload as a large file (dispatch to chunked path). */
export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024;
/**
* Per-{@link MediaFileType} upload metadata: the QQ Open Platform size
* ceiling and the Chinese display name used in user-facing error messages.
*
* Keyed by the enum value so call sites read as
* `MEDIA_FILE_TYPE_INFO[MediaFileType.IMAGE].maxSize`, and adding a new
* type forces both fields to be supplied in a single place.
*/
export const MEDIA_FILE_TYPE_INFO: Record<MediaFileType, { maxSize: number; name: string }> = {
[MediaFileType.IMAGE]: { maxSize: 30 * 1024 * 1024, name: "图片" },
[MediaFileType.VIDEO]: { maxSize: 100 * 1024 * 1024, name: "视频" },
[MediaFileType.VOICE]: { maxSize: 20 * 1024 * 1024, name: "语音" },
[MediaFileType.FILE]: { maxSize: 100 * 1024 * 1024, name: "文件" },
};
/** Return the Chinese display name for a media file type code. Defaults to "文件". */
export function getFileTypeName(fileType: number): string {
return MEDIA_FILE_TYPE_INFO[fileType as MediaFileType]?.name ?? "文件";
}
/** Return the upload ceiling for a given media file type. Defaults to 100MB. */
export function getMaxUploadSize(fileType: number): number {
return MEDIA_FILE_TYPE_INFO[fileType as MediaFileType]?.maxSize ?? CHUNKED_UPLOAD_MAX_SIZE;
}
const QQBOT_MEDIA_HOSTNAME_ALLOWLIST = [
// QQ rich media
"*.qpic.cn",
@@ -103,29 +132,50 @@ export function formatFileSize(bytes: number): string {
/** Infer a MIME type from the file extension. */
export function getMimeType(filePath: string): string {
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
const mimeTypes: Record<string, string> = {
".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";
return MIME_TYPES[ext] ?? "application/octet-stream";
}
/** Canonical ext → MIME table. Single source of truth. */
const MIME_TYPES: Record<string, string> = {
".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",
};
/** Extensions accepted as image uploads by the QQ Bot media pipeline. */
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]);
/**
* Return the image MIME type for a local file path, or `null` if the
* extension is not in the supported image whitelist.
*
* Use this instead of `getMimeType` when the caller must enforce
* "image formats only" as a business rule (e.g. constructing a
* `data:image/...;base64,` URL).
*/
export function getImageMimeType(filePath: string): string | null {
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
if (!IMAGE_EXTENSIONS.has(ext)) {
return null;
}
return MIME_TYPES[ext] ?? null;
}
/** Download a remote file into a local directory. */

View File

@@ -1,10 +1,12 @@
/**
* Text chunking — core/ version.
* Text chunking constants and fallback.
*
* The actual chunking logic is provided by the framework runtime
* (`runtime.channel.text.chunkMarkdownText`). This module exposes a
* registerable adapter so core/ modules can call `chunkText()` without
* importing the plugin-sdk runtime store.
* (`runtime.channel.text.chunkMarkdownText`) and injected via the
* outbound dispatch pipeline — NOT via a global singleton.
*
* This module only exports the chunk limit constant and a naive
* fallback splitter for edge cases outside the pipeline.
*/
/** Maximum text length for a single QQ Bot message. */
@@ -13,24 +15,14 @@ export const TEXT_CHUNK_LIMIT = 5000;
/** Text chunker function signature. */
export type ChunkTextFn = (text: string, limit: number) => string[];
let _chunkText: ChunkTextFn | null = null;
/** Register the text chunker — called by the outer-layer startup. */
export function registerTextChunker(fn: ChunkTextFn): void {
_chunkText = fn;
}
/**
* Markdown-aware text chunking.
* Naive text chunking fallback.
*
* Delegates to the registered chunker (framework runtime).
* Falls back to a naive split when no chunker is registered.
* Used only by code outside the outbound pipeline that needs a
* simple split. The real markdown-aware chunking is always done
* via `runtime.channel.text.chunkMarkdownText` inside the pipeline.
*/
export function chunkText(text: string, limit: number = TEXT_CHUNK_LIMIT): string[] {
if (_chunkText) {
return _chunkText(text, limit);
}
// Naive fallback: split by limit without markdown awareness.
const chunks: string[] = [];
for (let i = 0; i < text.length; i += limit) {
chunks.push(text.slice(i, i + limit));

View File

@@ -104,13 +104,17 @@ export interface QQBotAccountConfig {
*/
upgradeMode?: "doc" | "hot-reload";
/**
* Block streaming configuration.
* - mode "partial" (default): enable block streaming for incremental delivery.
* - mode "off": buffer the full response before sending.
* Block streaming + optional QQ C2C official stream API.
* - `true`: same as `mode: "partial"` and `c2cStreamApi: true` (recommended).
* - `false` / omitted: no official C2C stream for this account (see object form for fine control).
* - Object (legacy / advanced): `mode` "partial" | "off", `c2cStreamApi` for C2C `/stream_messages`.
*/
streaming?: {
mode?: "off" | "partial";
};
streaming?:
| boolean
| {
mode?: "off" | "partial";
c2cStreamApi?: boolean;
};
}
/** Audio format policy controlling which formats can skip transcoding. */

View File

@@ -54,7 +54,8 @@ const allowedRawFetchCallsites = new Set([
bundledPluginCallsite("qa-lab", "web/src/app.ts", 21),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 29),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 37),
bundledPluginCallsite("qqbot", "src/engine/api/api-client.ts", 108),
bundledPluginCallsite("qqbot", "src/engine/api/api-client.ts", 124),
bundledPluginCallsite("qqbot", "src/engine/api/media-chunked.ts", 552),
bundledPluginCallsite("qqbot", "src/engine/api/token.ts", 211),
bundledPluginCallsite("qqbot", "src/engine/tools/channel-api.ts", 178),
bundledPluginCallsite("qqbot", "src/engine/utils/stt.ts", 87),