# qqbridge-plus — MaimaiDX score lookup + chat extensions **A plugin built on [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) and [qq-bridge](https://github.com/Derpyu520/qq-bridge): primarily Diving-Fish (MaimaiDX) score lookup, plus a set of chat extensions.** Broken down: - **Diving-Fish score lookup** — `#查分` in QQ for a MaimaiDX score, rendered as a B50 image with song artwork. It uses OAuth, is open to everyone rather than admins only, and **does not depend on the bridge**: it works even if qq-bridge is not installed. - **Chat extensions** — qq-bridge's operational abilities wired into the AI's tool surface: balance and auto-shutdown, reasoning tier switching with billing peaks, the holiday table, startup / shutdown greetings, and the `#功能` toggles. > This code was generated by AI. Many thanks to > [Derpyu520](https://github.com/Derpyu520) for building > [qq-bridge](https://github.com/Derpyu520/qq-bridge) — without it this plugin would have no > reason to exist. English | [中文](README.md) --- ## What you get Once installed you can just ask the AI "what's the bot's balance?" or "what state is it in?", instead of digging through logs. Anyone in the group can also send `#查分` to look up a MaimaiDX score and get a B50 card with song artwork. And the admin can send `#功能 balance off` in QQ to turn a query off — it takes effect immediately, no file editing, no restart. Here is what you can look up: | Query | What comes back | | --- | --- | | Runtime | current mode, whether DSH is ready, the allowlist, the owner, unread count and wake state per conversation | | Balance | how much is left, how fresh that number is, the alert threshold, and how long until it actually shuts down | | Reasoning tier | which tier is active right now. The schedule switches to the cheaper tier during DeepSeek's billing peak and back to the good one off-peak, which saves a bit of money | | Holiday table | whether the table still covers you; when a year is missing you can pull the State Council notice and see what it would parse to | | Greetings | which personas have a startup/shutdown line configured, and where those go | | Admin commands | triggers and permission rules for `#余额` `#关机` `#功能` `#帮助` | | MaimaiDX scores | `#查分` in QQ for a B50, rendered as an image with song artwork; independent of qq-bridge, and open to everyone | | Bridge config | the config itself, with tokens masked | --- ## How it works inside None of the decision logic is re-implemented here. Billing peak/off-peak, holiday parsing, wake filtering and balance alerts are all `import`ed straight from qq-bridge's own pure modules. The plugin only fetches data and formats text. ``` qqbridge-plus ──import──▶ qq-bridge/src/wake-filters.mjs (billing / holidays / wake / balance) ──import──▶ qq-bridge/src/whale-balance.js (balance ledger) ──HTTP────▶ qq-bridge console 127.0.0.1:3100 (runtime: mode, sessions, tier) ◀──HTTP──── QQ admin commands forwarded by qq-bridge ``` The reason is that qq-bridge is the authority on the QQ side: the SnowLuma connection, the allowlists, session mapping and timers all live in its process. A second copy of the decision logic would drift from the first sooner or later, and during development it actually did. With a `NaN` balance, one side judged `recovered` while the other judged `none` — and that is the branch that decides whether to shut the bot down. The feature-key list works the same way: the bridge does not keep its own copy, otherwise the plugin adds a feature and the bridge starts rejecting a perfectly valid command first. When the bridge is not running the plugin does not throw. The tool tells you the console is unreachable and how to go about checking it. --- ## Installing it ### 1. Prerequisites | Needed | Why | | --- | --- | | Node.js ≥ 18 | required by both DSH and qq-bridge | | git | to clone this repo (skippable for a manual install from a zip) | | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | the plugin host. Must have been **started at least once**, otherwise the profile directory does not exist yet | | [qq-bridge](https://github.com/Derpyu520/qq-bridge) | the plugin `import`s its pure modules and calls its endpoints, so it must be installed first | > You do not need to tell the installer where qq-bridge is: by default it looks for a `qq-bridge` > directory next to the plugin. If that is not where yours lives, pass `--bridge-dir`. ### 2. Install #### Option A: automated (recommended) **Dry-run first** to see exactly what it would change (worth doing the first time): ```bat node "%USERPROFILE%\dsh-plugins\qqbridge-plus\scripts\install.mjs" ^ --dir "%USERPROFILE%\dsh-plugins\qqbridge-plus" --dry-run ``` Drop `--dry-run` once it looks right. Full flow (clone plus install): **Windows (cmd or PowerShell)** ```bat git clone https://github.com/xueyingneko/qqbridge-plus.git "%USERPROFILE%\dsh-plugins\qqbridge-plus" node "%USERPROFILE%\dsh-plugins\qqbridge-plus\scripts\install.mjs" --dir "%USERPROFILE%\dsh-plugins\qqbridge-plus" ``` **Linux / macOS / Git Bash** ```bash git clone https://github.com/xueyingneko/qqbridge-plus.git ~/dsh-plugins/qqbridge-plus node ~/dsh-plugins/qqbridge-plus/scripts/install.mjs --dir ~/dsh-plugins/qqbridge-plus ``` **If qq-bridge is not next to the plugin**: ```bat node "...\scripts\install.mjs" --dir "...\qqbridge-plus" --bridge-dir "D:\path\to\qq-bridge" ``` **To install into the web profile instead of desktop**: add `--profile web`. **Already inside the repo?** The wrapper script figures that out on its own: ```bat scripts\install.cmd --dry-run ``` What the installer does — every step is idempotent, so re-running writes nothing twice: 1. clones the repo, or runs `git pull` on an existing one; 2. installs the single npm dependency, `@deepseek-ai/schemastery`, in the plugin directory; 3. registers the package in the profile's `package.json`: a `link:` entry under `dependencies`, and the package name appended to `dsh.profile.bundles`; 4. appends the plugin entry to the profile's `cordis.patch.yml` (skipped if already present); 5. has the package manager create the `node_modules` link inside the profile (pnpm preferred, to match the profile's own lockfile); 6. checks whether qq-bridge is in place and prints the next steps. Profile files are backed up before being modified, as `package.json.bak-`, and writes go through a temp file plus rename so another process can never read a half-written file. **Uninstall**: remove `dsh-plugin-qqbridge-plus` from `dependencies` and `dsh.profile.bundles` in the profile's `package.json`, delete the entry carrying the `# === dsh-plugin-qqbridge-plus (managed by install.mjs) ===` marker from `cordis.patch.yml`, and restart DSH. #### Option B: manual Use this if you want fine control (managing the symlink yourself, installing into several profiles). **Step one, register in the profile**: add the package to `~/.dsh/profiles//package.json` under `dependencies` (with `link:` pointing at this repo), and to `dsh.profile.bundles` as well: ```json { "dependencies": { "dsh-plugin-qqbridge-plus": "link:/abs/path/to/qqbridge-plus" }, "dsh": { "profile": { "bundles": ["...", "dsh-plugin-qqbridge-plus"] } } } ``` Then run `pnpm install` (or `npm install`) in the profile directory to create the link. **Step two, add an entry to the profile's `cordis.patch.yml`**: ```yaml - id: qqbridge-plus name: dsh-plugin-qqbridge-plus config: bridgeDir: '/abs/path/to/qq-bridge' # ← point at your qq-bridge checkout consoleBase: 'http://127.0.0.1:3100' personaSection: true sectionOrder: 7 features: status: true balance: true schedule: true holidays: true greeting: true commands: true config: true ``` The `id` must be `qqbridge-plus` (DSH uses the entry id to locate the settings namespace) and the `name` must be the package name. Step two is not optional. A bundle's own `cordis.patch.yml` only describes which rows that bundle carries; the profile-level patch is what makes the loader create the entry. With step one only, the plugin manager reports `installed=true` and lists the row, yet the tool does not exist at all — and no error is printed. **Step three, restart DSH.** ### 3. Verify it installed After restarting DSH, either of these will tell you: - ask the AI for the bot's status — it should reach the `qqbridge` tool; - or look at `state/plugin-apply.json` in the bridge directory, whose `trace` records this assembly: ```json { "trace": [ { "phase": "apply-called" }, { "phase": "tool-registered" }, { "phase": "http-api-registered" }, { "phase": "apply-returned" } ] } ``` A trace that stops at `apply-called` means assembly failed partway; no file at all means the plugin was never loaded. ### 4. Enable QQ-side toggling (optional) In qq-bridge's `config.json`: ```json "featureCommand": { "triggers": ["#功能", "#开关"], "baseUrl": "http://127.0.0.1:19387" }, "helpCommand": { "triggers": ["#帮助", "#help"] } ``` `baseUrl` points at the DSH web server, where the plugin endpoint is mounted. Then restart qq-bridge. --- ## Usage in QQ ### Admin commands (admin only) ``` #帮助 list all admin commands (triggers rendered from current config) #功能 show all toggles (values changed from QQ are marked *) #功能 balance off disable a feature (also 关 / 禁用 / off / 0) #功能 balance 开 enable (also 启用 / on / 1) #功能 reset clear runtime overrides, back to the config file values ``` - Triggers are configurable (`featureCommand.triggers` / `helpCommand.triggers`). - The help text is assembled from the configured triggers, so it follows any change you make and can never contradict your setup. - Admin only (qq-bridge's `ownerQQ`). With no `ownerQQ` configured these commands are unusable for everyone. ### Score lookup (everyone) ``` #查分 is score lookup usable on this machine #查分 柚子 just a username = public B50 lookup #查分 b50 柚子 same, with the sub-command written out #查分 b50 my own B50 (requires a completed binding first) #查分 b50 qq 12345678 a specific QQ (they must have bound first) #查分 song 11823 the chart constants for one song #查分 search ztn fuzzy song search, abbreviations work #查分 bind start binding; the auth link arrives by DM #查分 confirm 123456 finish binding with the confirmation code ``` - **Not admin-only** — anyone can look up their own scores. "My own" requires a one-time binding, and binding requires **you personally** to click "authorize" on the Diving-Fish page. - Triggers are configurable (`maimaiCommand.triggers`, defaults `#查分` / `#maimai`). - A leading ideographic space or zero-width character is stripped first — invisible, yet enough to make `#查分` fail to match. - The link and confirmation code from `bind` / `confirm` go **only by DM**; the group at most sees "sent to your DM". That is not needless caution: a link can be forwarded by anyone, while the confirmation code only ever appears in the browser of whoever clicked authorize. --- ## What the AI can call One tool, `qqbridge`, with 10 actions: | action | Purpose | | --- | --- | | `status` | Overview: mode / readiness / owner / allowlist / balance / tier / sessions | | `balance` | Balance, observation freshness, thresholds, auto-shutdown countdown | | `schedule` | Billing peak/off-peak and tier; `testAt=[ts…]` evaluates any moment | | `holidays` | Holiday table coverage; `fetch=true` performs a live dry-run parse | | `greeting` | Startup / shutdown greeting config, targets, per-persona coverage | | `commands` | `#余额` `#关机` triggers and permission rules | | `config` | Bridge config summary (tokens redacted); `key="role.balanceAlarm"` for a subtree | | `features` | Feature toggles: view, or modify with `set`/`enabled`, or `reset` | | `maimai` | MaimaiDX score lookup (see the next section) | | `firstRun` | Re-read the first-run guidance | --- ## MaimaiDX score lookup Talks to the [Diving-Fish prober](https://diving-fish.com). This part is **unrelated to qq-bridge** — it works even if the bridge is not installed. ``` qqbridge action=maimai, sub=status is lookup usable on this machine qqbridge action=maimai, sub=b50, username=someone public lookup (they must have made scores public) qqbridge action=maimai, sub=b50, qq= my own (requires a completed OAuth binding) qqbridge action=maimai, sub=song, songId=11823 the constants for one song qqbridge action=maimai, sub=search, query=ztn fuzzy song search (abbreviations work) qqbridge action=maimai, sub=bind, qq= start binding, returns the auth link qqbridge action=maimai, sub=confirm, qq=, code= finish binding ``` `sub=b50` renders a **score image** (1080 px wide PNG) by default, writes it to the system temp directory and hands you the path; add `image=false` if you do not want the image. ### Why there is no "look up any QQ number" Diving-Fish has retired the developer token (`DIVINGFISH_TOKEN`). The reason: it could **read any user's scores by QQ number**, and those users never authorized the bot and cannot revoke it — a "score lookup" built on it is really a tool for prying into other people's records without consent. So this plugin implements **OAuth only**: | Step | How it works | | --- | --- | | Credentials the bot holds | Only your own application's `clientId` / `clientSecret` | | QQ number | Sent out only as `sha256(":")`; **the number itself never leaves the bot** | | User token | Cached **in memory only** (5 minutes, invalidated 30 s early), **never written to disk** | | Binding | User runs `bind` to get a link → clicks "authorize" personally → sends back the **one-time confirmation code** shown on that page | | Revoking | Users can revoke any time at https://auth.diving-fish.com/apps | | Unbound users | Can still use public lookup (by username) | The confirmation code is not red tape: a binding link can be forwarded by anyone, while the code only ever appears in the browser of whoever clicked authorize. Without it, someone could craft a link with your `clientId`, send it to a victim, and bind that victim's account to themselves. ### Configuration ```yaml maimai: clientId: 'your application ID' # apply at https://auth.diving-fish.com/apps clientSecret: 'your application secret' proxy: false # enable on servers outside mainland China ``` When applying, pick **"bot / tool"** as the integration type (device-code binding plus token exchange, no callback URL needed) — that is exactly this plugin's flow. Choose "run it yourself" as the deployment type (confidential client, uses `client_secret`). **Only three scopes need ticking** (all read-only): | Scope | Why it is needed | | --- | --- | | `profile` | basic account information | | `prober.profile.read` | Rating, name plate and similar summary data | | `prober.records.read` | **the per-chart records behind B50** (without it queries return 403) | **Do not tick any `write` scope** — this plugin has no write path at all. It would also push your application into manual review, which only slows you down. `chunithm.*` (Chunithm) is irrelevant here. When left empty the plugin does not fail silently; it tells you to go apply (`sub=status` reports the current state). ### The score image needs `@napi-rs/canvas` **The installer installs it for you** (step 2), and then **actually renders a PNG** before reporting success — it does not just check "the command returned 0". The reason: it is a native module (N-API prebuilt) that can install yet fail to load on some platforms/architectures, and reporting only "install succeeded" would make you believe images work until you actually look up a score. ### Song search Search uses subsequence matching, so abbreviations work too: `ztn` finds `Zitronectar`. Results are ranked by match quality (exact > prefix > substring > artist > subsequence), and queries shorter than 3 characters skip the subsequence fallback to avoid false hits. ### About song jackets The song artwork on score cards is **SEGA game material**, and this repository is MIT, so it **cannot be bundled here**. The approach is therefore: **you supply the assets, the plugin only reads them.** Without them there is no artwork and cards fall back to a stable colour strip derived from the song title (the same song always gets the same colour); nothing else changes. With assets in place you get jacket art on all 50 cards. Measured against a real B50: **49/50 hits**. #### How to get the jackets One command syncs them from a public CN song database: ``` #查分 sync downloads into the directory set in maimai.jacketDir ``` ``` === 曲绘同步 === 目录:E:/qqbridge-plus/jackets 曲数据库共 1293 首,本地已有 0 张 本轮下载 52 张(3.1 MB) 还差 1241 张,再执行一次即可继续(增量,已下载的不会重下)。 (GitHub 接口额度 6/60,每小时重置) ``` > **That song database declares no license** (no license means all rights reserved). So it is > neither bundled in this repository nor redistributed by the plugin — **your own machine performs > the download and the files land on your own disk**; the plugin is only a reader. Check the asset > licensing yourself if you intend to use or redistribute it commercially. > Source: **Be patient**: GitHub's unauthenticated API allows only **60 requests per hour**, and the database has 1293 songs, so one run cannot finish. Sync is therefore **incremental and resumable** — just run it again; what is already downloaded is not re-fetched. To let it finish on its own, run the persistent process (it waits for the quota to reset instead of busy-looping): ```bash node scripts/sync-jackets.mjs --dir E:/qqbridge-plus/jackets ``` > Two pitfalls found in testing, both recorded in code comments: (1) `raw.githubusercontent.com` > returns 502 on some networks, so the sync uses `api.github.com`; (2) Node may report > `UNABLE_TO_VERIFY_LEAF_SIGNATURE` for GitHub (the system store has a chain Node's bundled CAs do > not trust) — the sync **merges the system CA store automatically**, no CLI flag needed. #### Directory layout A filename containing the **song id** is most reliable (`11576.png`); `by-title/.png` also works (that is what the sync writes). Matching is **case-insensitive** — the database contains both `Trust` and `TRUST`, which are the same file on Windows but two files on Linux. ## The switches One boolean per feature, `false` means off, with `'*'` as a fallback. All on by default. Precedence: ``` QQ / runtime override file > composition config > code default (all on) ``` In other words, what was changed in QQ beats what is written in the config. Overrides live in `qq-bridge/state/plugin-features.json`, written atomically (temp file plus rename) so another process can never read half a JSON. They survive a DSH restart, and `#功能 reset` clears them. A corrupt state file is treated as "no overrides" and the reason is recorded; it never stops the plugin from starting. Eight keys, all on by default: | Key | Capability | | --- | --- | | `status` | Overview | | `balance` | Balance and auto-shutdown | | `schedule` | Billing peak/off-peak and reasoning tier | | `holidays` | Holiday table | | `greeting` | Startup / shutdown greetings | | `commands` | Admin command status | | `config` | Bridge config summary | | `maimai` | MaimaiDX score lookup | ### What happens when a feature is off - The action stays in the enum and answers with a clear sentence rather than being removed: ``` ⛔ 功能「余额与自动关机」已在插件配置里关闭。 当前开启的功能:status(总览)、schedule(计费峰谷与推理档位)… ``` It is not removed from the enum because the tool schema is serialized at `defineTool()` time and cannot be added to or removed from afterwards. Rather than fake "the config changed but the enum didn't", the off state is left visible. - The matching section in `status` states the reason: `⛔ 已关闭(features.balance=false)`. - The system prompt is generated from the toggles, so disabled features are not advertised and the model does not waste an attempt on them. - A misspelled toggle name is reported and recorded in `plugin-apply.json`'s trace. The usual trap with this kind of switch is writing `scheduler` instead of `schedule`, which looks like "set but not working". --- ## Three hard rules All learned the hard way. Worth reading before changing the code. ### 1. `inject` must list every service you will touch Cordis throws when you access a service property that was not declared in `inject`: ``` Error: cannot get property "systemPrompt" without inject ``` Only `tools` was declared at first, with `if (ctx.systemPrompt)` intended as an optional-dependency fallback — but reading the property itself throws, so the fallback never runs. In the plugin manager this shows up only as `[failed]` or "did not activate", with no reason given. ### 2. Do not export `Config` unless it is a real schemastery schema When the loader sees a `Config` export it validates against it and expects it to come from `@deepseek-ai/schemastery`. A plain object fails before `apply` ever runs (the entry is created, `apply` does not execute a single line). And schemastery is a bare specifier: if the plugin directory has no `node_modules` of its own it cannot be resolved, and the whole module fails at import time. There is a second measured boundary: `volatile` fields make the loader evaluate the config into `{}`. A writable DSH settings panel requires `volatile`, but measurement (raw evidence kept in `lib/last-apply-config.json`) showed that after evaluation every volatile field becomes `{}`, so `sectionOrder` is no longer a number and `order must be a finite number` makes the plugin vanish entirely. Even bypassing that, pressing Save in the settings panel would corrupt the config. So this plugin's settings panel is read-only, and changing switches is done through QQ commands or the config file. A test asserts this stays true. ### 3. `defineTool` must be a top-level static import `lib/index.js` uses a static import with a hard-coded absolute path, the same approach official plugins take. The first attempt used a runtime dynamic `import()` with 7 fallback paths for "safety", and produced the hardest failure of all: one path reported import success, yet the `.then()` on the chain never ran — no throw, no catch — so the tool was simply never registered. A plain node subprocess cannot read inside `app.asar`, so this module cannot be imported by plain node. That is an Electron asar property, not a defect; use the tests under `test/`, which do not import that file. **Self-check for troubleshooting**: assembly writes `qq-bridge/state/plugin-apply.json`, whose `trace` array records how far it got. The host console is invisible in the GUI and loader startup failures give no reason, so this file is the only judge that requires no guessing. --- ## Version pairing The plugin and qq-bridge are a cross-repo dependency. qq-bridge reports both versions at startup: ``` [bridge] 版本:qq-bridge 0.2.0 [bridge] 插件:dsh-plugin-qqbridge-plus 1.1.1 ``` Each side takes its version solely from its own `package.json` `version`, with no second copy. Also learned the hard way: a version constant was once hard-coded in a module and contradicted `package.json`, which amounts to having no trustworthy version at all. --- ## Tests ```bash npm test # plugin side: 52 + 21 node ../qq-bridge/scripts/test-feature-command.mjs # bridge pure functions: 28 node ../qq-bridge/scripts/e2e-feature-command.mjs # end-to-end: 10 (both sides running) ``` CI runs only the two suites that do not need qq-bridge (52 + 21). The two that need a running bridge stay local — a CI that always fails is worse than no CI. Key safety and correctness assertions: | Assertion | Why it matters | | --- | --- | | `config` summary must not contain an unredacted `accessToken` | model context is retained long-term | | A misspelled toggle must not disable any feature | "set but not working" is the hardest to diagnose | | No field may carry `volatile` | it makes the loader evaluate config into `{}`, corrupting it on save | | The endpoint must reject or not register without a token | it can widen the entire tool surface | | `#功能xyz` must not be treated as a command | a false positive swallows ordinary chat | --- ## License [MIT](LICENSE). Third-party components and compliance boundaries (including the SnowLuma license discussion and the data-egress list) are in the second half of LICENSE.