Standalone outbound message gateway for clinic services. It owns the NapCat /
OneBot 11 transport, the per-account FIFO queue, gap-based pacing, retries, and
the HTTP API. It was extracted from
clinic-backend/internal/message and keeps that behavior.
- Account-scoped OneBot 11 base URLs at
/api/v1/accounts/{account}with pass-through for ordinary actions and per-account token auth. - Account-scoped delivery status at
/api/v1/accounts/{account}/message-status/{delivery_id}. - Asynchronous delivery through one in-memory FIFO queue per configured account.
- Multiple accounts per gateway, each with its own endpoint, queue, and pacing.
- Configurable gap policy, retry policy, and queue capacity.
- Friend/group/member directory sync with shared-group delivery for non-friends.
- Public
GET /healthzliveness endpoint. - Docker Compose stack for the gateway, plus an optional sample NapCat stack.
- Dev-only fake NapCat for testing without a QQ account.
- No external Go dependencies or cgo.
The new API gives each configured account one OneBot 11 base URL:
http://message-gateway:8081/api/v1/accounts/qq
http://message-gateway:8081/api/v1/accounts/qq-backup
Ordinary actions are proxied to the account driver and returned unchanged:
curl -X POST http://127.0.0.1:8081/api/v1/accounts/qq/get_login_info \
-H "Authorization: Bearer $QQ_CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'The account's client_token is the scoped credential for that URL:
curl "http://127.0.0.1:8081/api/v1/accounts/qq/get_group_list?group_id=20001&access_token=$QQ_CLIENT_TOKEN"Delivery status is account-scoped:
curl http://127.0.0.1:8081/api/v1/accounts/qq/message-status/qq-1 \
-H "Authorization: Bearer $QQ_CLIENT_TOKEN"Send actions enter the account queue. Normal sends wait for the result;
_async actions return a delivery ID immediately. See API.md for
request formats, queue behavior, and status tracking.
cp accounts.example.json accounts.json
# Edit accounts.json so each address points at a reachable OneBot service.
export MESSAGE_ACCOUNTS_FILE=./accounts.json
# Use the account's client_token as the bearer token.
export QQ_CLIENT_TOKEN=replace-with-client-token
go run ./cmd/message-gatewayThe gateway listens on :8081 by default. Account definitions, NapCat
access_token values, and gateway client_token values live in
accounts.json; there is no global gateway key. The gateway does not read
NAPCAT_BASE_URL or NAPCAT_ACCESS_TOKEN. Compose uses http://napcat:3001
in the example file.
curl http://127.0.0.1:8081/healthz
curl -X POST http://127.0.0.1:8081/api/v1/accounts/qq/send_private_msg_async \
-H "Authorization: Bearer $QQ_CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id":10001,"message":"hello"}'See API.md for the full HTTP contract.
Each object in the accounts array is one outbound endpoint. The gateway does
not know which platform account is logged in behind the address; NapCat or the
OneBot service owns that identity. Entries with the same driver are
independent and get their own client, queue, and pacing.
{
"accounts": [
{
"name": "qq",
"driver": "napcat",
"address": "http://napcat:3001",
"access_token": "replace-with-napcat-token",
"client_token": "replace-with-client-token"
},
{
"name": "qq-backup",
"driver": "napcat",
"address": "http://napcat-backup:3001",
"access_token": "replace-with-backup-token",
"client_token": "replace-with-backup-client-token"
}
]
}| Field | Required | Description |
|---|---|---|
name |
yes | Unique account name, ^[a-z0-9][a-z0-9_-]{0,31}$. Clients send it in platform; delivery ids use it as a prefix. |
driver |
yes | napcat (currently the only implemented driver). |
address |
yes | Base URL of the OneBot HTTP service, http or https. |
access_token |
yes for napcat |
Upstream bearer token for the OneBot HTTP server. Copy the token configured in NapCat. It is never accepted as a gateway credential. |
client_token |
yes | Gateway credential for this account. Required, must differ from access_token. Generate with openssl rand -hex 32. |
timeout |
no | Per-request timeout for queued sends, default 10s. |
proxy_timeout |
no | Driver response-header timeout for pass-through requests, default 30s. |
directory_refresh |
no | Friend/group directory refresh interval, default 30m. |
directory_retry |
no | Initial directory sync retry interval, default 5s. |
directory_timeout |
no | One directory sync timeout, default 60s. |
group_temp_session |
no | Deliver to non-friends through shared groups, default true. |
Account config is read once at startup. Restart the service after editing the
file. Every request must name an account in platform.
accounts.json may contain bearer tokens, so keep it out of version control
and restrict its permissions (chmod 600 accounts.json).
The main Compose stack runs only the gateway. NapCat is an external dependency;
a sample NapCat stack is provided in docker-compose.napcat.yml.
cp .env.example .env
cp accounts.example.json accounts.json
chmod 600 .env
# Edit accounts.json so each address points at a OneBot service, each
# access_token is the NapCat token, and each client_token is the gateway
# credential.
docker compose up -d --build
docker compose logs -f message-gatewayThe main Compose file mounts ./accounts.json read-only at
/etc/message-gateway/accounts.json and sets MESSAGE_ACCOUNTS_FILE. Create
the file before starting the stack.
Use --build after changing gateway code. Production deployments use a
prebuilt image and start with --no-build instead.
The Compose network is named message-gateway, so clinic-backend can join it
as an external network and reach the gateway at http://message-gateway:8081.
The gateway connects to the OneBot HTTP endpoint configured in
accounts.json. Point it at an existing NapCat, or start the sample stack
after the gateway network exists:
mkdir -p napcat/config napcat/QQ
docker compose -f docker-compose.napcat.yml up -dIf NapCat cannot write to its bind mounts because they were created as root, give them to the configured UID/GID:
sudo chown -R 1000:1000 napcatThen configure NapCat:
- Open
http://127.0.0.1:6099/webui. The WebUI token is innapcat/config/webui.json. - Log in to the QQ account by scanning the QR code. On later starts, set
NAPCAT_QUICK_ACCOUNTin.envto reuse the saved session. - In NapCat's network settings, enable the OneBot 11 HTTP server on port
3001. - Put the OneBot HTTP token from NapCat into the account's
access_tokenfield. The gateway requires it fornapcataccounts. - Generate a separate
client_tokenfor gateway clients, for example withopenssl rand -hex 32. - Set the account address to
http://napcat:3001when using the sample stack.
To run the gateway against an existing NapCat, set that entry's address in
accounts.json and do not start the sample stack.
The sample stack uses the gateway's external message-gateway network. Stop it
separately before removing the gateway network:
docker compose -f docker-compose.napcat.yml downcmd/fake-napcat is a dev-only OneBot 11 server. It implements the directory
and send endpoints the gateway uses and records every send attempt. It never
connects to QQ.
Terminal 1 — start fake NapCat:
cd /home/potato/Projects/clinic/message-gateway
go run ./cmd/fake-napcat -token local-tokenIt listens on :3001 by default. Keep this terminal running.
Create accounts.json:
The gateway requires a non-empty access_token for napcat. Use the same value
as the fake server's -token. Add a distinct client_token for gateway
clients.
cd /home/potato/Projects/clinic/message-gateway
cat > accounts.json <<'EOF'
{
"accounts": [
{
"name": "qq",
"driver": "napcat",
"address": "http://127.0.0.1:3001",
"access_token": "local-token",
"client_token": "local-client-token"
}
]
}
EOF
chmod 600 accounts.jsonaccounts.json is gitignored.
Terminal 2 — start the gateway:
cd /home/potato/Projects/clinic/message-gateway
export MESSAGE_ACCOUNTS_FILE=./accounts.json
# The client_token from accounts.json is the gateway credential.
export QQ_CLIENT_TOKEN=local-client-token
go run ./cmd/message-gatewayExpected log:
message-gateway listening on :8081 (accounts qq)
The gateway does not load .env automatically; export MESSAGE_ACCOUNTS_FILE
before starting it. QQ_CLIENT_TOKEN is only used by the curl examples.
Send a private message:
curl -i -X POST http://127.0.0.1:8081/api/v1/accounts/qq/send_private_msg_async \
-H "Authorization: Bearer test-key" \
-H "Content-Type: application/json" \
-d '{"user_id":10001,"message":"hello from gateway"}'Expected response:
{"status":"async","retcode":1,"data":{"delivery_id":"qq-1"}}qq is the account name from accounts.json.
Check the delivery status:
curl -s http://127.0.0.1:8081/api/v1/accounts/qq/message-status/qq-1 \
-H "Authorization: Bearer test-key"You may briefly see queued, then:
{
"id": "qq-1",
"status": "sent",
"attempts": 1,
"receipt": {
"message_id": "1000",
"sent_at": "2026-11-08T..."
}
}Inspect what the fake server received:
curl -s http://127.0.0.1:3001/_messagesYou should see the text, target, and fake message id:
[
{
"kind": "private",
"user_id": 10001,
"text": "hello from gateway",
"message_id": 1000,
"at": "2026-11-08T..."
}
]Send a group message:
curl -s -X POST http://127.0.0.1:8081/api/v1/accounts/qq/send_group_msg_async \
-H "Authorization: Bearer test-key" \
-H "Content-Type: application/json" \
-d '{"group_id":20001,"message":"group hello"}'Reset the fake server:
curl -s -X POST http://127.0.0.1:3001/_resetHealth check:
curl -s http://127.0.0.1:8081/healthzCommon mistakes:
404 unknown account— the URL account must match an entry inaccounts.json.- Startup error
access_token is required for napcat— add a non-emptyaccess_token. - Startup error
client_token is required— add a gateway client token. - Startup error
client_token must differ from access_token— generate a separate client token, for example withopenssl rand -hex 32. - Delivery fails with
401— theaccess_tokendoes not match the fake’s-token. 202but nothing arrives — checkcurl http://127.0.0.1:3001/_messagesand the gateway logs.- Port
3001already in use — a real NapCat is probably running; stop it or change both the fake address and the account address. - The second message stays
queuedfor a few seconds — the default gap is 3s plus jitter.
Create the account file first; Compose mounts it into the gateway:
cp .env.example .env
cp accounts.example.json accounts.json
# keep the address as http://napcat:3001
# set a non-empty access_token and a distinct client_token; the Compose fake
# has no token configured and ignores the Authorization headerThe gateway network must exist before the fake stack starts:
docker compose up -d --build
docker compose -f docker-compose.fake-napcat.yml up -d --buildThen send through the gateway as usual. The gateway publishes
127.0.0.1:8081, and the fake publishes 127.0.0.1:3001, so the manual
curl commands above work unchanged:
curl http://127.0.0.1:3001/_messages
curl -X POST http://127.0.0.1:3001/_resetFrom clinic-backend, scripts/dev.sh --fake-napcat starts and stops this fake
stack automatically.
| Flag | Environment | Default | Description |
|---|---|---|---|
-addr |
FAKE_NAPCAT_ADDR |
:3001 |
Listen address. |
-token |
FAKE_NAPCAT_ACCESS_TOKEN |
empty | Require a bearer token. |
-login-id |
FAKE_NAPCAT_LOGIN_ID |
10000 |
Fake bot QQ id. |
-login-nickname |
FAKE_NAPCAT_LOGIN_NICKNAME |
fake-napcat |
Fake bot nickname. |
-friends |
FAKE_NAPCAT_FRIENDS |
10001 |
Comma-separated friend ids. |
-groups |
FAKE_NAPCAT_GROUPS |
20001 |
Comma-separated group ids. |
-members |
FAKE_NAPCAT_MEMBERS |
10001 |
Comma-separated member ids in every group. |
-fail-first |
FAKE_NAPCAT_FAIL_FIRST |
0 |
Fail the first N send attempts. |
-fail-mode |
FAKE_NAPCAT_FAIL_MODE |
transient |
transient, permanent, or http500. |
-delay |
FAKE_NAPCAT_DELAY |
0s |
Delay before send responses. |
-message-id-start |
FAKE_NAPCAT_MESSAGE_ID_START |
1000 |
First fake message id. |
Useful scenarios:
- Private message: default friends and members include
10001. - Group message: default groups include
20001. - Non-friend temporary session: set
FAKE_NAPCAT_FRIENDS=and keepFAKE_NAPCAT_MEMBERS=10001; send to user10001with"group_temp_session": trueinaccounts.json. - Transient retry: set
FAKE_NAPCAT_FAIL_FIRST=1and a shortMESSAGE_RETRY_BACKOFF. - Permanent drop: set
FAKE_NAPCAT_FAIL_FIRST=1andFAKE_NAPCAT_FAIL_MODE=permanent.
The fake accepts unauthenticated requests by default. When it is started
without -token, it ignores the gateway’s bearer token. An explicitly empty
FAKE_NAPCAT_FRIENDS, FAKE_NAPCAT_GROUPS, or FAKE_NAPCAT_MEMBERS clears
that list.
The fake and the real sample NapCat both use service name napcat and host port
3001. Do not run them at the same time. The fake is in-memory and dev-only; it
is not part of the production image.
The backend integration contract is account-scoped HTTP. In the backend .env:
# Host-run backend development:
MESSAGE_GATEWAY_URL=http://127.0.0.1:8081
# Inside Compose:
# MESSAGE_GATEWAY_URL=http://message-gateway:8081
MESSAGE_GATEWAY_ACCOUNT=qq
MESSAGE_GATEWAY_ACCOUNT_TOKEN=<the account's client_token from accounts.json>
MESSAGE_GATEWAY_TIMEOUT=5sKeep NAPCAT_BASE_URL empty in the backend so the legacy in-process manager
stays disabled. clinic-backend/scripts/dev.sh starts the gateway stack
automatically. It does not start NapCat; run the sample stack separately if
you need it locally, or use clinic-backend/scripts/dev.sh --fake-napcat for
testing without QQ.
| Variable | Default | Description |
|---|---|---|
HTTP_ADDR |
:8081 |
HTTP listen address. Compose fixes this at :8081 in the container. |
MESSAGE_ACCOUNTS_FILE |
required | JSON file with the configured accounts and their access/client tokens. |
MESSAGE_STATUS_CAPACITY |
4096 |
Delivery statuses kept for the status query endpoint. |
MESSAGE_QUEUE_SIZE |
100 |
Per-account queue capacity. |
MESSAGE_SEND_TIMEOUT |
30s |
Per-send timeout. |
MESSAGE_MAX_ATTEMPTS |
3 |
Attempts before a message is dropped. |
MESSAGE_RETRY_BACKOFF |
2s |
Delay after a transient failure. |
MESSAGE_GAP_MODE |
fixed |
fixed or length. |
MESSAGE_GAP_FIXED |
3s |
Base gap in fixed mode. |
MESSAGE_GAP_LENGTH_BASE |
1s |
Base gap in length mode. |
MESSAGE_GAP_PER_RUNE |
50ms |
Per-rune gap in length mode. |
MESSAGE_GAP_JITTER |
1s |
Random extra gap in [0, jitter). |
MESSAGE_GAP_MIN |
1s |
Lower bound for any gap. |
Account-specific settings (access_token, client_token, timeout,
proxy_timeout, directory_refresh, directory_retry, directory_timeout,
group_temp_session) live in accounts.json; see Accounts.
Gap calculation:
fixed:MESSAGE_GAP_FIXED + random[0, MESSAGE_GAP_JITTER)length:MESSAGE_GAP_LENGTH_BASE + MESSAGE_GAP_PER_RUNE × runeCount + random[0, MESSAGE_GAP_JITTER)- The result is never below
MESSAGE_GAP_MIN.
| Variable | Default | Description |
|---|---|---|
MESSAGE_GATEWAY_PORT |
8081 |
Loopback host port for the gateway. |
MESSAGE_GATEWAY_IMAGE_TAG |
latest |
Gateway image tag. |
Used only by docker-compose.napcat.yml.
| Variable | Default | Description |
|---|---|---|
NAPCAT_IMAGE_TAG |
latest |
NapCat image tag. |
NAPCAT_ONEBOT_PORT |
3001 |
Loopback host port for NapCat OneBot. |
NAPCAT_WEBUI_PORT |
6099 |
Loopback host port for the NapCat WebUI. |
NAPCAT_UID / NAPCAT_GID |
1000 |
UID/GID used by the NapCat container. |
NAPCAT_QUICK_ACCOUNT |
empty | QQ account to quick-login after the first QR login. |
NAPCAT_CONFIG_DIR |
./napcat/config |
NapCat config bind mount. |
NAPCAT_QQ_DIR |
./napcat/QQ |
NapCat login-state bind mount. |
- The queue is in-memory. Pending messages are lost when the process stops.
- Delivery statuses are kept in memory, bounded by
MESSAGE_STATUS_CAPACITY(oldest evicted first), and lost when the process stops. - One gateway instance serves every configured account. Each account has its own client, queue, pacing, and worker.
- Account config is read once at startup. Restart the service after editing
accounts.json; there is no hot reload. 202 Acceptedmeans the message was queued, not delivered. Query/api/v1/accounts/{account}/message-status/{delivery_id}for its outcome.sentmeans NapCat accepted the message; it does not prove it was read.- A client retry can duplicate a send because requests have no idempotency key.
- Send requests require positive numeric QQ target IDs before enqueueing.
GET /healthzis a liveness check. It does not report NapCat connectivity or directory readiness.- The gateway does not terminate TLS. Run it on a private network or behind a reverse proxy.
401 Unauthorized: no client token was supplied.403 Forbidden: the client token is invalid or belongs to another account. Use theclient_tokenof the account named in the request.429 Too Many Requests: the account queue is full. Clients can retry after theRetry-Afterinterval.202but no message arrives: checkdocker compose logs message-gatewayand NapCat's logs. Confirm the OneBot HTTP server is enabled on port3001, the account address inaccounts.jsonmatches it, and the QQ account is logged in. If you use the sample stack, inspect it withdocker compose -f docker-compose.napcat.yml logs napcat.- NapCat cannot write its data: fix ownership of
napcat/configandnapcat/QQto matchNAPCAT_UID/NAPCAT_GID.
cmd/message-gateway/ service entry point
cmd/fake-napcat/ dev-only fake OneBot server
internal/accounts/ per-account sender registry and driver factory
internal/config/ environment and accounts.json loading/validation
internal/fakenapcat/ fake OneBot implementation
internal/httpapi/ HTTP transport
internal/message/ platform-neutral message model, queue, and manager
internal/napcat/ OneBot 11 HTTP transport for NapCat
tests/ external tests
accounts.example.json documented account template
Dockerfile multi-stage gateway image
Dockerfile.fake-napcat dev-only fake image
docker-compose.yml gateway stack
docker-compose.napcat.yml optional sample NapCat stack
docker-compose.fake-napcat.yml optional fake NapCat stack
.env.example documented environment template
.github/workflows/ GHCR image publishing
go test ./...
go test -race ./...
go vet ./...The Dockerfile builds ghcr.io/bitnp/message-gateway. Pushing a v* tag runs
.github/workflows/docker-publish.yml, which publishes version, short-SHA, and
latest tags. Pin MESSAGE_GATEWAY_IMAGE_TAG in deployments.
For an offline server, pull and package the image on a connected machine, load
it on the server, then start the stack with --no-build.
The gateway is ready for outbound text messaging with multiple configured
NapCat accounts. The next integration step is to wire backend business callers
to MESSAGE_GATEWAY_URL with per-account tokens, then remove the frozen
clinic-backend/internal/message copy once nothing depends on it.
Hot reload, persistent queues, cancellation, replay across reconnects, inbound messages, and new drivers are intentionally out of scope.