A QQ group-management bot on the official QQ bot platform (App ID + App
Secret from https://q.qq.com), built with the
qq-official-bot SDK. It connects
over WebSocket and reacts to group, private (C2C), and guild message events.
- Group & private commands — responds to group @-mentions and private (C2C)
DMs; built-in commands are
ping,help, andnews [YYYY-MM-DD]. - Automatic ad moderation — screens group messages for Chinese spam via a naive-Bayes keyword score (likelihood-weighted hits vs. a threshold, with length-aware evidence: long messages weigh more, short ones less) plus regex patterns, recalls the message, tracks per-member strikes, and alerts an admin at the limit.
- Config-file rules — forbidden words, intensity and the probability params
all live in one editable
config/ad.json; ad keywords and patterns are additionally unioned with a remote text file (this repo'sdocs/ad-rules.txtby default) refreshed on a schedule, so you retune a running bot by editing files and pushing — no redeploy. - Daily AI news digest — a scheduled job polls RSS/Atom feeds (resolved from
a remote OPML), stores items in SQLite, and has an LLM (DeepSeek via an
Anthropic-compatible endpoint) write one summary a day; the
newscommand serves it instantly as Markdown with clickable per-source links, and the bot auto-pushes it to the groups listed inNEWS_PUSH_GROUPS. - Runs anywhere — ships as a public Docker image on ghcr.io: a port-less outbound WebSocket client configured entirely through environment variables.
History note: an earlier NapCatQQ + NcatBot (Python) implementation lives on the
napcatqqbranch;mainwas restarted from scratch for this stack.
src/
index.ts # entry point: creates the Bot, wires handlers, starts it
config.ts # loads & validates environment variables
handlers.ts # message handlers + a minimal command router
ad/ # ad moderation: keyword + regex detection, config-file rules, remote-rule refresh, strikes
news/ # daily AI news: feed fetch, SQLite storage, scheduler, LLM summarizer
config/
ad.json # ad moderation config: forbidden words, intensity, probability params
test/ # unit tests (node:test), run with pnpm test
- Node.js ≥ 22.13 (
nvm usepicks up.nvmrc; thenewscommand uses the built-innode:sqlite) - pnpm (
npm i -g pnpm) - A QQ bot registered at https://q.qq.com — its App ID and App Secret
pnpm install
cp .env.example .env
# then edit .env and fill in BOT_APPID / BOT_SECRETConfigured in .env (see .env.example for the full annotated list):
| Variable | Required | Description |
|---|---|---|
BOT_APPID |
✅ | QQ bot App ID |
BOT_SECRET |
✅ | QQ bot App Secret |
BOT_SANDBOX |
true for the sandbox environment (default false) |
|
LOG_LEVEL |
trace/debug/info/warn/error (default info) |
|
AD_STRIKE_LIMIT |
Ad offenses by one member before an admin alert (default 3) |
|
AD_CONFIG_PATH |
Path to config/ad.json — forbidden words, intensity and probability params (default config/ad.json) |
|
AD_RULES_URL |
Remote rules file unioned on top of the config-file lists (default: this repo's docs/ad-rules.txt; empty to disable) |
|
AD_RULES_REFRESH_MINUTES |
Refresh interval for the remote rules file, in minutes (default 360) |
|
NEWS_LLM_API_KEY |
Summarizer LLM key (falls back to DEEPSEEK_API_KEY; unset disables the daily job) |
|
NEWS_LLM_BASE_URL |
Anthropic-compatible endpoint (default https://api.deepseek.com/anthropic) |
|
NEWS_MODEL |
Summarization model (default deepseek-v4-pro) |
|
NEWS_MAX_TOKENS |
Max output tokens per summary request (default 64000) |
|
NEWS_OPML_URL |
OPML listing the feeds to poll (default: iread feeds in isomoes/arch-config) |
|
NEWS_FEEDS |
Offline fallback feeds if the OPML fetch fails (comma/newline separated) | |
NEWS_DB_PATH |
SQLite file for fetched news items (default data/news.db) |
|
NEWS_MAX_ITEMS |
Max items per day handed to the summarizer (default 30) |
|
NEWS_LOOKBACK_HOURS |
How far back a run collects items (default 24; raise for quiet feeds) |
|
NEWS_SUMMARY_HOUR |
Hour (UTC+8, 0–23) the daily summary job runs (default 22) |
|
NEWS_PUSH_GROUPS |
Group openids to auto-push the daily summary to (comma/newline separated; empty disables — see AI news) | |
NEWS_MANUAL_REFRESH |
Enable the news refresh command (dev only; off in production) |
|
NEWS_LANG |
Language of the generated summary (default Chinese) |
pnpm dev # development (hot reload)
pnpm typecheck # type-check only
pnpm build # production build
pnpm start # run the buildPrebuilt linux/amd64 images are on the GitHub Container Registry, so no
Node.js on the host is needed:
docker run -d --name qq-bot --init --restart unless-stopped --env-file .env \
-v qq-bot-data:/app/data \
ghcr.io/isomoes/qq-bot:latestThe bot is an outbound WebSocket client (no ports to map). The named
qq-bot-data volume persists the news SQLite database across container
replacements — a named volume rather than a host bind mount because the
container runs as the unprivileged node user (UID 1000), and Docker
initializes the volume writable by it. Credentials come from the environment
(--env-file .env); the image must be public, or the host must
docker login ghcr.io first.
To keep the DB in a host directory instead, use
-v "$PWD/data:/app/data"and runchown -R 1000:1000 ./datafirst — otherwise the non-root process can't create the file (SQLITE_CANTOPEN: unable to open database file).
A compose file is included (run it from the repo root):
docker compose -f docker/docker-compose.yml up -dAdd --build (or docker build -f docker/Dockerfile -t qq-bot .) to build from
the checkout instead of pulling.
Message the bot news for the latest daily summary, or news 2026-07-06 for a
specific day. The interactive path only reads a stored summary, so it makes no
LLM call.
Once a day at NEWS_SUMMARY_HOUR:00 UTC+8 (with a startup catch-up if the bot
was down), a scheduled job — modeled on
iread — resolves the feed list from
NEWS_OPML_URL (falling back to NEWS_FEEDS), fetches each feed with
conditional GET, stores deduplicated items in SQLite, and has the LLM
(NEWS_LLM_BASE_URL + NEWS_MODEL, DeepSeek by default) condense the day's
items (up to NEWS_MAX_ITEMS) into that day's summary.
The summary is a Markdown bullet list. The model writes only the prose plus a
[[n]] citation tag per entry, which the bot rewrites into a clickable
per-source link — so the model never emits raw URLs, and feed titles/snippets
are wrapped as untrusted <item> data a compromised feed can't use to steer the
output.
With NEWS_PUSH_GROUPS set, the freshly generated summary is also pushed
to each listed group right after the scheduled run. A failed send is retried a
few times on a 15-minute timer, and on every startup the last two days'
summaries are re-offered — so a crash, a transient send failure, or a fresh
deploy (which delivers the most recent stored digest immediately) doesn't drop
a push, while a per-group marker in SQLite ensures a group never receives the
same day's summary twice. Days with no news push nothing. Two prerequisites:
- Group openid — the ids in
NEWS_PUSH_GROUPSare the opaque per-bot openids, not the QQ group numbers shown in the client. To find one, @mention the bot in the target group and copy the id from therecv from Group(...)line in the logs. - Owner opt-in — pushes are "active" messages (QQ re-opened them for
groups on 2026-06-22): the group owner must enable
机器人主动在群聊内发言 in the group's robot settings, or sends fail with
code
40034102(logged with a hint).
During local dev (pnpm dev), news refresh re-runs the job on demand and
replies with the fresh summary; it's disabled in production (scheduled-only, via
NEWS_MANUAL_REFRESH). A dev refresh never triggers a group push — only the
scheduled job (and its startup catch-up) delivers to groups.
Group messages are screened for advertising (src/ad/). On a flag, the bot
recalls the message, adds a strike against the sender (per group, in
memory), and on reaching AD_STRIKE_LIMIT (default 3) replies once asking
an admin to remove the member.
Detection combines two signals — keywords scored with a naive-Bayes model, and regex patterns (flag on a single match). Keyword hits are precompiled into one case-insensitive matcher; every distinct hit is evidence weighted by a likelihood ratio (strong terms weigh far more than generic ones) and combined with a prior base rate:
P(ad | text) ≥ threshold ⇒ flag
Length evidence is calibrated to the group's chat habit — modern chat
messages are short, so suspicion grows continuously from the shortest messages
onward (lengthLr·ln(1 + len/chatLength), capped at the modest maxLengthLr):
no fixed 30-char cliff, and no amount of length can ever flag a message on its
own. No single indicator decides — a flag requires ad features to co-occur:
the message must meet the distinct-hit floor AND carry a strong keyword (or a
per-keyword LR override) or a suspicious URL. So a pile of generic words
(客服 咨询 QQ …), a lone keyword buried in a 500-char post, or a bare link is
never an ad on its own; the indicators only push each other.
Ads almost always carry a URL — but to a domain that is neither an official
site nor a major platform. URLs on the safeUrlDomains whitelist (jd.com,
bilibili.com, gov.cn, …) are normal sharing — a member recommending a product
page — and add nothing. A non-whitelisted URL adds ln(suspiciousUrlLr) to the
log-odds, and if it also sits on a spam-prone TLD (.top, .xyz, .icu,
.cc, …; suspiciousTlds) a further small ln(suspiciousTldLr). A short
recommendation with a link and a couple of generic words still passes; the same
words in a long message with a sketchy link flags. Keywords repeated many times
are discounted (repeatDiminish^(count-1)) — repeating one sensitive word is
attention-seeking, and a real ad wouldn't be that low-effort — while packing
several different ad keywords still flags, and generic hits have diminishing
returns (weakDiminish).
All of this — the forbidden words, their intensity and the probability params —
lives in one config file, config/ad.json, the single source
of truth. Edit it at runtime, no source changes needed:
A missing or corrupt file (or an invalid field) falls back to the bundled
defaults in src/ad/, and the bot logs what it dropped at startup. Override the
path with AD_CONFIG_PATH. On top of the config file, one remote rules file is
still unioned as an optional live-update layer, refreshed every
AD_RULES_REFRESH_MINUTES. By default that file is this repo's own
docs/ad-rules.txt:
[keywords]
促销
代购
[strong]
加V
扫码
[patterns]
拉[你您]进[群裙]
/加\s*薇\s*[::]?\s*[a-z0-9_-]{4,20}/iEdit docs/ad-rules.txt and push to retune a running bot on its next refresh;
override the source with AD_RULES_URL, or set it empty to disable remote rules.
A fetch failure leaves the config-file lists (plus the last good file) in
effect, so detection never drops below the configured baseline.
The keyword seed derives from the MIT-licensed
konsheng/Sensitive-lexicon
and includes generic terms. Because of the likelihood weighting these no longer
false-flag on their own; to tune, raise threshold / minKeywordHits for fewer
flags or lower them for more, or move a term out of strongKeywords. Patterns
are one regex per line (bare source, or /source/flags) and validated at load —
invalid, over-long, or catastrophically slow ones are skipped and logged, and
stateful g/y flags are dropped.
QQ official-API limits: the group API cannot mute or kick, so removal stays a manual admin action (the bot only alerts); a public-domain bot only receives messages that @mention it; and recalling another member's message is best-effort (it may require permission).
⚠️ ReDoS: patterns run against every message with no timeout — a nested-quantifier pattern like(a+)+can hang the bot. The load-time timing screen catches common cases but isn't a guarantee; keep patterns specific and anchored, or swap inre2for a linear-time engine.
{ "prior": 0.02, // prior P(ad) of an arbitrary message "threshold": 0.6, // P(ad|text) at which a message is flagged "strongLr": 40, // likelihood ratio of a strong keyword hit "weakLr": 2.5, // likelihood ratio of a generic keyword hit "lengthLr": 0.35, // length-evidence growth per ln(1 + len/chatLength) "chatLength": 10, // reference chat-message length: dampening ends here, length evidence grows beyond "maxLengthLr": 0.5, // cap on the length-evidence term — length can tip, never decide "shortKeywordFactor": 0.5, // keyword-evidence multiplier for the shortest messages "repeatDiminish": 0.7, // a keyword repeated count times weighs 0.7^(count-1) (刷屏 ≠ 广告) "weakDiminish": 1.5, // n-th distinct generic hit weighs min(1, 1.5/n) "suspiciousUrlLr": 8, // LR of a URL outside the safe whitelist "suspiciousTldLr": 2, // extra LR when that URL uses a spam-prone TLD (.top/.xyz/.icu…) "minKeywordHits": 2, // distinct-hit floor before the keyword path runs "safeUrlDomains": ["jd.com", "bilibili.com", "gov.cn"], // official/major platforms: URL adds nothing "suspiciousTlds": ["top", "xyz", "icu", "cc"], // spam-prone TLDs: slightly more doubt "keywords": ["促销", "代购"], "strongKeywords": ["加V", "扫码"], "keywordLrs": { "刷单": 100, "押题": 80 }, // per-keyword intensity (违禁强度) "patterns": ["/加[V微]信?\\s*\\w+/"] }