QQ channel for dsh via OneBot 11 (NapCat and friends): reverse or forward WebSocket, DM and group chats, image and voice transcription, t2i text-image cards, merged forwards, and a settings page.
Install
# from npm (prebuilt)
dsh plugin --profile web add dsh-onebot-qq
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:mario841859784/dsh-onebot
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).
README
English | 中文
A QQ channel for DeepSeek Harness. 给 DeepSeek Harness 加上 QQ 通道。
This plugin turns dsh into a QQ bot (OneBot 11 protocol, compatible with NapCat / Lagrange / LLOneBot / go-cqhttp;
since 0.9.12 also compatible with the SnowLuma protocol implementation —
forward mode connects to its wsServers (Bearer header or ?access_token=, zero-config compatible), and the reverse-mode server accepts both the Bearer header and the ?access_token= query parameter),
Like dsh-vision, it ships as an external plugin: zero Python, pure
TypeScript, a native Cordis plugin mounted into the dsh host process, with no core code changes.
User(QQ) ←→ NapCat ←→ dsh-onebot plugin ←→ dsh Agent (one per chat)
├─ Reverse WS server / forward WS client (auto-reconnect)
├─ Inbound: CQ parsing, image download, speech-to-text (STT), reply/forward expansion
└─ Outbound: single/t2i-card send, Markdown stripping, [[qq_forward]], image/voice/video/file tools
Architecture

Interactive version (dark/light theme toggle + guided views): docs/dsh-onebot-architecture.html; vector version: docs/dsh-onebot-architecture-en.svg.
Features
| Category | Capability |
|---|---|
| Connection | Reverse WS (NapCat ws-reverse dials in, default port 8643) or forward WS (plugin dials out, default ws://127.0.0.1:3001); auto-reconnect with backoff (2s → 60s) |
| Inbound | Private/group chats; segment-array-first parsing (CQ string fallback), CQ unescaping, @/reply trigger detection (fail-closed; replies count only when replying to the bot itself); images resolved from 4 sources (url/base64/file/hash) with auto-shrink (long edge ≤ inboundImageMaxPx, GIFs untouched); files received via dual channel (CDN direct link get_private_file_url + get_file base64/url fallback); face id→emoji/card/poke segment types; quoted messages auto-fetched via get_msg; merged forwards auto-expanded via get_forward_msg |
| Voice | ffmpeg to 16 kHz WAV + whisper transcription (openai-whisper / whisper.cpp / custom command); non-blocking: the voice message enters the turn as a [语音] placeholder right away, and the transcript follows as a (语音转写:…) supplement when done (default timeout 60s); failure keeps the [语音] placeholder |
| Text image | t2i card renderer (@napi-rs/canvas): headings/bold/italic/strikethrough/quotes/lists/code blocks/tables/inline code pills/color emoji/CJK punctuation rules; same numbers as the Hermes original (800px/26px/rules/right edge 790) |
| Outbound | Body length ≤ textImageThreshold (default 150) is sent as one message; over the threshold renders a t2i text-image card (AstrBot style: headings/quotes/lists/tables/code blocks/color emoji; render failure, a PNG over outboundImageMaxBytes, or <=0 (card path disabled) falls back to a single plain-text message); Markdown stripped to plain QQ text; [[qq_forward]] merged-forward cards (group/private); live interim messages (interimMessages: each interim text is sent immediately; each is auto-recalled alone after interimRecallMs (default 90s); at turn end the whole turn's interims render into one t2i summary card, the still-on-screen originals are recalled, then the final reply is sent — no turn-end merged forwarding, avoiding unrecallable >2min originals and duplicate cards on long turns; interimRecall: false degrades to send-only: no summary card, no recall); host plan-book/question-card auto-relay (when the model calls exit_plan_mode, the full plan text is sent to QQ; ask_user_question QQ bridge (0.9.11): questions render as QQ option cards, members reply with an option / 答: / 跳过 to send the answer back, settles instantly while waiting, falls to pending after 300s unanswered and can still be answered — see "Group-chat permission tiers & ask_user_question bridge"), typing indicator (set_input_status, private chats only) |
| Commands | Slash commands (admin-only by default; /help shows members the member view): /new fresh session (context cleared, old session kept on disk), /stop stop the current generation, /steer <text> append a supplementary instruction to the running task (usage hint when no task is running; see the command table below and the newMessagePolicy setting), /model view/switch the current session's model (--default changes the deployment default; the bare form renders a two-level numbered list — reply with a number to pick a provider, then a number to pick the model), /workspace view/switch workspace (bare form renders a numbered list; reply with a number to switch), /preset view/switch agent presets (session rebuilt, recorded in the new session's header; bare form numbered), /status session panorama: chat/session/preset/model/cwd/outbound mode/agent status, `/doctor [export [ref] |
| Tools | qq_send_image (≤9 images, path or URL), qq_send_voice, qq_send_video, qq_send_file, qq_send_segments (1–9 plain-text bubbles sent as separate messages, ≤500 chars each, see Segmented sending below), qq_send_forward, qq_napcat_api (14 allowlisted actions), qq_group_history (guarded file editing code_safe_edit/code_safe_rollback/code_list_backups moved to the standalone plugin dsh-safe-edit — see Safe editing below) |
| Permissions | Admin allowlist (ONEBOT_ALLOWED_USERS), dm/group policies (open/allowlist/disabled), group @-mention gating, group-chat permission tiers (0.9.11: each group message's prefix carries the sender's tier ·admin/·member; member-tier turns reject all qq_* tools, permissions.memberTools: all lifts it), restricted users soft limit ([受限用户:仅问答]), outbound sensitive-content auditing |
| Sessions | One persistent Agent per QQ chat (stable derived session id), auto-resumed after restart; mounted into presets/workspaces via agentPreset/agentPresetGroup/workspacePath; mapping flushed to disk at the end of every turn |
| Ops | Hot reload: config overrides and insert additions/removals in the profile-level patch take effect hot — the plugin fiber restarts in place within seconds (the host process is not restarted; cost: one second-level QQ bridge reconnect, in-flight turns interrupted); touch / no-content-change triggers nothing; changes to the bundle's built-in patch, repo templates, deployment-copy patch files, or the root cordis.yml do not hot-reload; temp media TTL cleanup |
| Prompt | Injects QQ platform notes automatically (plain-text output, [图片]/[语音] placeholders for incoming images/voice, tool & command guidance, ask_user_question QQ bridge, host Web approval card exit_plan_mode banned); injected into each QQ chat agent's own scope, invisible to Web sessions |
Compatibility
| Item | Requirement |
|---|---|
| dsh | ≥ 0.1.5-rc.1 (engines.dsh: full 0.1.5/0.1.6/0.1.7 lines + 0.2.0 line ≥0.2.0-rc.1; @deepseek-ai/* peer deps same range, JsonValue provided by dsh-util-values) |
| Node.js | ≥ 22 |
| OneBot 11 impl | NapCat / Lagrange / LLOneBot / go-cqhttp / SnowLuma (reverse or forward WebSocket) |
| Optional deps | Voice transcription needs ffmpeg + whisper CLI; t2i text images need Noto CJK fonts on Linux |
Last verified: 2026-10-12 (0.9.0 release batch W1–W3 five features: v4 command audit / verify health-check script / queue+steer with autoSteerPolicy / deferred delivery on timeout / diagnostic reference IDs — npx tsc --noEmit clean, 532/532 vitest green; M0 security semantics unchanged: reverse mode refuses an empty accessToken and defaults to 127.0.0.1, see the BREAKING note under the config table).
Installation
Prerequisites: dsh (≥0.1.5-rc.1) on PATH; NapCat or another OneBot 11 implementation running.
npm install (published channel — pick this OR the source install below; do not mix)
dsh plugin --profile web add dsh-onebot-qq
(or install from the Plugins page in the web UI.) Then:
Write two lines into
pnpm-workspace.yamlin the profile directory<DSH_HOME>/profiles/web/(pnpm 12 no longer reads a project-level.npmrc, seedocs/npm-e2e-report.mdD1):autoInstallPeers: false hoist: false— this stops pnpm from pulling stale standalone
@deepseek-ai/*copies from the registry. npm/pnpm version difference: this is required for pnpm 12 users only (pnpm 12 no longer reads.npmrc); npm users are unaffected — npm's default peer auto-install pulls registry versions that now satisfy the peer range (docs/npm-e2e-report.md§1 #2). Runtime peer unification is done by the host's resolver layer: any@deepseek-ai/*import declared in the package's peerDependencies is intercepted by the host and redirected to the same instance in the host install tree — no npm overrides needed.Merge the two
insertlines from the bundledcordis.patch.ymltemplate into the profile-level<DSH_HOME>/profiles/web/cordis.patch.yml: main entryname: 'dsh-onebot-qq', host-plane linename: 'dsh-onebot-qq/settings-remote'; config overrides must sit on the top-level line with the same id (see the template header for details).Never hand-write any
@deepseek-ai/*dependency into the profile's package.json — a hand-written copy overrides the host install tree as the interception target, causing a version-skewed dual-package.If the compatibility gate rejects the install (dsh version outside the peer range), use the official escape hatch
dsh plugin allow-version ... --accept-riskto opt in explicitly.
Option 2: install from source (git clone)
git clone <repo> ~/dsh-plugins/dsh-onebot
cd ~/dsh-plugins/dsh-onebot
npm install --include=dev
./scripts/build.sh # link host @deepseek-ai packages + tsc src/ → lib/
A deployment copy must carry the full node_modules link set (produced by
scripts/link-host.sh):filespackages onlylib/, but the runtime peer deps@deepseek-ai/*are symlinked to the host install — copyinglib/alone fails to mount (the plugin entry fails loudly with an explicit "peer dependency resolution failed" error). When the host has nodshon PATH (e.g. the fnOS app form), use the escape hatch:DSH_ROOT=<host node_modules path> ./scripts/build.sh.
Building against an fnOS app-form host: the dsh installed from the fnOS App Center has no dsh CLI on PATH,
so build.sh's auto-discovery (PATH binary → npx store) fails with an explicit cannot locate the dsh install.
Point DSH_ROOT at the host node_modules root explicitly (it must contain @deepseek-ai/; a wrong path errors
out instead of silently falling back):
DSH_ROOT=<fnOS app data dir>/node_modules ./scripts/build.sh
After building, place the deployment copy (lib/ + the full node_modules link set) at the target location and
mount it; a runtime copy missing the link set fails to mount the same way (see above).
Mount it in ~/.dsh/config.yaml (create it if missing):
- insert:
- id: dsh-onebot
name: '$HOME/dsh-plugins/dsh-onebot/lib/index.js'
config:
mode: reverse # reverse = NapCat dials in; forward = plugin dials out
port: 8643
# accessToken: 'token-matching-NapCat' # required: reverse mode refuses to start with an empty token (M0 hardening)
# botQQ: '' # leave empty to learn automatically from meta events
adminUsers: ['<your-QQ-number>'] # required: at least one admin, otherwise private chats & slash commands are unavailable
⚠️ You must configure at least one admin on first setup (
adminUsersor theONEBOT_ALLOWED_USERSenv var):dmPolicy: open(default) only allows admins to DM, and slash commands are admin-only too; with no admin, nobody can talk to the bot. For development you can temporarily setallowAllUsers: true(orONEBOT_ALLOW_ALL_USERS=true) to allow everyone.
Restart dsh (dsh web or however you start it); the log line [dsh-onebot] mounted means it loaded.
NapCat side (required, pick one of two modes):
- reverse mode (NapCat dials into dsh, recommended): in NapCat's network settings add a WebSocket client,
set "report URL" to dsh's WS address
ws://<dsh-host-ip>:<port>/ws(e.g.ws://192.168.1.100:8643/ws), and set "token" to the same value as the plugin'saccessToken; when dsh and NapCat are on different machines127.0.0.1won't work. - forward mode (dsh dials out to NapCat): enable the WebSocket server in NapCat's network settings
(listens on
0.0.0.0:3001by default), set the plugin'surltows://<napcat-host-ip>:3001(ws://127.0.0.1:3001works on the same machine), tokens must match on both sides.
Tokens must match on both sides; for the message report format, choose "array" (the plugin parses segment
arrays first; CQ strings are only a fallback). After configuring, restart dsh. [dsh-onebot] mounted in the log
plus a successful NapCat connection means you're ready.
Deployment requirements: NapCat must be on a LAN reachable from dsh (same subnet / routable).
The WS connection, image downloads and file resolution all depend on this network path; when NapCat and dsh are
not on the same machine, enable the "file-to-URL" switch on the NapCat side so get_file returns a downloadable
http(s) url (otherwise it returns a container-local path the plugin cannot access).
Configuration
The full schema lives in the Config of src/index.ts (schemastery-validated, every key has a
default). Common options:
| Key | Default | Description |
|---|---|---|
mode |
reverse |
reverse/forward |
host / port |
127.0.0.1 / 8643 |
reverse listen address; for cross-machine deployment (NapCat dialing in from another machine), explicitly set this to 0.0.0.0 |
url |
ws://127.0.0.1:3001 |
forward target |
reconnectMaxAttempts |
100 |
reconnect give-up limit: auto-reconnect stops after this many consecutive failures (the log includes the limit and recovery guidance); 0 = unlimited retries (backoff capped at 60s) |
accessToken |
empty | OneBot token; required in reverse mode — the plugin refuses to start when left empty (fail-closed); may stay empty in forward mode |
botQQ |
empty | bot QQ (empty = auto-learned) |
ignoreSelf |
true |
Ignore messages sent by the bot itself (prevents self-loops) |
requireMention |
true |
groups respond to an explicit @ or a reply to the bot; reply-only triggers verify the original author through get_msg.sender.user_id. Replies to others, unknown authors and lookup failures do not trigger (fail-closed). Explicit @bot remains independent of quote lookup |
rateLimitPerMinute |
30 |
per-chat cap on ordinary messages per minute (60s sliding window): over-limit messages are skipped with a rate-limit notice (at most one per window); commands are exempt; 0 disables |
dedupWindowSeconds |
300 |
inbound message dedup window (seconds, W2-②): within the window a redelivered message with the same message_id in the same chat is processed only once; later duplicates are silently skipped with a debug log; applies only to message events that trigger reply handling (notice/meta events never reach this gate); 0 disables dedup |
actionRatePerMinute |
20 |
bridge-wide per-minute cap on proactive writes (W2-③): covers proactive sends initiated by tools such as qq_send_image/voice/video/file/segments and qq_send_forward (in-turn passive replies/interim/notifications are not gated — they are bounded by rateLimitPerMinute and the offline-queue cap); over-limit sends are rejected with a warn and an audit entry; 0 disables the per-minute cap |
actionRatePerDay |
500 |
bridge-wide per-day cap on proactive writes (W2-③, local calendar day): same scope as actionRatePerMinute; over-limit sends are rejected with a warn and an audit entry and the quota resets next day; 0 disables the daily cap |
actionAuditEnabled |
true |
proactive-write audit (W2-③): every proactive write (success/failure) and rejection appends one JSON line to mediaDir/qq-actions.log (ts/chatId/action/ok/reason); audit-write failures only warn and never affect sending |
traceEnabled |
false |
decision tracing (W1): mints a traceId per inbound message and carries it through inbound → agent turn → outbound; decision events (rate limit / dedup / whitelist / @-gate / write-gate rejections and every previously silent drop, each with a Chinese reason) append one JSON line to mediaDir/qq-trace.jsonl (4MiB rename rotation, keeps qq-trace.1/2.jsonl; the same cause is rate-limited to one line per 5 minutes; async queued writes never block the pipeline); default OFF — no traceIds, no file, zero behavior difference vs 0.6.0 |
traceLevel |
debug |
trace event level (W1): debug records every decision event; warn records only ok:false (rejected/failed/dropped) events |
logLevel |
info |
console log gate (debug < info < warn < error): messages below the configured level are dropped. debug prints everything (incl. debug lines); info routine info (mount, connection, meta events — default); warn warnings and errors only; error errors only |
recordInbound |
false |
inbound recording (W1): appends every inbound event — including gate-skipped ones, with the decision reason current at that moment — to mediaDir/qq-inbox.jsonl in a replayable one-JSON-per-line shape (2MiB rename rotation, keeps qq-inbox.1/2.jsonl; injected frames are never re-recorded, so no inject→record→replay feedback loop); pure bypass: write failures only warn (rate-limited) and never affect the reply path |
inboxRedact |
false |
recording redaction (W1): masks every 6+ digit run (QQ/group numbers and similar) to its first 3 digits + **** before the line is written (numeric id fields handled too), so recordings can be shared for offline replay (npm run replay) |
injectEnabled |
false |
event injection channel (W1, DEBUG-ONLY): when on, the bridge polls mediaDir/qq-inject.jsonl every injectIntervalMs and feeds each new line (one OneBot message-event JSON per line) through the real inbound pipeline (trace events tagged stage=inject); lines that already existed at startup are skipped and reported. Trust boundary: injection is a debug channel, disabled by default — do not enable in production |
injectDryRun |
true |
inject dry-run (W1, strongly keep true): every outbound WRITE triggered by an injected round (sends/recalls, including the asynchronous agent turn's final reply — intercepted at the outbound action layer) is counted with its original text in the trace stream and never reaches real QQ; a real message to the same chat clears the interception immediately (never collaterally intercepting real users); false = injected rounds really send, isolated environments only |
injectIntervalMs |
2000 |
inject queue poll interval (ms, W1; minimum 500) |
unknownCommand |
intercept |
unknown slash-command handling: intercept (default) consumes the message and suggests close matches (prefix matches first, edit-distance ≤2 fallback only for inputs of length ≥4, at most 3 candidates; with no match it points to /help or re-sending without the leading /); passthrough restores the old fall-through to the model. Text not starting with a /word token (e.g. a path like /tmp/x) is unaffected |
newMessagePolicy |
queue |
how a new ordinary message is handled while the chat's task is running (P1): queue (default) = the message is processed as the next turn after the current task ends, in arrival order, with at most one queue notice per running turn; steer = a new plain-text message is submitted into the running task as a supplementary instruction (read at the agent's next step) with a submit notice. Exceptions: images / files / voice / quoted or forwarded messages and [受限用户:Q&A only] group-member messages always take the queue path in both modes (the soft limit is never widened and the media pipeline is never bypassed); auto-steer eligibility is governed by autoSteerPolicy; the /steer command works under both modes; without a running task the behavior is identical to 0.8.1 regardless of this setting. Editable in the settings page behavior group; applies after a hot restart |
autoSteerPolicy |
admin |
who may auto-steer under the steer mode (T9, user ruling 2026-10-12): admin (default) = admins only; all = every non-restricted sender. Messages from senders without eligibility take the queue path (enqueue + queue notice); the /steer command stays admin-only regardless of this setting; the queue mode behavior is completely unchanged. Editable in the settings page behavior group; applies after a hot restart |
notices.steerAccepted |
true |
auto-receipt after a supplementary instruction is accepted under steer mode (boolean or string; switch since 0.9.10, custom text since 0.9.13): false or an empty string "" = not sent, the steer is still accepted; true or unset = default text; non-empty string = custom text. The /steer command's confirmation reply: false still sends the default text, a non-empty string uses the custom text there too, an empty string mutes it |
notices.queue |
true |
queue notice, at most one per running turn (boolean or string; switch since 0.9.10, custom text since 0.9.13): false or an empty string "" = not sent, new messages still queue; true or unset = default text; non-empty string = custom text |
notices.answerAccepted |
true |
receipt after a question answer is accepted (boolean or string, since 0.9.13): false or an empty string "" = not sent, the answer is still submitted; true or unset = default text「✔ 已提交回答」; non-empty string = custom text |
notices.deferredTimeout |
true |
wait-for-model-reply timeout notice, threshold set by deferredTimeoutMs (boolean or string, since 0.9.13): false or an empty string "" = not sent, the turn keeps running and is still registered for deferred delivery; true or unset = default text; non-empty string = custom text (replaces the whole sentence, so the waited seconds in the default text no longer appear) |
notices.rateLimited |
true |
"too many messages" notice, at most one per minute (boolean or string, since 0.9.13): false or an empty string "" = not sent, messages over the limit are still dropped; true or unset = default text; non-empty string = custom text |
segments.enabled |
true |
qq_send_segments segmented-sending tool switch (since 0.9.10): false = the tool goes offline (invisible to the model, not callable); true = the tool is available (default, same as pre-0.9.10 behavior) |
segments.intervalMs |
0 |
send interval between adjacent segments of a segmented message in ms (since 0.9.10): the gap between consecutive sends creates a typing "breathing" feel; range 0–3000, out-of-range values are clamped; the intervalMs parameter of the qq_send_segments tool can override it per call (also clamped to 0–3000); 0 = no wait |
deferredTimeoutMs |
600000 |
wait-for-model-reply timeout in ms (T3/P2 deferred delivery): when a turn produces no model output for this long, the plugin replies with a timeout notice and registers the turn into the deferred-delivery registry (the turn keeps running); when the turn finishes, the final text is delivered to the original chat with the「(超时补发)」(deferred delivery) prefix through the existing outbound splitting/t2i rules — final text and terminal-state notices only; approvals / question cards / file tool calls / images are never replayed. Definitive send failures are retried up to 3 times (2s/10s backoff); when the send result is uncertain (action timeout / non-gateway error — the message may still have arrived) the record is kept and auto-retry stops to prevent duplicate messages. The registry is persisted line-by-line as JSONL at mediaDir/deferred-delivery.jsonl (atomic write, per-line corrupt degrade, missing file = fresh start) and pending entries resume tracking after a plugin restart; /stop still only cancels the turn the current chat submitted and also explicitly terminates that chat's deferred tracking (other chats are unaffected); normal (non-timed-out) completion writes nothing and sends nothing extra. Editable in the settings page behavior group; applies after a hot restart; 0 disables |
dmPolicy |
open |
DM policy: open(admins only)/allowlist/disabled |
groupPolicy |
open |
group policy: open(everyone)/allowlist/disabled |
restrictedMemberPrefix |
true |
Prefix non-admin group messages with [受限用户:仅问答] (soft restriction) |
adminUsers |
[] |
admin QQ numbers; or the ONEBOT_ALLOWED_USERS env var. At least one is required, otherwise DMs (dmPolicy=open) and slash commands are unavailable to everyone |
allowFrom / groupAllowFrom |
[] |
allowlisted users/groups |
interimMessages |
true |
send interim text between tool calls immediately; false sends only the final reply |
interimRecall |
true |
interim recall + turn-end summary card switch; false = send-only (degraded: no summary card, no recall) |
sendErrorNotice |
true |
Send a ⚠️ error notice to the user when a turn fails |
sensitivePatterns |
[] |
Outbound sensitive-content audit regexes (built-in rm -rf/shutdown/db-wipe/secret patterns; leave empty to use the built-in defaults) |
sttEnabled |
true |
voice transcription (needs ffmpeg + whisper CLI) |
sttEngine |
auto |
STT engine: auto (auto-detects whisper-cli/whisper/mlx_whisper)/openai/whisper-cpp/custom |
sttModel |
small |
whisper model |
sttCommand |
empty | Program name/path for the custom engine |
sttArgs |
[] |
Argument templates for the custom engine; {file} and {out} are substituted |
sttTimeoutMs |
60000 |
voice transcription timeout in ms (60s default since v0.4.0, previously 300s): on timeout the [语音] placeholder is kept; <=0 falls back to the built-in 60s |
textImageThreshold |
150 |
t2i card threshold: body length ≤ this is sent as one message; > this renders a text-image card. Render failure, a PNG over outboundImageMaxBytes, or <=0 (card path disabled) falls back to a single plain-text message |
cardFooter |
dsh |
card footer brand ("Powered by ") |
fontFiles / fontFamilies |
[] |
t2i font file/family overrides (Linux deployments: install Noto CJK, see below) |
monoFamilies |
[] |
monospace family names the t2i renderer prefers for code text (code blocks / inline code, since 0.9.10): tried before fontFamilies and platform defaults; CJK body text is unaffected. Common on Windows: ["Cascadia Code", "Consolas"] |
mediaDir |
<dsh-home>/media/onebot |
inbound media / mapping file directory |
tempTtlHours |
6 |
Retention for inbound temporary media files (hours), auto-cleaned on expiry |
inboundImageMaxPx |
2048 |
inbound image long-edge limit (px): larger images are proportionally shrunk before reaching the vision model (transparent PNGs preserved, GIFs untouched); <=0 disables (old name imageMaxSize deprecated, still honored this release) |
outboundImageMaxBytes |
8388608 |
outbound image size cap (bytes): an oversized t2i summary card falls back to plain text (old name maxImageBytes deprecated, still honored this release) |
maxVoiceBytes |
15728640 |
Outbound voice size cap (bytes) |
maxFileBytes |
20971520 |
Outbound video/file size cap (bytes) |
inboundFileMaxBytes |
20971520 |
inbound QQ file size cap (bytes): oversized file segments are rejected with a notice (old name maxInboundFileBytes deprecated, still honored this release) |
allowPrivateHosts |
false |
allow downloads from private/loopback addresses (skips only the private-network check; the protocol allowlist and size limits still apply); enable only in trusted setups such as a local reverse proxy |
fileRefRoots |
[] |
root directories that inbound file:// refs may be copied from; an empty array (default) refuses every inbound file:// ref. Paths are checked with realpath and must fall inside one of these roots; a refusal logs one warning. This only affects OneBot implementations that return images, voice or video as file://; file segments (fetched through get_file and the file-URL actions), http(s) URLs, base64 and local paths without the file:// prefix never reach this check, so setting it has no effect on them |
allowCidrs |
[] |
optional CIDR allowlist (e.g. 100.64.0.0/10, fd7a:115c:a1e0::/48): only these private ranges pass, other private addresses are still refused; an empty array keeps the original behavior; a malformed entry fails at startup |
agentPreset |
empty | agent preset for sessions (empty = default) |
agentPresetGroup |
empty | agent preset id for group-chat sessions (coarse tier, since 0.9.11): when set, group-chat sessions prefer it while private chats keep agentPreset; empty = same preset for group and private. A per-chat /preset override still wins |
permissions.memberTools |
none |
tool permission for the member tier (non-admins, since 0.9.11): none (default) = all qq_* tool calls are rejected outright in member turns (hard plugin-side gate, no model cooperation needed); all = unrestricted. A missing config fails closed to none; callers with no QQ session mapping (Web and other channels) are outside this tier |
workspacePath |
empty | workspace for sessions (empty = host process cwd; when unconfigured the plugin warns once at startup recommending an explicit value — the host exposes no programmable default-workspace query). The per-chat /workspace override is persisted to the mapping file and survives restarts |
chatIdleEvictDays |
7 |
idle-session eviction (days): when a chat has had no activity for longer than this, its in-memory agent is cleaned up before the next inbound message is processed (the session is flushed to disk first and the mapping is kept, so a later message from the same chat resumes the original session); 0 = disabled |
⚠️ BREAKING (M0 security hardening): in reverse mode an empty
accessTokenrefuses to start (fail-closed); thehostdefault changed from0.0.0.0to127.0.0.1(loopback only) — cross-machine deployments must explicitly configurehost: 0.0.0.0.
Env vars: ONEBOT_ALLOWED_USERS (comma-separated admins), ONEBOT_ALLOW_ALL_USERS=true (development).
Reply-trigger lookup cost and diagnostics
With requireMention enabled, each valid reply without an explicit @ triggers one get_msg lookup after the group-policy and dedup gates, provided the bot identity is known. This includes members replying to one another. Accepted replies reuse the response for quote expansion. The lookup precedes the inbound message rate limit, so rateLimitPerMinute does not cap these queries. There is no cross-message cache; caching can be considered if high-traffic groups reveal a bottleneck.
Reply IDs must be exactly representable decimal integers (negative IDs are supported); invalid IDs skip the query. When tracing is enabled, the mention stage distinguishes invalid IDs, failed lookups or empty responses, missing authors, non-bot authors and ordinary unmentioned messages. For explicit @, private messages and disabled mention gating, an invalid quote skips only its expansion, not the message itself.
Record · Replay · Inject · Healthcheck (W1)
| Capability | Switches (default) | Usage |
|---|---|---|
| Inbound recording | recordInbound (false) + inboxRedact (false) |
When on, every inbound event is appended to mediaDir/qq-inbox.jsonl as one replayable JSON line (gate-skipped events carry the decision reason; 2MiB rename rotation); pure bypass — write failures only warn (rate-limited) |
| Offline replay | no switch needed | After npm run build: npm run replay -- <inbox.jsonl> [--media-dir <dir>] [--limit <n>] [--require-mention] [--bot-qq <qq>] [--admin <qq>] [--out report.json] — feeds the recording through the real inbound pipeline (forced dry-run: no websocket, all outbound writes intercepted and counted) and prints each message's traceId + decision-chain summary; does not require the host process. Replay gates come from the CLI args (fully open by default) — pass the production policy to reproduce its drop decisions |
| Event injection | injectEnabled (false) + injectDryRun (true) + injectIntervalMs (2000, min 500) |
DEBUG-ONLY: append one OneBot message-event JSON line to mediaDir/qq-inject.jsonl (a copied qq-inbox.jsonl line works), the bridge polls and feeds it through the real pipeline; under dry-run every outbound write of the round (including the async agent reply) is intercepted with its original text in the trace stream (stage=inject) and never reaches real QQ. Lines that predate startup are skipped and reported |
| Healthcheck + diagnostics | no switch needed | Send /doctor in QQ (admin only, equivalent alias /healthcheck): connection / retry+self-heal / dedup & write-gate counters / trace·inbox·audit file sizes / the 5 most recent ok:false events / recent fault references; /doctor export [ref] packs trace + inbox + actions log + a redacted config snapshot into mediaDir/qq-diagnostics-<ts>[-<ref>].zip (an omitted ref falls back to the latest recorded fault reference; tokens force-scrubbed, with a fail-closed final byte scan) |
| Fault references | no switch needed | Connection/bridge faults mint a QQ-CONN-<ts><rand> reference, message faults mint MF-<ts><rand> (generated on fault events only — normal traffic pays zero cost; a reference itself never contains user message content). One reference spans four surfaces: the ⚠️ notice tail, the host log line (故障参考号 …), the trace event's data.ref, and the /doctor report's recent-references section; the exported archive file name carries the triggering reference |
Settings page
The dsh Web GUI settings page exposes this plugin's configuration visually (a host-plane Remote
onebotSettings that writes the config override of the dsh-onebot entry in the profile-level
cordis.patch.yml — the same persistence layer as a hand-edited patch and the host's native settings page).
Three groups, 19 keys
| Group | Keys |
|---|---|
| connection | mode, host, port, url, accessToken, botQQ |
| permissions | requireMention, adminUsers, dmPolicy, groupPolicy, allowAllUsers, allowFrom, groupAllowFrom |
| behavior | interimMessages, interimRecall, interimRecallMs, sendErrorNotice, unknownCommand, rateLimitPerMinute, newMessagePolicy, autoSteerPolicy, deferredTimeoutMs |
accessToken is a password-style input in the settings page; snapshots redact it on return (no plaintext echo);
the write side still lands in the patch file as plaintext.
Priority vs. patch config
schema defaults ← bundle/base patch (inherited layer) ← profile override line (what the settings page writes);
within the same layer, later patch entries win. Settings-page keys and a hand-edited patch are the same line in
the same layer — there are no two competing priorities. Keys outside the 19 (media/STT/performance/
agentPreset and other low-frequency keys) get no UI; hand-edit the patch in the same config line.
How changes take effect
Every save (settings page or a hand edit of the same override line) = the plugin fiber restarts in place within seconds; the host process and Web GUI are not restarted. Cost: one second-level QQ bridge reconnect, in-flight turns interrupted, rate-limit windows and interim-message buffers cleared (session mappings/history are unaffected). A save identical to the current override line is a no-op and triggers no restart; if a write makes the plugin fail to activate, the patch file rolls back automatically and the old config keeps running.
Session workspace selection
Each QQ session picks its working directory at creation time, in this order (written into the session meta and frozen for the session's lifetime):
- The chat's
/workspaceoverride (per-chat, survives/newresets) - The configured
workspacePath - The host process cwd (
process.cwd())
/workspace <dir> switches directories (realpath + directory check): it records the override and retires the
current agent, so the next message rebuilds the session under the new directory. The old session stays on disk.
The bare /workspace form renders a numbered list (the current directory is marked); reply with
/workspace <number> to switch without retyping the path; /workspace list lists all workspace records.
The per-chat /workspace override is persisted to the mapping file (chat-sessions.json, additive format that
stays compatible with older files): it survives restarts and failed session resumes, and a /new keeps it too —
as long as the chat uses a non-default directory, /workspace and new sessions keep using it after a restart.
Sessions attach to GUI workspaces as follows: a new workspace is auto-created only when the session cwd equals
the configured workspacePath (or the host cwd when unset); legacy sessions carrying a foreign cwd are attached
only when a workspace already owns that path, never auto-created.
Slash commands quick reference (admin-only by default; /help shows members the member view)
| Command | Purpose |
|---|---|
/new |
Start a fresh session (context cleared, old session kept on disk) |
/stop |
Stop the current generation |
/steer <text> |
Append a supplementary instruction to the running task (the agent reads it at its next step and a submit notice is sent); available under both newMessagePolicy modes; with no running task or an empty argument it replies with the usage hint (never crashes, never silent) |
/model [--default] <provider> <model> |
View/switch the current session's model; --default changes the deployment default. The bare form renders a two-level numbered list: reply with a number to pick a provider, then a number to pick the model |
/workspace [path|number|list] |
View/switch workspace; bare form renders a numbered list (current dir marked), reply /workspace <number> to switch; /workspace list lists all records |
/preset [id|number] |
View/switch presets (bare form numbered, reply with a number to pick); switching rebuilds the session and records the preset in the new session's header |
/session [number] |
List the switchable historical sessions of this chat (bare form numbered; each entry has a content preview, created and retired timestamps, truncated id; unreadable entries show "(content unreadable)" without affecting the rest). Reply with a number to switch back: the current session retires into the list, the target session is restored with its history (including per-chat settings); you can switch back and forth. The list persists across restarts (switchable-sessions.json, max 20 per chat) |
/status |
Session panorama: chat/session/preset/model/cwd/outbound mode/agent/switchable count |
| `/doctor [export [ref] | test]` |
/retry |
Rerun the last user message (retry after a failed turn; a failed retry replies with a ⚠️ notice carrying an MF-… reference) |
/id |
chat/session/cwd only (for troubleshooting) |
/ver |
Plugin version + git commit |
/ocr |
OCR the latest inbound image of this session (NapCat ocr_image) |
/mode [interim|instant] |
Switch this session's outbound mode (per-chat override, persisted across restarts) |
/plan [off|text] |
Host plan mode (/plan enters; /plan off exits directly with no Web approval card; /plan <text> enters and processes the text) |
/permission [w|f|preset|number] |
Switch the host permission preset (sandbox mode + approval policy, applies immediately): w/ws/write→workspace-write (workspace-writable + approvals required), f/full/danger→danger-full-access (full read/write, no approvals); full preset names are passed through as-is (unknown names make the host error and list the available ones); bare form renders a Chinese permission menu; numeric indexes resolve against the host's available list in order (not snapshotted) |
/goal [goal|clear] |
Record/update this session's goal (auto-attached as a reminder each turn, persisted across restarts) |
/silent [minutes] |
Temporary do-not-disturb (since 0.9.11, admin only): /silent <minutes> mutes for 1–1440 minutes (default 30); ordinary messages are ignored while slash commands still work; a bare /silent lifts it early. The window is persisted into ChatSettings (chat-sessions.json) and survives both /new and plugin restarts — muting is a chat-level property, not session state |
/help |
Grouped command card (▍sessions/output/queries/actions/other), each entry with usage; error hints always include usage or the next step. Since 0.9.11 members can call it too: non-admins receive the member view (only commands usable by members, plus a "the rest are admin-only" tail; currently just /help itself), admins still get the full view |
/preset switching is an in-process per-chat override (survives /new): the next message rebuilds the session
with the new preset written into the header, and a restart-resume restores it from the record. The per-chat state
of /mode and /goal is persisted into the mapping file (since v0.4.0, across restarts), as is the /workspace
override; /plan remains an in-process override that falls back to the default on restart.
The /session switchable list persists per chat in the media directory's switchable-sessions.json (same
read/write discipline as retired-sessions.json: missing file = fresh, corrupted file kept in memory, atomic
writes; max 20 per chat, deduped, newest first). Each entry renders a content preview (the first real user input
of that session's event log: kind 'user' or QQ messages authored by this plugin first, falling back to any
user/message; all text blocks joined, the QQ inbound <user_message> wrapper and trusted prefixes outside the
join boundary ([受限用户:仅问答] / [HH:MM nickname(QQ)] — incomplete wrappers or empty bodies fall back
verbatim) stripped, folded to newlines and truncated to ≤40 code points, never splitting an emoji; with no user
input found it shows "(no conversation content)"), the created time (the session header's createdAt, omitted if
unavailable), the retired time, and a truncated id (first 8 + … + last 8 chars, keeping both ends recognizable).
Previews come from a cold read of the first 24 events via sessionPersistence's read handle (no write ownership,
closed right after reading); a missing/corrupted/unreadable log only degrades that entry to "(content unreadable)",
the rest and the switch-back feature are unaffected. After a successful switch the target session is un-retired;
restart-resume and post-eviction reactivation find it through the normal path. If resuming the target fails, its
id is hard-retired immediately and removed from the list, and the chat falls back to creating a new session on
the next message — it never gets stuck. Sessions retired due to corruption (collision/resume failure) never
enter the list and can never be switched back to.
Serial-number replies resolve against a snapshot of the list the command just printed and stay valid for
5 minutes (an expired snapshot asks you to list again); a purely numeric argument is read as an index only while
such a list is live, otherwise the original parameter semantics apply. Exception: /permission numeric indexes
are not snapshotted — they resolve live against the host's available list each time, with out-of-range or
unparseable values falling back to the usage hint. Unknown slash commands are intercepted by default with
close-match suggestions; unknownCommand: passthrough passes them to the model instead.
Safe editing (code_safe_edit)
Guarded host-file editing is provided by the standalone plugin dsh-safe-edit (~/dsh-plugins/dsh-safe-edit/,
split out of this plugin on 2026-08-18, registered globally across all channels). Three tools; the approach
draws on irmia_devkit_open's safe_edit (AGPL-3.0, an
independent clean TypeScript implementation). Tools and boundaries: see the dsh-safe-edit repo/docs:
code_safe_edit: read → path allowlist → auto backup → match (exact → strip line-number prefix → whitespace-aligned / Aider-style) → replace → syntax check (node --checkfor js/cjs/mjs) → auto-rollback on failurecode_safe_rollback/code_list_backups- Boundaries follow the session sandbox policy:
danger-full-accessunrestricted,workspace-writelimited to the session workspace,read-onlydenied; with no policy service it falls back tosafeEditRoot(default/Users/mario/workspace) - The model may prefer this tool (the
code-safe-editskill guides all channels; the QQ platform notes guide built-in read/edit conventions without binding to a specific tool name)
Segmented sending (qq_send_segments)
The qq_send_segments tool lets the model send several independent plain-text bubbles to the
current QQ chat in order (one message per segment), for content that explicitly benefits from
being split into separate bubbles (step-by-step guides, lists):
segments: array of strings, 1–9 segments, each ≤500 chars and non-empty; sent in order;- optional
chat_id: target chat (private:<QQ id>orgroup:<group id>); omitted = current chat; - it does not auto-split ordinary replies — plain output goes through the existing t2i card / auto-split path; use this tool only when separate bubbles are explicitly wanted;
- breathing feel (since 0.9.10): the interval between adjacent segments is controlled by the plugin
config
segments.intervalMs(0–3000, default0= no wait); the optional tool parameterintervalMsoverrides it per call (also clamped to 0–3000); with the settings-page switchsegments.enabled(defaulttrue) set tofalsethe tool goes offline; - it is a proactive write and is gated by
actionRatePerMinute.
dm / group access policies (pick on first setup)
Private (dmPolicy) and group (groupPolicy) chats each have three options:
| Option | dmPolicy (private) | groupPolicy (group) |
|---|---|---|
open |
Admins only can DM (adminUsers/ONEBOT_ALLOWED_USERS; with allowAllUsers: true everyone can) |
All groups can chat (messages gated by requireMention: @ or reply-to-bot required; group members get the [受限用户:仅问答] soft limit) |
allowlist |
Only the allowFrom QQ numbers can DM (admin not required) |
Only the groupAllowFrom groups can chat |
disabled |
All DMs rejected |
…
Links
More in this category
xmanrui/dsh-im★ 3118
Connect IM bots to DeepSeek Harness via QR codes or bot credentials (9 channels: Feishu, WeChat, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp).
shaobeichen/dsh-pocket★ 2685
Remote phone access to the DSH Web UI: scan a QR code for LAN or public (cloudflared tunnel) access with real-time sync, a mobile-adaptive layout, and a settings tab.
OpenPetsHQ/openpets#dsh★ 1373
Bridges DeepSeek Harness lifecycle status, errors, and approval requests to a locally running OpenPets desktop companion.
c1overzz/dsh-bili-scroll★ 1150
Let your DSH agent browse Bilibili on its own, read subtitles, write a daily digest, and @ you when it finds something worth your attention.
inclusionAI/Avernet#deepseek-harness-channel-bcn★ 599
Connects DeepSeek Harness to Avernet's Bot Collaboration Network over WebSocket V2, with automatic onboarding, isolated agent sessions, tool-call events, and multi-bot routing tools.
zaofan-make/dsh-qqbot★ 213
QQ official bot channel for DeepSeek Harness (feature-rich fork of tencent-connect/dsh-qqbot): group and private chat with the full agent, sticker library with auto-tagging and AI meme replies, group management (join approval and mute), botplay interactive card events with paginated catalogs, user extension registry (custom slash commands and QQ tools), scheduled wakeups, remote approvals over QQ, and a floating dock web UI.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.