QQ (OneBot)
中文版见
README.zh-CN.md· Chinese readme:README.zh-CN.md
The OneBot adapter connects Hermes to QQ through the OneBot 11 protocol, compatible with NapCat, Lagrange, LLOneBot, and go-cqhttp. Instead of the official QQ Bot platform (which requires a Tencent-approved app), it drives a regular QQ account through a local bridge. Good for personal bots and groups the official platform can't reach.
User (QQ) ←→ NapCat ←→ Hermes onebot adapter ←→ Hermes agent
├─ reverse WS server / forward WS client (auto-reconnect)
├─ inbound: CQ parsing, image download, voice STT, quote / forward expand
└─ outbound: split sends, markdown strip, [[qq_forward]], image/voice/video/file
Run
hermes gateway setupand pick QQ (OneBot) for a guided walk-through.
Feature overview
| Area | Capability |
|---|---|
| Connection | reverse WS (NapCat ws-reverse dials in, default port 8643) or forward WS (dial out, default ws://127.0.0.1:3001); auto-reconnect |
| Inbound | private/group chats, segment-array parsing first (CQ-string fallback), CQ unescaping, @/reply trigger detection (fail-closed), image resolution (url/base64/file/hash via get_image) + downscale, file/voice/video/face/json/poke segment types, quote original message (get_msg), file dual-channel receive (CDN direct get_private_file_url + get_file base64/url fallback), poke-notice reply when the bot itself is poked (opt-in, poke_reply) |
| Voice | ffmpeg → 16 kHz mono WAV → Hermes STT pipeline; voice without a URL fetched via get_record (base64); failure degrades to [语音] |
| Text image | AstrBot-style t2i card renderer: headings / bold / italic / strikethrough / quote / list / code block / table / inline-code pill / color emoji / CJK punctuation rules; 800 px wide |
| Outbound | sentence-boundary split (default ≤100 chars), >150 chars rendered as a t2i card, markdown stripped to plain text, [[qq_forward]] merged forwarding, loop interim merge + recall (turn-end "本轮进展" summary card, 60 ms recall spacing), typing indicator (private chats) |
| Commands | admin-only local slash commands: /ocr / /mode / /id / /ver / /approve / /reject; other slashes keep flowing to the gateway core |
| Tools | qq_send_image (≤9, path or URL), qq_send_voice / qq_send_video / qq_send_file / qq_send_forward, qq_napcat_api (15-action whitelist), qq_group_history; HTTP /api/napcat + /api/send_media; available in CLI/TUI too via provides_tools |
| Permissions | admin allowlist (ONEBOT_ALLOWED_USERS), dm/group policy (open/allowlist/disabled), group mention gating, restricted-member [受限用户:仅问答] soft limits, outbound sensitive-intent audit |
| Ops | hot reload for onebot_utils.py / t2i_render.py (extra.hot_reload, no gateway restart), temp-media TTL cleanup, startup t2i ink check (font chain self-test) |
Compatibility
| Item | Requirement |
|---|---|
| Hermes | gateway with the onebot platform enabled |
| OneBot 11 bridge | NapCat / Lagrange / LLOneBot / go-cqhttp (reverse or forward WebSocket) |
| Optional deps | ffmpeg on the Hermes host for voice transcription (the adapter probes it once at startup and logs a WARNING — voice STT stays off until you install it); an STT backend configured under stt: (local faster-whisper, auto-downloaded on first use, or an OpenAI-compatible API); CJK fonts for text-image cards (see below) |
Install (standalone repo)
This plugin lives in its own repo — install it with the Hermes plugin manager instead of copying files by hand.
Prerequisites
- Hermes v0.22.2 or newer — the plugin dependency consent flow described below is part of that release.
- A OneBot 11 bridge (NapCat / Lagrange / LLOneBot / go-cqhttp) that Hermes can reach and that points at this plugin's listener (reverse WS dials into
host:port; forward WS dials out), with the bridge's access token matching the plugin'saccess_token. Walk-through: the Configure Hermes and NapCat-side setup sections below.
Installing
hermes plugins install https://github.com/mario841859784/hermes-plugin-onebot.git
Prefix with HTTPS_PROXY=... when behind a proxy, as usual.
Python dependencies & the consent gate. Dependencies are declared in plugin.yaml (python_dependencies: ["fonttools>=4,<5"]) — only the text-to-image tier of long replies needs it (CJK system fonts for the rendered cards: see Font dependencies below). On an interactive terminal the installer lists them and asks y/N before installing anything; what you accept goes into Hermes' shared PM environment (the environment the gateway itself runs on), not into the plugin's own directory. No manual pip steps.
Headless / SSH (non-interactive). Pass --yes-deps to answer the dependency question up front:
hermes plugins install --yes-deps https://github.com/mario841859784/hermes-plugin-onebot.git
Without --yes-deps, a non-interactive install leaves the plugin installed but disabled — dependencies are never pulled in silently (fail-closed). Declining the y/N interactively lands in the same state; finish the install later with hermes plugins enable onebot-platform.
Updating
- Interactive:
hermes plugins update onebot-platformpulls the latest revision. If the new version declares Python dependencies the old one didn't, the samey/Nconsent gate runs again — an unattended update that would add dependencies is refused rather than consented on your behalf. - Pinned checkouts (installed with
--ref <sha>) refuseupdate; the error prints theinstall --force --ref <sha>command that moves the pin. In a headless environment, reinstall the same URL instead:hermes plugins install --yes-deps --force https://github.com/mario841859784/hermes-plugin-onebot.git(add--ref <40-character commit SHA>to stay pinned).
Verify
hermes plugins list # onebot-platform should show status "enabled" + its version
hermes plugins validate ~/.hermes/plugins/onebot-platform # self-check (path = installed plugin dir)
If a declared dependency is missing when the gateway loads, the loader logs a WARNING naming the package (and hermes pm repair as the fix) — it does not fail silently. Plugin discovery never installs dependencies; a repair or reinstall does.
Maintained by mario841859784.
Configure Hermes
Add the platform to the gateway block in ~/.hermes/gateway-config.yaml:
gateway:
platforms:
onebot:
enabled: true
extra:
mode: reverse # reverse | forward
host: "127.0.0.1" # listen address (reverse: WS+API; forward: /api-only server); 0.0.0.0 requires access_token
port: 8643 # listen port (reverse: WS+API; forward: /api-only server)
# url: "ws://127.0.0.1:3001" # forward: bridge ws endpoint
# access_token: "" # must match the bridge's token; REQUIRED when host is 0.0.0.0
# bot_qq: "" # optional; auto-learned from meta events
require_mention: true # groups: only reply when @'d
dm_policy: open # open | allowlist | disabled
allow_from: [] # user ids when dm_policy=allowlist
group_policy: open # open | allowlist | disabled
group_allow_from: [] # group ids when group_policy=allowlist
admin_users: [] # admins; falls back to ONEBOT_ALLOWED_USERS
split_length: 100 # long replies split at this many chars
text_image_threshold: 150 # longer replies render as a text image
image_max_size: 2048 # downscale inbound images (0 = keep as-is)
poke_reply: false # light reply when the bot itself gets poked
Extra keys (all optional)
| Key | Default | Meaning |
|---|---|---|
mode |
reverse |
reverse (bridge dials in) / forward (adapter dials out) |
host / port |
127.0.0.1 / 8643 |
reverse: WS + /api/* listener; forward: /api/*-only server (no /ws). Non-loopback hosts like 0.0.0.0 require access_token |
url |
ws://127.0.0.1:3001 |
forward target |
access_token |
empty | OneBot token, must match the bridge |
bot_qq |
empty | bot's QQ (empty = learned from meta events) |
require_mention |
true |
groups: only respond when @'d or replying to the bot (undeterminable replies fall back to responding) |
dm_policy |
open |
open (admins only) / allowlist / disabled |
allow_from |
[] |
allowed user ids when dm_policy=allowlist |
group_policy |
open |
open / allowlist / disabled |
group_allow_from |
[] |
allowed group ids when group_policy=allowlist |
admin_users |
[] |
admin QQ ids; falls back to ONEBOT_ALLOWED_USERS |
split_length |
100 |
long-reply split threshold |
text_image_threshold |
150 |
t2i card threshold; <=0 disables the card path |
image_max_size |
2048 |
inbound image long-edge cap (px); 0 keeps originals |
max_inbound_file_bytes |
20971520 (20 MB) |
inbound file size cap; larger files degrade to a [文件:name] marker |
interim_recall_seconds |
90 |
auto-recall timeout for unsettled interim messages (0 disables) |
hot_reload |
false |
dev only: reload onebot_utils.py / t2i_render.py on mtime change (saves a gateway restart while iterating styles; keep off in production, an in-place write during upgrade can reload a half-written module) |
poke_reply |
false |
reply with a light hint (@我 / /help) when the bot itself is poked (notify/poke notice); member-to-member pokes are ignored; per-chat 60s cooldown |
Environment variables: ONEBOT_ALLOWED_USERS (comma-separated admin ids), ONEBOT_ALLOW_ALL_USERS=true (dev only), and the global GATEWAY_ALLOW_ALL_USERS.
dm_policy: open+ allow-all env:ONEBOT_ALLOW_ALL_USERS=true(orGATEWAY_ALLOW_ALL_USERS=true) is the explicit opt-in that makesopenactually open to non-admins. Without it,openmeans admins only: the adapter rejects non-admin DMs at intake, before the gateway's allow-all check would ever run.
Connection modes
| Mode | Description |
|---|---|
reverse (default) |
Hermes hosts a WebSocket server; the bridge's ws-reverse client dials in (ws://<hermes-host>:8643/ws). One connection carries both events and actions. |
forward |
Hermes dials the bridge's WebSocket server (ws://<bridge-host>:3001 for NapCat's default). Also serves the qq_* tools via a local /api/* server (see below). |
If the bridge uses an access token, set the same value in access_token (Hermes sends it as Authorization: Bearer <token> on the reverse connection; forward mode includes it in the handshake headers).
Tool availability in forward mode
Forward mode also serves the qq_* tools: once the forward WS connection is
up, the adapter starts a local HTTP server on host:port that registers only
the /api/* helper endpoints (/api/group_history, /api/napcat,
/api/send_media) — the reverse-only /ws route is not registered. The
server reuses the same host / port keys as reverse mode (code default
127.0.0.1:8643, matching the qq_* tools' default base URL
http://127.0.0.1:8643), and its endpoints sit behind the same auth gate
described below. If you customize host / port, set ONEBOT_TOOL_BASE
accordingly (e.g. http://127.0.0.1:9000) — the qq_* tools derive their base
URL from that environment variable only, not from the plugin config. Reverse
and forward are mutually exclusive: each adapter
instance runs exactly one of the two servers, never both.
Token & network security
The local helper endpoints /api/group_history, /api/napcat and
/api/send_media (used by the qq_* model tools) hang off the same
host:port — alongside /ws on the reverse listener, on their own in
forward mode — and all of them sit behind one shared auth gate:
access_tokenset: every request to/wsand/api/*must carryAuthorization: Bearer <token>; anything else gets401.access_tokenempty: only loopback clients (127.0.0.1/::1— the whole127.0.0.0/8range and IPv4-mapped loopback forms included) are served; requests from other addresses get401and a WARNING is logged per request. Default127.0.0.1deployments are unaffected.
If host is set to 0.0.0.0 (or any other non-loopback address), access_token
is REQUIRED — the adapter now refuses to start without it (v1.2.0; previously
a warning only). Without a token, any host that can reach the port can connect
to /ws as if it were your bridge, while the local helper endpoints stay
locked to loopback — leaving a 0.0.0.0 deployment unauthenticated for the
very protocol it was bound for. LAN deployments (Hermes and NapCat on
different machines) must therefore always set access_token.
The bundled qq_* model tools authenticate automatically: once access_token
is set, their /api/* requests carry Authorization: Bearer <token> — the
token is read from the same config source as the adapter (or the
ONEBOT_ACCESS_TOKEN environment override), so no extra setup is needed.
Without a token they send no Authorization header and token-less loopback
deployments keep working unchanged.
WS frame limit of 4 MiB (declared explicitly, matching the aiohttp default — zero behavior change): NapCat frames larger than 4 MiB (e.g. base64-inline media) are rejected. Prefer URL-based image delivery (NapCat's file-to-URL switch) in production to stay under the limit.
NapCat-side setup (required)
After enabling the plugin you must also configure the bridge. The plugin cannot connect by itself:
- reverse mode (recommended, NapCat dials in): in NapCat's network settings add a WebSocket client:
- report URL:
ws://<hermes-host-ip>:<port>/ws(e.g.ws://192.168.1.100:8643/ws; use the LAN IP, not127.0.0.1, when Hermes and NapCat are on different machines) - token: same value as
access_tokenon the Hermes side — required whenever Hermes and NapCat are on different machines (the adapter refuses to bind a non-loopback address without a token); only same-host loopback deployments may leave both empty - message post format: array is recommended (segment-array parsing first, CQ string only as fallback)
- report URL:
- forward mode (Hermes dials out): in NapCat's network settings enable the WebSocket server (default
0.0.0.0:3001), then set the plugin'surltows://<napcat-host-ip>:3001(ws://127.0.0.1:3001when same host) and keep tokens identical.
The bridge must be on a network Hermes can reach (same LAN / routable); WS connection, image downloads and file resolution all depend on that path. This adapter is LAN-only. Cross-internet deployments are not supported.
⚠️ When NapCat runs on a different machine than Hermes, enable the 「文件转 URL」/ file-to-URL switch in NapCat's network settings (
enableLocalFile2Url). Without itget_filereturns container-local paths that Hermes cannot access, and file messages degrade to a[文件:name]marker instead of being downloadable.
dm / group access policy (choose at setup)
Both policies must be chosen when you first configure the plugin. The defaults only make sense after you pick one of the three options for each:
| Value | dm_policy (private) | group_policy (group) |
|---|---|---|
open |
admins only (non-admins silently rejected, no pairing flow) | all groups can chat; replies gated by require_mention |
allowlist |
only allow_from ids can DM (admin not required) |
only group_allow_from groups can chat |
disabled |
all DMs rejected | all group messages ignored |
⚠️ At least one admin must be set at setup (
admin_usersor theONEBOT_ALLOWED_USERSenv var). Withdm_policy: open(the default) only admins can DM, and slash commands are admin-only. With no admin configured nobody can talk to the bot. For quick dev testing useONEBOT_ALLOW_ALL_USERS=true.
Which option is right for you:
- personal bot →
dm_policy: open+admin_users: [<your QQ>]. Only you can DM - a few friends →
dm_policy: allowlist+allow_from: [<QQ1>, <QQ2>]. Non-admins in the list can DM too - group bot →
group_policy: open(defaultrequire_mention: truekeeps it quiet: members must @ or reply to trigger) - specific groups only →
group_policy: allowlist+group_allow_from: [<group_id>]
Access tiers (admin / restricted member)
Groups can be opened to all members of allowlisted groups while privileged
operations stay admin-only. The adapter enforces its own access policy
(enforces_own_access_policy), so the gateway trusts its allowlist decisions.
| Role | Who | Group @ | DM | Capabilities |
|---|---|---|---|---|
| admin | extra.admin_users (falls back to ONEBOT_ALLOWED_USERS) |
full | allowed | everything incl. slash commands |
| member | any other user in an allowlisted group | restricted | rejected | quick Q&A, image analysis, group summaries only |
| unauthorized | outside allowlisted groups / DM allowlist | blocked | rejected (pairing disabled) | — |
Enforcement points:
- member group messages get a
[受限用户:仅问答]text prefix so the agent applies the soft restriction (quick Q&A only, no file/terminal/config/HA/cross-platform/cron actions; declared in the platform hint) - member slash commands (
/new,/model,/help, …) are dropped before aMessageEventis constructed; path-like text (/tmp/x) is not affected - member DMs are rejected
- outbound replies to member chats are scanned against sensitive-intent keywords and logged with a WARNING (audit, not hard blocking)
Admin local slash commands
Admins get a few local slash commands handled inside the adapter (anything else keeps flowing to the gateway core):
| Command | Action |
|---|---|
/ocr |
OCR the most recently received inbound image via NapCat's ocr_image |
/mode interim|instant |
per-chat loop-merge mode override (interim = merge commentary into forwards, instant = send as-is); in-memory only, resets on restart |
/id |
print the current chat id |
/ver |
print the plugin version |
/approve <seq|flag> |
accept a pending friend request / group invite (OneBot set_friend_add_request / set_group_add_request); references use the #seq from the admin DM notification or the full flag. Bare /approve (no arg) is not handled here — it passes through to the gateway core's pending dangerous-command / data-training-tier confirmation flow |
/reject <seq|flag> |
decline a pending friend request / group invite (same APIs with approve=false); bare /reject passes through to the gateway core the same way |
/ocr needs the image to still exist in the temp media dir (6-hour TTL cleanup applies).
Friend request / group invite approval
Incoming OneBot request events (request_type=friend, or request_type=group with
sub_type=add / invite) are parsed, recorded to <HERMES_HOME>/plugin-data/onebot-platform/onebot_requests.json
(v1.2.0; one-time migration from the v1.1.x <HERMES_HOME>/onebot_requests.json on first touch — the legacy file is kept as a backup)
(0600 — contains applicant QQ ids and verification messages, treat as sensitive), and
every configured admin gets a private DM with the sequence number, applicant, verification
message summary and flag. Reply /approve 1 / /reject 1 (or use the full flag) in the
admin DM to decide. Approvals are idempotent (an already-processed flag never triggers a
second API call), survive restarts via the persisted ledger, and unknown references are
reported explicitly. Receiving requests and the admin notification are isolated from the
main message flow.
Group mentions
With require_mention: true (default), the bot only responds in groups when it is explicitly @'d or when the message replies to a message the bot itself sent. Replying to someone else stays silent, so busy groups are no longer woken by unrelated reply chains. The replied-to message is resolved with get_msg — the same fetch used for quote text, so there is no extra API call — and when the sender cannot be determined (fetch failed or timed out, the original message was recalled and deleted, or the bot's own id is still unknown) the bot falls back to treating the reply as a mention, so replies to recalled messages still trigger. Set it to false to respond to every group message (noisy; not recommended for large groups). When no bot_qq is configured the bot learns its own id from OneBot meta events, so mention detection works out of the box.
Long replies (three tiers, fully configurable)
Reply length is handled in three tiers. Both thresholds are user-configurable
(split_length and text_image_threshold in the platform extra block):
- ≤
split_length(default 100) characters: sent as a single text message. split_lengthtotext_image_threshold(default 150): split into multiple messages, breaking at sentence boundaries (。!?!?;;\\n) so sentences are never cut in half.- >
text_image_threshold: rendered as a black-on-white text image (800 px wide, CJK-aware font fallback chain) and sent as a single image message. The card is capped at a total height of 8000 px — taller content aborts the render and falls back to plain text chunks (the same fallback as a failed render). Falls back to text chunks if rendering fails.
Set text_image_threshold: 0 to disable the image path (everything splits as text);
raise/lower either value to tune the trade-off between message count and card rendering.
The text-image renderer is an AstrBot-style element-based Markdown renderer: bold / italic / strikethrough / inline code / code blocks / headers / quotes / lists and tables (AstrBot itself has no table element) are all drawn natively. Chinese typography rules are honored. Punctuation never starts a line (行首禁则), inline styles wrap as a whole line, literal \\n in plain text becomes a real line break (inside inline code it becomes a space; \\\\n is kept), and inline code uses a light-blue pill with a monospace font for Latin/digits and glyph-level fallback for CJK. When the reply target's nickname is known, the card gets an AstrBot-style blue top bar (To <nickname>, Klein blue #002FA7, white text at twice the body font size, ~68 px tall), matching AstrBot's card header proportions.
Font dependencies (auto-install one-liner)
The card renderer needs three font families (CJK / monospace / color emoji), auto-registered from the system; missing glyphs render as tofu boxes.
| Dependency | Provides (auto-registered path) | Used for |
|---|---|---|
fonts-noto-cjk |
/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc |
Chinese body/headers (ttc SC face auto-selected, JP/Mono fallback) |
fonts-dejavu-core |
/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf |
code blocks / inline code monospace |
fonts-noto-color-emoji |
/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf |
color emoji |
optional fonts-wqy-zenhei / fonts-wqy-microhei |
/usr/share/fonts/truetype/wqy/*.ttc |
CJK fallback if Noto is missing |
optional fonts-unifont |
/usr/share/fonts/opentype/unifont/*.otf |
last-resort fallback |
Linux (Debian/Ubuntu): one command covers all required fonts:
sudo apt install fonts-noto-cjk fonts-dejavu-core fonts-noto-color-emoji
macOS needs nothing (system Hiragino Sans GB / Songti SC, Menlo and Apple Color Emoji are picked up automatically). The renderer runs an ink self-check: font families without usable glyphs are dropped and fall back silently instead of producing tofu cards.
Markdown & voice
-
Markdown is stripped before delivery. QQ does not render it, so
**bold**, headings, lists, and tables are converted to readable plain text (headings →【…】, lists →•, tables → spaced cells, fenced code blocks → bordered boxes). This runs before splitting and text-image rendering, so images are clean too. -
Inbound voice messages are transcribed: the adapter downloads the clip and converts it with
ffmpegto 16 kHz mono WAV, then hands it to Hermes' STT pipeline. NapCat private voice messages often carry only a file hash (no URL): the adapter calls the OneBotget_recordaction to fetch the base64 audio before the download→ffmpeg→STT pipeline.STT itself uses the global
stt:config (same pipeline as other platforms):provider: localruns faster-whisper on the Hermes host (model =stt.local.model, defaultsmall, downloaded automatically on first use);provider: openaicalls an OpenAI-compatible endpoint (configurestt.openai.*+ API key). Requirements:ffmpegmust be installed; without it (or without a working STT backend), voice clips degrade to a[语音]marker. The adapter probesffmpegonce at startup (shutil.which) and logs a WARNING with install instructions when it is missing — voice STT stays unavailable until ffmpeg is on PATH.
Images
- Inbound messages are parsed as the OneBot segment array (
messagefield) when available. Image/voice/at/face/video/file segments are handled structurally; text-format clients fall back to CQ-code string parsing. CQ entity escaping (&→&,[→[, …) is reversed before any URL is used, so CDN links with¶meters download correctly. - Images are downloaded to a temp directory and exposed to the vision tool via
media_urls; undownloadable images degrade to a[图片]placeholder. If the image segment only carries afilehash (no URL), the adapter calls the OneBotget_imageaction to resolve the real URL;base64://andfile://forms are handled directly. - Images larger than
image_max_size(default 2048 px on the long edge) are downscaled with Pillow before the LLM sees them. High-resolution QQ photos otherwise make vision calls slow or time out. RGBA stays PNG, everything else becomes JPEG (q85); animated GIFs collapse to their first frame. Setimage_max_size: 0to keep originals untouched.
Inbound files
Inbound file segments are resolved through two channels so container-local paths never leak to the agent:
- CDN direct link first: for private chats the adapter calls
get_private_file_urland downloads the file straight from the QQ CDN. get_filefallback: when no direct link is available (group files, or NapCat without the file-to-URL switch), it falls back toget_file, accepting base64 or an http URL.
The downloaded file is stored under the temp media dir, annotated [文件:本地路径] in the message text so the agent can read it locally, and capped by max_inbound_file_bytes (default 20 MB). Files over the cap degrade to a plain [文件:name] marker. The annotation is skipped for user-level plugins that must stay path-neutral.
Outbound media
The adapter implements the gateway's native media senders as OneBot segments, so the agent can send media through the standard MEDIA: / markdown-image mechanism:
| Capability | OneBot segment | Notes |
|---|---|---|
| Image URL (direct) | image with url |
Markdown image URLs from the agent are sent as-is; the bridge downloads them (no local file needed). |
| Local image | image with base64:// |
Up to 8 MB. |
| Batch images | multiple image segments |
One message, max 9 images per message; URL + local mixed. |
| Voice | record with base64:// |
Up to 20 MB; the bridge transcodes to silk. |
| Video | video with base64:// |
Up to 20 MB. |
| File | file with base64:// + name |
Up to 20 MB. |
| Forwarded messages | send_forward_msg with nodes |
Group chats only. |
Merged forwarding is triggered by an agent-side block:
[[qq_forward]]
<display name>
<message text>
---
<display name>
<message text>
[[/qq_forward]]
Each ----separated block becomes one forwarded node (name + text, 500-char cap per node). In private chats the marker is ignored and the block degrades to plain text.
Replying to a message (quote)
When the user replies to (quotes) a previous message, the adapter calls the OneBot get_msg API to fetch the original message and:
- prefixes the original text with
[引用]so the agent sees what was quoted - attaches any image / voice / video from the original message as media (voice goes through the STT pipeline, video is downloaded for frame extraction)
This works for both segment-array and CQ-string payloads. If get_msg fails the current message is delivered unchanged.
Loop-message merge (interim commentary folding)
During a multi-tool turn the gateway sends interim commentary messages ("Using tool X…") followed by the final response. To save chat space the adapter buffers interim text messages per chat and, when the final message arrives, merges them into a single QQ forwarded-message first, sends the final content (including any text-image card), then retracts the originals:
- group chats use
send_forward_msg, private chats usesend_private_forward_msg - merge happens only when ≥2 interim messages are buffered
- retraction (
delete_msg) runs only after the merge forward succeeded; on failure the originals are kept - the buffer and pending-retraction list are cleared on any new inbound user message
This relies on the gateway marking commentary sends with interim: True in the stream-consumer metadata (see gateway/stream_consumer.py).
Per-interim auto-recall
Each buffered interim message gets its own independent timer (interim_recall_seconds, default 90). If the turn never settles (no final message arrives), the interim is recalled on its own instead of piling up forever. Once the buffer is settled by a final message the pending tasks self-cancel, so a settled turn is never double-retracted. Set interim_recall_seconds: 0 to disable.
Turn-end summary card
When a turn settles with ≥2 buffered interims, they are rendered into a single "本轮进展" text-image card summarizing the partial progress, the originals are retracted, and the final reply continues normally. If rendering or sending the card fails, the adapter falls back to the plain merge-forward path.
Recall spacing
delete_msg calls are spaced 60 ms apart to stay under NapCat's rate limit when retracting a batch of messages.
Agent model tools
The plugin registers model-facing tools so the agent can push media and query NapCat directly (available in CLI/TUI sessions too via provides_tools):
| Tool | Purpose |
|---|---|
qq_send_image / qq_send_voice / qq_send_video / qq_send_file |
send media out of band; chat resolves from the argument or HERMES_SESSION_CHAT_ID |
qq_send_forward |
merged forward of text nodes |
qq_napcat_api |
whitelisted NapCat actions: get_group_member_list, get_group_member_info, get_stranger_info, get_forward_msg, get_record, get_file, upload_group_file, upload_private_file, get_group_root_files, get_group_files_by_folder, get_group_file_url, ocr_image, get_ai_characters, send_group_ai_record, get_group_msg_history; anything outside the whitelist returns 403 |
qq_group_history |
fetch recent group history, paged via message_seq (≤50 per page) |
HTTP equivalents: GET /api/napcat (action proxying) and POST /api/send_media on the adapter's local API.
Hot reload
Opt-in via extra.hot_reload: true (default off, dev only). When enabled, onebot_utils.py (pure helpers: CQ parsing, splitting, markdown stripping, emoji map) and t2i_render.py (text-image renderer) are reloaded on every use: the adapter stats the file mtime and calls importlib.reload when it changed, so style/rule tweaks apply without a gateway restart. Changes to adapter.py itself still require a restart.
Privacy & data
- Network: one WebSocket connection to the OneBot 11 bridge (reverse listener or forward dial-out); inbound images/files are downloaded from QQ CDN.
- Files: inbound media is cached under a temp dir with a 6-hour TTL cleanup; the nickname cache used by the card banner is persisted to
nicknames.jsonnext to the plugin. - System calls: voice transcription invokes local
ffmpeg(and the configured STT backend); no other local tooling is required. - Sensitive info:
access_tokenand the admin allowlist are read from config only and never written to logs; outbound replies to restricted-member chats are audit-logged against sensitive-intent keywords. - No telemetry: the adapter makes no third-party calls beyond the configured OneBot bridge and QQ CDN.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
| Group chat not responding | require_mention: true needs an @ or a reply to the bot; reply triggering falls back to mention when the replied-to sender is undeterminable; confirm bot_qq was learned from meta events or set it explicitly |
| Image download 403 | NapCat escapes & in URLs to & (parsing unescapes automatically); check the media-download log lines if it still fails |
Voice shows [语音] placeholder |
ffmpeg unavailable, or get_record failed; install ffmpeg and retry — a missing ffmpeg also triggers a startup WARNING |
| File message arrives empty | CQ-string bridges may omit the file segment name. The adapter marks it [文件:<name>] (name falls back to the file= attribute); NapCat private files carry only a hash + container path, so the name comes from the file= attribute |
| Chinese tofu boxes in text-image cards | CJK fonts missing: apt install fonts-noto-cjk |
| Loop interim messages not merged | gateway must send interim: True in commentary metadata (patched _send_commentary in gateway/stream_consumer.py); adapter-side merge is only a fallback consumer |
Notes
- Outbound messages use the OneBot segment-array format (not CQ-code strings). Required for NapCat's message handling.
- Replies are sent as plain text without quoting the triggering message.
- QQ faces map to common emoji; unknown faces collapse to
[表情]. Voice without a downloadable link degrades to[语音]; inbound video/file segments degrade to[视频]/[文件:name]placeholders; unknown segment types (json cards, poke, forwarded-message CQ codes) degrade to[卡片]/[戳一戳]/[合并转发:id]placeholders. - In private chats the bot shows QQ's native "typing…" bubble while the agent generates (via NapCat's
set_input_statusextension). Group chats have no typing indicator on QQ. - Long replies may take a few seconds to render; the gateway shows a typing indicator where supported.
- Cron / scheduled deliveries cannot attach media to OneBot yet (the core
send_message_toolmedia whitelist covers telegram, discord, matrix, weixin, signal, yuanbao, feishu, whatsapp and slack only). Interactive replies are unaffected.