bub-qq
QQ Open Platform channel adapter for Bub.
Chinese documentation: README.zh-CN.md
What it provides
| Capability | Details |
|---|---|
| Single-chat (C2C) receive/reply | C2C_MESSAGE_CREATE adapted to Bub ChannelMessage; passive text / markdown replies |
| Group receive/reply | GROUP_AT_MESSAGE_CREATE / GROUP_MESSAGE_CREATE; full-message mode supported, payload carries was_mentioned / sender_role |
| Active group messages | Proactive fallback when a passive reply is impossible (active_messages, requires the group admin's opt-in in the QQ client) |
| Reply modes & selective silence | reply_mode: direct (default) forwards the model's final text and swallows <no_reply/>; reply_mode: tool exposes a native qq.send tool so the model replies by calling it and stays silent by not calling it (see Reply modes) |
| Quotes and chat records | msg_elements parsed into quoted_messages (quoted messages / merged-forward chat records) for the model |
| Receive transport | webhook or websocket (mutually exclusive on the QQ platform side); ed25519 signature verification and reconnect included |
| Security | User/group allowlists, role-gated comma commands, per-scope tool policy, LLM rate limiting, audit logs (see Security) |
| Persisted platform state | Active-message opt-ins (*_MSG_RECEIVE / *_MSG_REJECT) and group claw_cfg survive restarts |
| Reliable sending | Inbound/outbound dedupe, msg_seq management, error catalog; async manual audit (304023/304024) treated as pending success |
| Onboarding | bub onboard collects appid / secret / receive_mode; bundled skill resources under src/skills/qq |
Plugin entry point: qq → bub_qq.plugin. Supports single-chat (C2C) and group text receive/reply. QQ Guild is not covered yet.
Prerequisites
- Install Bub (recommended:
uv tool install bub) - Run
bub onboardand ensure model access works (bub chatorbub run) - Create a QQ bot on the QQ Open Platform and obtain
APPID/SECRET
Install (end users)
bub-qq is not on PyPI. With a global Bub install (uv tool install bub), install the plugin into Bub’s own environment:
bub install bub-qq@main
This resolves to the official monorepo package:
git+https://github.com/bubbuild/bub-contrib.git@main#subdirectory=packages/bub-qq
Equivalent forms:
# Full Git URL (use https:// …, not git+https://, when passing to bub install)
bub install "https://github.com/bubbuild/bub-contrib.git#subdirectory=packages/bub-qq"
# Pin a tag or commit when available
bub install bub-qq@<tag-or-sha>
Verify the plugin is loaded:
bub hooks
You should see the qq plugin among discovered entry points / hook providers.
Upgrade / uninstall
bub update bub-qq
bub uninstall bub-qq
Notes
- Do not use bare
bub install bub-qq. A name without@refis treated as a PyPI package name. bub installrequires Bub to run inside a virtual environment (including the environment created byuv tool install bub) anduvonPATH.
Install (local development)
Editable install into the same environment that runs bub.
Option A — global Bub (uv tool)
uv pip install -e /path/to/bub-contrib/packages/bub-qq \
--python ~/.local/share/uv/tools/bub/bin/python
Then use the global CLI as usual:
bub hooks
bub gateway
Option B — Bub / monorepo project venv
From a uv project that already depends on Bub:
uv add --editable /path/to/bub-contrib/packages/bub-qq
# or, from bub-contrib workspace workflows:
uv pip install -e packages/bub-qq
Option C — raw Git install into a chosen interpreter
uv pip install \
"git+https://github.com/bubbuild/bub-contrib.git#subdirectory=packages/bub-qq" \
--python /path/to/the/python/that/runs/bub
Configuration
Settings can come from:
- the
qq:section in~/.bub/config.yml BUB_QQ_*environment variables (including values loaded from.env)bub onboard, which interactively collects the required fields when theqqchannel is enabled
Env vars override YAML, so shared policy can live in config.yml while secrets stay in the environment.
Required
YAML field (qq.*) |
Env var | Description |
|---|---|---|
appid |
BUB_QQ_APPID |
QQ bot app ID |
secret |
BUB_QQ_SECRET |
QQ bot secret |
receive_mode |
BUB_QQ_RECEIVE_MODE |
Inbound transport: webhook or websocket |
receive_mode must match the QQ developer console:
webhook— starts the embedded webhook server only; WebSocket is not startedwebsocket— starts the WebSocket client only; the embedded webhook server is not started
QQ treats webhook and WebSocket as mutually exclusive. After a valid HTTPS webhook callback URL is configured successfully, WebSocket delivery is no longer supported on the platform side.
Gateway start fails if appid / secret are empty, or if receive_mode is not webhook / websocket.
Optional
YAML field (qq.*) |
Env var | Default | Description |
|---|---|---|---|
token_url |
BUB_QQ_TOKEN_URL |
https://bots.qq.com/app/getAppAccessToken |
Access token endpoint |
openapi_base_url |
BUB_QQ_OPENAPI_BASE_URL |
https://api.bot.qq.com |
OpenAPI base URL (official unified endpoint; override here to use the legacy https://api.sgroup.qq.com) |
timeout_seconds |
BUB_QQ_TIMEOUT_SECONDS |
30 |
HTTP timeout for token and OpenAPI |
token_refresh_skew_seconds |
BUB_QQ_TOKEN_REFRESH_SKEW_SECONDS |
60 |
Refresh token this many seconds before expiry |
webhook_host |
BUB_QQ_WEBHOOK_HOST |
127.0.0.1 |
Embedded webhook bind host |
webhook_port |
BUB_QQ_WEBHOOK_PORT |
8080 |
Embedded webhook port (80 / 443 / 8080 / 8443 allowed by QQ) |
webhook_path |
BUB_QQ_WEBHOOK_PATH |
/qq/webhook |
Webhook path |
webhook_callback_timeout_seconds |
BUB_QQ_WEBHOOK_CALLBACK_TIMEOUT_SECONDS |
15 |
Reserved for future callback controls |
verify_signature |
BUB_QQ_VERIFY_SIGNATURE |
true |
Enforce webhook signature verification |
webhook_signature_timestamp_tolerance_seconds |
BUB_QQ_WEBHOOK_SIGNATURE_TIMESTAMP_TOLERANCE_SECONDS |
0 |
Reject webhook requests whose signature timestamp deviates from local time by more than this many seconds; 0 disables the freshness check |
inbound_dedupe_size |
BUB_QQ_INBOUND_DEDUPE_SIZE |
1024 |
Recent inbound msg_id cache size |
session_state_size |
BUB_QQ_SESSION_STATE_SIZE |
1024 |
Max sessions / send records kept in memory for passive replies (oldest entries are evicted) |
passive_reply_window_seconds |
BUB_QQ_PASSIVE_REPLY_WINDOW_SECONDS |
3600 |
How long after an inbound message passive replies are attempted |
active_messages |
BUB_QQ_ACTIVE_MESSAGES |
false |
Send proactive group messages (no msg_id) when a passive reply is impossible; requires the group admin to allow proactive messages in the QQ client |
passive_replies_per_msg_id |
BUB_QQ_PASSIVE_REPLIES_PER_MSG_ID |
4 |
Local cap of passive replies per inbound msg_id; beyond it the send falls back to an active message (when enabled) or is skipped |
reply_mode |
BUB_QQ_REPLY_MODE |
direct |
How model output reaches QQ: direct forwards the final text (output exactly <no_reply/> to stay silent); tool disables direct forwarding and exposes the qq.send tool instead (see Reply modes) |
state_file |
BUB_QQ_STATE_FILE |
empty | JSON file persisting platform switches (active-message opt-ins, group claw_cfg); empty uses <bub home>/qq/state.json |
admin_users |
BUB_QQ_ADMIN_USERS |
empty | Comma-separated user openids with full comma-command and tool access in every scope |
allow_users |
BUB_QQ_ALLOW_USERS |
empty | Comma-separated C2C allowlist; when set, C2C messages from anyone else are dropped |
allow_groups |
BUB_QQ_ALLOW_GROUPS |
empty | Comma-separated group allowlist; when set, messages from other groups are dropped |
group_tool_policy |
BUB_QQ_GROUP_TOOL_POLICY |
restricted |
Tool policy for group sessions: open / restricted (denies bash*, fs.write, fs.edit, subagent) / locked (denies all tools) |
c2c_tool_policy |
BUB_QQ_C2C_TOOL_POLICY |
open |
Tool policy for C2C sessions; same values as group_tool_policy |
denied_tools |
BUB_QQ_DENIED_TOOLS |
empty | Extra comma-separated tool-name glob patterns denied under restricted, e.g. web.fetch,tape.* |
llm_rate_limit_per_minute |
BUB_QQ_LLM_RATE_LIMIT_PER_MINUTE |
0 |
Max LLM calls per sender per session per minute; 0 disables |
llm_rate_limit_notice |
BUB_QQ_LLM_RATE_LIMIT_NOTICE |
请求过于频繁,请稍后再试。 |
Reply text used when a sender hits the LLM rate limit |
websocket_intents |
BUB_QQ_WEBSOCKET_INTENTS |
1 << 25 |
WebSocket identify intents (GROUP_AND_C2C_EVENT) |
websocket_use_shard_gateway |
BUB_QQ_WEBSOCKET_USE_SHARD_GATEWAY |
false |
Use /gateway/bot recommended shard count |
websocket_reconnect_delay_seconds |
BUB_QQ_WEBSOCKET_RECONNECT_DELAY_SECONDS |
5 |
Base delay before WebSocket reconnect (doubles per consecutive failure) |
websocket_reconnect_max_delay_seconds |
BUB_QQ_WEBSOCKET_RECONNECT_MAX_DELAY_SECONDS |
300 |
Upper bound for the exponential reconnect backoff |
websocket_max_identify_rejections |
BUB_QQ_WEBSOCKET_MAX_IDENTIFY_REJECTIONS |
5 |
Stop the client after this many consecutive identify rejections (op 9); 0 disables the limit |
Example:
qq:
appid: your_app_id
secret: your_secret
receive_mode: websocket
export BUB_QQ_APPID=your_app_id
export BUB_QQ_SECRET=your_secret
export BUB_QQ_RECEIVE_MODE=websocket
Which group messages the bot hears is controlled in the QQ client by a group admin setting (all messages / last 10 @mentions / @only). Every received group message wakes the model; was_mentioned in the payload is false when the bot was not @mentioned.
Settings path in the latest mobile QQ client: open the group chat → tap "More" in the top-right corner → Group Bots → Manage. There the group owner or an admin can adjust the bot's group message scope and toggle "allow the bot to speak proactively" (pairs with active_messages).
Reply modes
reply_mode decides how model output becomes a QQ message, and — just as important — how the model stays silent (e.g. for un-mentioned group chatter it has nothing to add to). In opt-in/opt-out terms: direct is opt-out (replying is the default; the model explicitly opts out with a sentinel), while tool is opt-in (silence is the default; the model explicitly opts in by calling the send tool — the same contract Bub's other channels use). Both modes share the same send pipeline (passive msg_id/msg_seq targeting, dedupe, markdown fallback, active-message fallback), and a per-mode <qq_response_instruct> block is injected into the system prompt so the model knows the active contract.
direct (default)
The model's final text is forwarded to the chat as-is — delivery never depends on the model calling anything. To skip a reply, the model outputs exactly <no_reply/>; the channel swallows it (logged as qq.send skip_no_reply) and nothing is sent. Leaked model special tokens (<|eos|>, <|im_end|>, …) are stripped from the edges of outbound text; output consisting only of such tokens is also treated as silence. Recommended when the configured model's tool-calling reliability is unknown: the failure mode is an unwanted message, never a lost one.
tool
Direct forwarding is disabled (model output is routed to the null channel) and a native qq.send tool is registered instead. The model replies by calling qq.send with the message text — msg_id/msg_seq are resolved internally, so the model never touches protocol fields — and stays silent by simply not calling it. This matches Bub's native channel contract and additionally allows several messages per turn. The failure mode is inverted: if the model forgets to call the tool, the reply is silently lost, so use this mode with models whose tool calling you trust.
qq.send is exempt from the tool policy (group_tool_policy / c2c_tool_policy / denied_tools): it is the reply path itself, which was never gated in direct mode.
Notes for tool mode:
- Comma-command output is always delivered directly in both modes (commands bypass the model).
- The
llm_rate_limit_noticetext is not delivered in tool mode (the short-circuited turn produces direct output, which tool mode drops); the rate limit itself still applies and is logged.
Security
The plugin ships with layered, fail-closed protections for public chats:
- Allowlists — when
allow_users/allow_groupsare set, messages from anyone else are dropped before reaching the model. - Comma-command gate — inbound text starting with
,runs as a Bub command only for authorized senders: in groups the platform-reportedmember_rolemust beowner/admin, or the sender must be inadmin_users; in C2C onlyadmin_usersqualify. Everyone else's,message is forwarded as plain text. - Tool policy — a
before_tool_callhook denies dangerous tools per scope. Groups default torestricted(nobash*,fs.write,fs.edit,subagent); C2C defaults toopen. Authorized senders (rule 2) bypass the policy. - Rate limit — a
before_llm_callhook caps LLM calls per sender per session (llm_rate_limit_per_minute) and short-circuits the turn withllm_rate_limit_noticewhen exceeded. - Audit log —
after_llm_call/after_tool_callhooks emitqq.audit.llm/qq.audit.toollog lines with session, sender, role, tool/model, duration, and error type.
Note: with no configuration, comma commands are unusable in C2C (fail-closed). Set admin_users to your own openid to keep command access.
Ops comma commands
The plugin ships model-invisible comma commands (registered with agent_use=False) for authorized senders:
| Command | Description |
|---|---|
,qq.version |
Show the installed bub-qq plugin version |
Any other registered Bub tool can also be run as ,name args, and an unknown ,name falls back to executing the line as a bash command — which is why the comma-command gate effectively grants authorized senders full shell access.
Run
QQ is a channel listener surface. Start Bub gateway after the plugin is installed and configured:
bub gateway
For webhook mode, expose a public HTTPS URL that reaches the embedded server (host/port/path above) and register it in the QQ bot console. For websocket mode, ensure the console is not locked into a successful webhook-only configuration.
CLI chat (bub chat) does not replace the QQ channel; use gateway for QQ IO.
Session and message mapping
| Concept | Format / behavior |
|---|---|
| Session ID (C2C) | qq:c2c:<user_openid> |
| Chat ID (C2C) | c2c:<user_openid> |
| Session ID (group) | qq:group:<group_openid> |
| Chat ID (group) | group:<group_openid> |
| Inbound event | C2C_MESSAGE_CREATE, GROUP_AT_MESSAGE_CREATE, GROUP_MESSAGE_CREATE |
| Group activation | every received group message is is_active=true; delivery scope is set in the QQ client by a group admin |
| Command messages | inbound text starting with , is forwarded as Bub kind=command for authorized senders only (see Security); otherwise treated as plain text |
| Outbound | Text (msg_type = 0), or markdown (msg_type = 2) when the reply looks like markdown; passive reply preferred (msg_id + plugin-managed msg_seq), with an optional active fallback for groups (active_messages, plain text only) |
| Passive window | passive replies stop once the latest inbound timestamp is older than 60 minutes; groups fall back to active messages when enabled |
| Active opt-in | GROUP_MSG_RECEIVE / GROUP_MSG_REJECT (and the C2C twins) are persisted per group/user; sends are skipped when the admin explicitly rejected active messages |
| Debounce | needs_debounce = true |
C2C stays passive-only: official docs state active C2C push stopped being provided on 2025-04-21. Group active messages are opt-in on both sides (bot config active_messages + the group admin's QQ client switch) and consume platform quota.
Payload shape
Inbound non-command messages are encoded as a JSON string, including fields like:
messagemessage_idtype(textorattachment)sender_id(C2Cuser_openid, groupmember_openid)sender_name/sender_role/group_openid/chat_type/was_mentioned(group)dateattachments(when present)quoted_messages(when present: quoted message / merged-forward chat record content frommsg_elements, withmessage, optionalsender_name, and nestedmessages)
In direct mode, normal replies should return final text and let Bub outbound routing call QQChannel.send; in tool mode, replies go through the qq.send tool. In both cases msg_seq is managed inside the plugin — never invent protocol fields.
Status
Supported today
- Config via
qq:YAML,BUB_QQ_*, andbub onboard - Access token from
https://bots.qq.com/app/getAppAccessTokenwith cached refresh (60s renewal window) aiohttpOpenAPI client withAuthorization: QQBot {ACCESS_TOKEN}- Embedded webhook receiver, callback validation (
op = 13), ed25519 signature flows - Webhook request verification (
X-Signature-Ed25519,X-Signature-Timestamp) - WebSocket receive path with reconnect / resume and optional sharding
- C2C / group inbound adaptation,
msg_iddedupe, 60-minute passive text or markdown replies - Group text receive/reply; message scope is controlled in the QQ client by a group admin
- In-memory send idempotency for the same
session_id + msg_id + msg_seq - OpenAPI error surfacing (HTTP status, platform
code/err_code, trace_id from the response header or body) and error catalog metadata - Layered security: allowlists, role-gated comma commands, per-scope tool policy, LLM rate limiting, and audit logs (see Security)
- Proactive group messages as a passive-reply fallback (
active_messages), with persisted per-group/user opt-in state from*_MSG_RECEIVE/*_MSG_REJECTevents - claw_cfg round-trip:
INTERACTION_CREATE2002 updates are persisted per group and 2001 queries echo the realrequire_mentionstate - 304023/304024 (async manual audit) treated as pending success instead of a failed send
- Selective silence in both reply modes:
<no_reply/>sentinel filtering (direct) and reply-by-tool with silence-by-omission (tool), driven by an injected per-mode system prompt block - Automated tests for config, auth, signatures, channel, webhook, websocket, gateway, plugin onboarding, C2C/group services, security policies, reply modes, and the platform store
Not yet
- QQ Guild and rich-media send/receive
- Wider webhook event coverage beyond validation, basic
{"op":12}ack, C2C/group messages, message-toggle events, and interaction query/update - Active C2C push (discontinued by the platform on 2025-04-21)
- Markdown in active group messages (requires a registered template; active path sends plain text)
- Dynamic in-process shard rebalancing after startup
Confirmed interface rules
From official QQ Bot docs (API auth + event subscription):
Auth / OpenAPI
- Token:
POST https://bots.qq.com/app/getAppAccessTokenbody{ appId, clientSecret } - Token lifetime up to
7200seconds; refresh within60seconds of expiry returns a new token while the old remains valid during the overlap - OpenAPI unified endpoint:
https://api.bot.qq.com(the legacyhttps://api.sgroup.qq.comcan be restored viaopenapi_base_url) - Header:
Authorization: QQBot {ACCESS_TOKEN} - Failure response body carries
err_code,message,trace_id(legacy format usescode; the plugin accepts both); trace_id is also exposed via theX-Tps-trace-IDresponse header
Events / transport
- Production webhooks require HTTPS; ports
80,443,8080,8443 - Webhook and WebSocket are mutually exclusive once a valid HTTPS callback is configured
- Validation requests use
op = 13; response must includeplain_tokenand ed25519 signature overevent_ts + plain_token - Normal webhook verification uses
timestamp + raw_body - Event payload shape:
{ id, op, d, s, t } C2C_MESSAGE_CREATE/GROUP_AT_MESSAGE_CREATEintent:GROUP_AND_C2C_EVENT(1 << 25)- Documented
C2C_MESSAGE_CREATE.dfields used here:id,author.user_openid,content,timestamp,attachments - Group event
dfields used here:id,group_openid,author.member_openid,content,timestamp,mentions,attachments - Group send:
POST /v2/groups/{group_openid}/messageswith the same body as C2C (msg_id,msg_seq, plus eithercontent+msg_type = 0ormarkdown.content+msg_type = 2) - WebSocket close codes
4914/4915are fatal; codes such as4006–4009and4900–4913are treated as reconnectable
Official documentation
Use the QQ docs for app creation, credentials, event subscription, and callback settings (APPID, SECRET, webhook URL, intents, etc.).
Development
uv run --package bub-qq pytest -q
Tests use mocks — no live QQ network required.
License
Same as the bub-contrib repository.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file bub_qq-0.0.1.tar.gz.
File metadata
- Download URL: bub_qq-0.0.1.tar.gz
- Upload date:
- Size: 51.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c1b533371f4c228e460ed68709c19b9299df48d69b582c90184a7a11bd726188
|
|
| MD5 |
6d3009907c961806b2fef94dc1a3f7cc
|
|
| BLAKE2b-256 |
765b855614e975afb3f504e0304388d5c2a46cf9c390d051828c4e31c8fa1534
|
File details
Details for the file bub_qq-0.0.1-py3-none-any.whl.
File metadata
- Download URL: bub_qq-0.0.1-py3-none-any.whl
- Upload date:
- Size: 64.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6b0cf3482736b35039aea63c2c249d8953357d4544f70b9d237e230eb2f81999
|
|
| MD5 |
fd68f4432cb897eca7b726547b0d7d8a
|
|
| BLAKE2b-256 |
91c57a22e90f675e292fed715415140ab6079e829191d02da3e7b49042d4d5a4
|