跪拜 Guibai
← Back to the summary

Ditching QQ's Official Bot for a Self-Hosted SnowLuma + AstrBot Stack

Abandoning QQ's Official Bot: Self-Hosting a QQ Bot with SnowLuma + AstrBot

一、Why Replace the Official Bot

The starting point was QQ's official bot. After a few months of use, three problems couldn't be worked around:

1. Capabilities are tiered by developer type. Individual-certified developers are directly denied a large batch of interfaces, with only one line left on the page:

Individual-certified developer feature in gray-scaling

2. Friend private messages are unavailable, and group chat is also restricted. The official bot is positioned as a "service account within channels/groups", not "an account that can chat with you privately". Using it as an ordinary friend is impossible.

3. Card messages fail entirely on TIM. This hurts the experience most: card-type messages returned by the bot still display on mobile QQ, but the TIM desktop client turns them into a string of placeholders:

TIM cannot display card messages

In one sentence: the official bot is a product for enterprises to build service entry points, not a tool for individuals to make an "AI groupmate". The goals don't align, and the longer you use it, the more awkward it feels.

二、Why SnowLuma Instead of NapCat

The mainstream self-hosted path uses protocol clients like NapCat. The problem: I previously had an account banned using NapCat, and accounts have a cost. I didn't want to gamble again.

SnowLuma's route differs from NapCat. Instead of reimplementing the QQ protocol, it runs a real QQ client inside a container and uses ptrace to inject hooks into it. To Tencent's side, that looks like a normal client running.

Community feedback is generally "more stable than NapCat", and in my actual use there was no abnormal disconnection. But it must be said clearly: this is still third-party injection. No one gives you any security guarantee, and you bear the risk yourself. Don't play with your main account.

Two details actually made me switch:

三、Architecture: Two Halves, Plus a Direction

AstrBot + SnowLuma architecture diagram

Data flow: QQ ← process injection ← SnowLuma ←WS→ AstrBot → LLM API

Port division:

Port Owner Purpose Mapped to host
6185 AstrBot WebUI admin panel Yes
6199 AstrBot OneBot v11 reverse WS server No (via docker internal network)
6081 SnowLuma noVNC remote desktop, for QR login Yes
5099 SnowLuma SnowLuma WebUI Yes
3000 / 3001 SnowLuma OneBot HTTP / WS server No (not used in this setup)

Note the last row: SnowLuma's own 3000/3001 are completely unnecessary to expose in this approach. Two fewer open ports means two fewer entry points.

四、Three Things the Official Docs Don't Clarify

This section is the core of the article. SnowLuma's Docker deployment docs are written properly, but they only cover one usage: "SnowLuma itself exposes OneBot services externally". The information needed for connecting AstrBot is missing in three places.

1. The docs only describe it acting as a server, not as a client

The docs' port table is clear: in the container, 3000 is OneBot HTTP and 3001 is OneBot WebSocket, and the compose example maps both by default.

It's natural to think: since SnowLuma has a WS server, let AstrBot connect to ws://snowluma:3001.

That path doesn't work. AstrBot's OneBot v11 adapter (aiocqhttp) is, by design, a reverse WebSocket server. It has no ability to actively connect out: its config options only include "reverse WS host / port / token", with no field for filling in a peer address.

SnowLuma's docker docs, meanwhile, never mention wsClients (the reverse WS client) even once. That capability only appears in the WebUI's "OneBot protocol endpoints → WS clients" page, and in the fields of config/onebot_<uin>.json.

The correct direction is exactly the opposite:

❌ AstrBot(client) ──connect──> SnowLuma:3001(WS server)
✅ SnowLuma(WS client, outbound) ──> AstrBot:6199/ws(WS server)

There is only one criterion for judgment: whichever end has an input box for "filling in the other side's address" is the client. Don't assume something is the side being connected to just because it says "server".

2. The docs make you edit JSON by hand, but the WebUI already has the entry point

The docs give this modification method:

docker exec -it snowluma bash -c "vi /app/data/config/onebot_<uin>.json"
docker restart snowluma

Following that also works, but it's a detour with three extra risks:

In fact the WebUI has had a graphical interface all along: OneBot protocol endpoints → WS clients → create WS client, fill in URL, token, message format, click save, and the app persists it itself.

Conclusion: all OneBot-side configuration should go through the WebUI first; editing files is only a fallback.

3. How the two containers talk to each other is never covered

AstrBot and SnowLuma are two separate compose files, each creating its own independent bridge network, mutually unaware of each other. The docs assume the reader handles this layer, but it's the most likely point of getting stuck: both sides have correct config and still can't connect.

The fix is to create a shared network yourself and attach both running containers to it; see section six.

五、Deployment

AstrBot

The environment already exists, kept minimal. The key point: do not put 6199 in ports; let the docker network handle it.

services:
  astrbot:
    image: astrbot-pw:local
    container_name: astrbot
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    ports:
      - "6185:6185"
    environment:
      - TZ=Asia/Shanghai
    volumes:
      - /vol1/1000/docker/astrbot/data:/AstrBot/data

SnowLuma

services:
  snowluma:
    image: motricseven7/snowluma:latest
    container_name: snowluma
    restart: unless-stopped

    shm_size: 1gb
    ulimits:
      nofile:
        soft: 65536
        hard: 1048576

    cap_add:
      - SYS_PTRACE
    security_opt:
      - seccomp=unconfined

    environment:
      TZ: Asia/Shanghai
      VNC_PASSWD: ${VNC_PASSWD}
      SNOWLUMA_UID: 1000
      SNOWLUMA_GID: 1000
      SNOWLUMA_WEBUI_HOST: 0.0.0.0
      SNOWLUMA_WEBUI_PORT: 5099
      SNOWLUMA_HOOK_AUTOLOAD: 1

    ports:
      - "6081:6081"
      - "5099:5099"

    volumes:
      - /vol1/1000/docker/snowluma/data:/app/data
      - /vol1/1000/docker/snowluma/config:/app/.config
      - /vol1/1000/docker/snowluma/share:/app/.local/share

Four parameters cannot be omitted:

Parameter Function What omitting it does
cap_add: SYS_PTRACE the hook injects into the QQ process via ptrace won't attach; QQ runs but no messages are received
security_opt: seccomp=unconfined default seccomp blocks ptrace same as above
shm_size: 1gb QQ is Chromium-based and depends on /dev/shm QQ crashes randomly a few seconds after startup
SNOWLUMA_HOOK_AUTOLOAD: 1 auto-load hook after QR scan must manually Load in WebUI every time

After startup, verify injection capability first, don't rush to scan the QR code:

docker exec snowluma getcap /usr/local/bin/node
# expected to include cap_sys_ptrace=ep

QR Login

http://<NAS_IP>:6081/, password is the VNC_PASSWD from compose. In the remote desktop, QQ has already auto-started; scan the QR code directly.

Then open http://<NAS_IP>:5099/; initial credentials are in the logs:

docker logs snowluma 2>&1 | grep -E "initial credentials" | tail -1

This password is shown only once. Accept the EULA first, then change it immediately.

A successful injection looks like this:

[preload] succeeded. /opt/QQ/resources/app/major.node
[Hook] pipe connected: PID=86

六、Networking: Connecting Both Containers into the Same Network

docker network create botnet
docker network connect botnet astrbot
docker network connect botnet snowluma

docker network inspect botnet | grep -E '"Name"|"IPv4Address"'

Both should appear on the same subnet:

"Name": "astrbot",   "IPv4Address": "172.25.0.2/16"
"Name": "snowluma",  "IPv4Address": "172.25.0.3/16"

Verify the network layer first, then touch the config:

docker exec snowluma node -e "require('net').connect(6199,'astrbot').on('connect',()=>{console.log('6199 OK');process.exit(0)}).on('error',e=>{console.log('FAIL',e.code);process.exit(1)})"

Continue only after 6199 OK. This step rules out most "config is right but can't connect" cases in advance.

七、Configuration on Both Sides

AstrBot side

WebUI (6185) → Robots → create robot → OneBot v11:

Field Value
Reverse WebSocket host 0.0.0.0
Reverse WebSocket port 6199
Reverse WebSocket token a random string

The host must be 0.0.0.0. Filling in 127.0.0.1 makes the container listen on loopback only, and other containers can't connect.

SnowLuma side

WebUI (5099) → OneBot protocol endpoints → WS clients → create new:

Field Value
Name astrbot
URL ws://astrbot:6199/ws
Token exactly the same as the AstrBot side
Message format array
Role Universal

Use the container name for the URL rather than an IP: IPs change when containers are recreated, container names don't. After saving, the status should immediately become normal · connected:

SnowLuma WS client connected

八、Verification

docker logs --tail 30 astrbot | grep -i aiocqhttp

This line appearing means it works:

[aiocqhttp.aiocqhttp_platform_adapter:107]: aiocqhttp(OneBot v11) adapter connected.

Then send a message from another account; AstrBot's logs will print the event:

[core.event_bus:117]: [default] [snowluma(aiocqhttp)] nickname/QQ: message content

九、Other Pitfalls Hit

1. docker compose build has no output

The compose file only has image: and no build: section, so build is a no-op returning 0 immediately. The real time cost is docker compose up -d pulling the image (~2GB).

Also --progress is a global flag; the position matters:

docker compose --progress plain build   # correct
docker compose build --progress=plain   # gives a warning

2. .env didn't get passed through, password became an empty string

level=warning msg="The \"VNC_PASSWD\" variable is not set. Defaulting to a blank string."

.env is a dot-prefixed hidden file that ls doesn't show, easy to miss when copying. Confirm with ls -la, then check variable expansion with docker compose config.

3. 401 Unauthorized is not a failure

Probing a port with curl returns:

HTTP/1.1 401
server: hypercorn-h11

That is evidence the port is completely fine. Being rejected for a missing token means TCP works, HTTP works, and the WS route is there. How to read it:

Return Meaning
401 service normal, only the token is wrong
Connection refused port not mapped to the host
timeout blocked by firewall/security group

4. wsClients is an empty array

If AstrBot shows the adapter loaded successfully but no "connected" status, look at this line first:

docker exec snowluma sh -c "cat /app/data/config/onebot*.json"

"wsClients": [] means the client was never created, so no connection was ever initiated. Checking ports at this point is checking a problem that doesn't exist.

5. No ss / netstat in the containers

Both images are slim builds: sh: 1: ss: not found. Three alternatives:

# ① from the host, check mappings
docker port astrbot

# ② direct TCP probe (AstrBot is a Python app)
docker exec astrbot python -c "import socket;socket.create_connection(('127.0.0.1',6199),3);print('OPEN');s.close()"

# ③ install nothing: read /proc/net/tcp, port in hex
docker exec astrbot sh -c 'grep ":1837" /proc/net/tcp'

For ③, column 4 being 0A means LISTEN. Common port mappings:

Port hex Port hex
3000 0BB8 5099 13EB
3001 0BB9 6081 17C1
6185 1829 6199 1837

6. Don't copy online tutorials verbatim

Two places got me:

7. docker network connect is not persistent

When a container gets recreated (docker compose up -d recreates containers when it detects compose changes), the manually connected network is lost, and the connector can't reconnect.

For a permanent solution, declare the external network in compose:

networks:
  default:
    name: botnet
    external: true

8. Client private-message reporting is unavailable

[QQHook.Runtime] Client private-message reporting is unavailable: PID=86 status=-1 reason=1

Normal output while logged out; it disappears on its own after successful QR scan. If it keeps appearing after login, private messages can't be received (group messages are unaffected), and you need to check whether SnowLuma and the QQ client versions match.

十、Security

The risk points of this stack differ from ordinary web services:

十一、Quick Reference

# status
docker compose ps
docker exec snowluma supervisorctl status
docker exec snowluma getcap /usr/local/bin/node

# network
docker network inspect botnet | grep -E '"Name"|"IPv4Address"'
docker exec snowluma node -e "require('net').connect(6199,'astrbot').on('connect',()=>{console.log('OK');process.exit(0)}).on('error',e=>{console.log(e.code);process.exit(1)})"

# logs
docker logs --tail 50 astrbot  | grep -i aiocqhttp
docker logs --tail 50 snowluma | grep -iE "hook|onebot|ws-client"

The two final successful log lines:

snowluma: [OneBot.WS-Client] [astrbot] connected ws://astrbot:6199/ws
astrbot:  aiocqhttp(OneBot v11) adapter connected.

Summary

The real cost of this migration wasn't deployment; it was information gaps: the official docs only cover half of the usage, and the rest has to be filled in by trial and error.

Three lessons:

  1. In reverse WebSocket, the "passive party" is the server. Judge the client by whether it has an input box for "filling in the peer's address", not by the feature name.
  2. If a GUI exists, don't edit config files. For entries already in the WebUI, editing files only adds three risks: permissions, formatting, and overlay overrides.
  3. Verify the network layer first, then the higher-level config. In the wrong order, time goes to the wrong places.